Skip to content

BigUpdates #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
MIT License

Copyright (c) 2025 Bitshost

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
102 changes: 97 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ OpenAPI (Swagger) docs, and zero code generation.

---

## 🚀 Features
## 🚀 ## 🚀 Features

- Auto-discovers tables and columns
- Full CRUD endpoints for any table
- Configurable authentication (API Key, Basic Auth, JWT, or none)
- Advanced query features: filtering, sorting, pagination
- RBAC: per-table role-based access control
- Admin panel (minimal)
- OpenAPI (Swagger) JSON endpoint for instant docs
- Clean PSR-4 codebase
- PHPUnit tests and extensible architecture
Expand Down Expand Up @@ -110,6 +113,94 @@ curl -u admin:secret "http://localhost/index.php?action=list&table=users"

---


### 🔄 Advanced Query Features (Filtering, Sorting, Pagination)

The `list` action endpoint now supports advanced query parameters:

| Parameter | Type | Description |
|--------------|---------|---------------------------------------------------------------------------------------------------|
| `filter` | string | Filter rows by column values. Format: `filter=col1:value1,col2:value2`. Use `%` for wildcards. |
| `sort` | string | Sort by columns. Comma-separated. Use `-` prefix for DESC. Example: `sort=-created_at,name` |
| `page` | int | Page number (1-based). Default: `1` |
| `page_size` | int | Number of rows per page (max 100). Default: `20` |

**Examples:**

- `GET /index.php?action=list&table=users&filter=name:Alice`
- `GET /index.php?action=list&table=users&sort=-created_at,name`
- `GET /index.php?action=list&table=users&page=2&page_size=10`
- `GET /index.php?action=list&table=users&filter=email:%gmail.com&sort=name&page=1&page_size=5`

**Response:**
```json
{
"data": [ ... array of rows ... ],
"meta": {
"total": 47,
"page": 2,
"page_size": 10,
"pages": 5
}
}
```

---

### 📝 OpenAPI Path Example

For `/index.php?action=list&table={table}`:

```yaml
get:
summary: List rows in {table} with optional filtering, sorting, and pagination
parameters:
- name: table
in: query
required: true
schema: { type: string }
- name: filter
in: query
required: false
schema: { type: string }
description: |
Filter rows by column values. Example: filter=name:Alice,email:%gmail.com
- name: sort
in: query
required: false
schema: { type: string }
description: |
Sort by columns. Example: sort=-created_at,name
- name: page
in: query
required: false
schema: { type: integer, default: 1 }
description: Page number (1-based)
- name: page_size
in: query
required: false
schema: { type: integer, default: 20, maximum: 100 }
description: Number of rows per page (max 100)
responses:
'200':
description: List of rows with pagination meta
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { type: object }
meta:
type: object
properties:
total: { type: integer }
page: { type: integer }
page_size: { type: integer }
pages: { type: integer }
```

## 🛡️ Security Notes

- **Enable authentication for any public deployment!**
Expand All @@ -128,10 +219,11 @@ curl -u admin:secret "http://localhost/index.php?action=list&table=users"

## 🗺️ Roadmap

- RESTful route aliases (`/users/1`)
- OAuth2 provider integration
- More DB support (Postgres, SQLite)
- Pagination, filtering, relations
- Relations / Linked Data (auto-join, populate, or expand related records)
- API Versioning (when needed)
- OAuth/SSO (if targeting SaaS/public)
- More DB support (Postgres, SQLite, etc.)
- Analytics & promotion endpoints

---

Expand Down
36 changes: 27 additions & 9 deletions config/api.example.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
<?php
return [
'auth_enabled' => false, // true to require authentication
'auth_method' => 'apikey', // 'apikey', 'basic', 'jwt', 'oauth'
// ... existing config ...
'auth_enabled' => true,
'auth_method' => 'basic', // or 'apikey', 'jwt', etc.
'api_keys' => ['changeme123'],
'basic_users' => ['admin' => 'secret'],
'jwt_secret' => 'YourSuperSecretKey',
'jwt_issuer' => 'yourdomain.com',
'jwt_audience' => 'yourdomain.com',
'oauth_providers' => [
// 'google' => ['client_id' => '', 'client_secret' => '', ...]
]
'basic_users' => [
'admin' => 'secret',
'user' => 'userpass'
],
// RBAC config: map users to roles, and roles to table permissions
'roles' => [
'admin' => [
// full access
'*' => ['list', 'read', 'create', 'update', 'delete']
],
'readonly' => [
// read only on all tables
'*' => ['list', 'read']
],
'users_manager' => [
'users' => ['list', 'read', 'create', 'update'],
'orders' => ['list', 'read']
]
],
// Map users to roles
'user_roles' => [
'admin' => 'admin',
'user' => 'readonly'
],
];
3 changes: 3 additions & 0 deletions public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

require_once __DIR__ . '/../vendor/autoload.php';

// Add this line if admin React is enabled.
// \App\Cors::sendHeaders();

use App\Database;
use App\Router;
use App\Authenticator;
Expand Down
119 changes: 112 additions & 7 deletions src/ApiGenerator.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

namespace App;

use PDO;
Expand All @@ -14,10 +15,91 @@ public function __construct(PDO $pdo)
$this->inspector = new SchemaInspector($pdo);
}

public function list(string $table): array
/**
* Enhanced list: supports filtering, sorting, pagination.
*/
public function list(string $table, array $opts = []): array
{
$stmt = $this->pdo->query("SELECT * FROM `$table`");
return $stmt->fetchAll(PDO::FETCH_ASSOC);
$columns = $this->inspector->getColumns($table);
$colNames = array_column($columns, 'Field');

// --- Filtering ---
$where = [];
$params = [];
if (!empty($opts['filter'])) {
// Example filter: ['name:Alice', 'email:gmail.com']
$filters = explode(',', $opts['filter']);
foreach ($filters as $f) {
$parts = explode(':', $f, 2);
if (count($parts) === 2 && in_array($parts[0], $colNames, true)) {
$col = $parts[0];
$val = $parts[1];
// Use LIKE for partial match, = for exact
if (str_contains($val, '%')) {
$where[] = "`$col` LIKE :$col";
$params[$col] = $val;
} else {
$where[] = "`$col` = :$col";
$params[$col] = $val;
}
}
}
}

// --- Sorting ---
$orderBy = '';
if (!empty($opts['sort'])) {
$orders = [];
$sorts = explode(',', $opts['sort']);
foreach ($sorts as $sort) {
$direction = 'ASC';
$col = $sort;
if (str_starts_with($sort, '-')) {
$direction = 'DESC';
$col = substr($sort, 1);
}
if (in_array($col, $colNames, true)) {
$orders[] = "`$col` $direction";
}
}
if ($orders) {
$orderBy = 'ORDER BY ' . implode(', ', $orders);
}
}

// --- Pagination ---
$page = max(1, (int)($opts['page'] ?? 1));
$pageSize = max(1, min(100, (int)($opts['page_size'] ?? 20))); // max 100 rows per page
$offset = ($page - 1) * $pageSize;
$limit = "LIMIT $pageSize OFFSET $offset";

$sql = "SELECT * FROM `$table`";
if ($where) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
if ($orderBy) {
$sql .= ' ' . $orderBy;
}
$sql .= " $limit";

$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

// Optionally: include pagination meta info
$countStmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table`" . ($where ? ' WHERE ' . implode(' AND ', $where) : ''));
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();

return [
'data' => $rows,
'meta' => [
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
'pages' => (int)ceil($total / $pageSize)
]
];
}

public function read(string $table, $id): ?array
Expand Down Expand Up @@ -52,6 +134,10 @@ public function update(string $table, $id, array $data): array
foreach ($data as $col => $val) {
$sets[] = "`$col` = :$col";
}
// Handle no fields to update
if (empty($sets)) {
return ["error" => "No fields to update. Send at least one column."];
}
$sql = sprintf(
"UPDATE `%s` SET %s WHERE `$pk` = :id",
$table,
Expand All @@ -60,13 +146,32 @@ public function update(string $table, $id, array $data): array
$stmt = $this->pdo->prepare($sql);
$data['id'] = $id;
$stmt->execute($data);
return $this->read($table, $id);
// Check if any row was actually updated
if ($stmt->rowCount() === 0) {
// Check if the row exists at all
$existing = $this->read($table, $id);
if ($existing === null) {
return ["error" => "Item with id $id not found in $table."];
} else {
// The row exists but there was no change (e.g., same data)
return $existing;
}
}
$updated = $this->read($table, $id);
if ($updated === null) {
return ["error" => "Unexpected error: item not found after update."];
}
return $updated;
}

public function delete(string $table, $id): bool
public function delete(string $table, $id): array
{
$pk = $this->inspector->getPrimaryKey($table);
$stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `$pk` = :id");
return $stmt->execute(['id' => $id]);
$stmt->execute(['id' => $id]);
if ($stmt->rowCount() === 0) {
return ['error' => "Item with id $id not found in $table."];
}
return ['success' => true];
}
}
}
37 changes: 36 additions & 1 deletion src/Authenticator.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

namespace App;

use Firebase\JWT\JWT;
Expand Down Expand Up @@ -117,4 +118,38 @@ private function requireBasicAuth()
echo json_encode(['error' => 'Unauthorized']);
exit;
}
}

// ... existing code ...

public function getCurrentUser(): ?string
{
// Basic Auth
if ($this->config['auth_method'] === 'basic' && isset($_SERVER['PHP_AUTH_USER'])) {
return $_SERVER['PHP_AUTH_USER'];
}
// JWT
if ($this->config['auth_method'] === 'jwt') {
$headers = $this->getHeaders();
$authHeader = $headers['Authorization'] ?? '';
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
try {
$decoded = \Firebase\JWT\JWT::decode($matches[1], new \Firebase\JWT\Key($this->config['jwt_secret'], 'HS256'));
return $decoded->sub ?? null;
} catch (\Exception $e) {
}
}
}
// For API key or other methods, you can add user tracking as needed
return null;
}

public function getCurrentUserRole(): ?string
{
$user = $this->getCurrentUser();
if ($user && !empty($this->config['user_roles'][$user])) {
return $this->config['user_roles'][$user];
}
// For API key, assign a default role (optional)
return null;
}
}
Loading