Skip to content

Commit 39e4360

Browse files
authored
Merge pull request #1 from BitsHost/v1.1-rbac
BigUpdates
2 parents 9293529 + d5856b2 commit 39e4360

File tree

9 files changed

+420
-38
lines changed

9 files changed

+420
-38
lines changed

LICENSE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Bitshost
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
13+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
14+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
15+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
16+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
17+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
18+
SOFTWARE.

README.md

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ OpenAPI (Swagger) docs, and zero code generation.
66

77
---
88

9-
## 🚀 Features
9+
## 🚀 ## 🚀 Features
1010

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

111114
---
112115

116+
117+
### 🔄 Advanced Query Features (Filtering, Sorting, Pagination)
118+
119+
The `list` action endpoint now supports advanced query parameters:
120+
121+
| Parameter | Type | Description |
122+
|--------------|---------|---------------------------------------------------------------------------------------------------|
123+
| `filter` | string | Filter rows by column values. Format: `filter=col1:value1,col2:value2`. Use `%` for wildcards. |
124+
| `sort` | string | Sort by columns. Comma-separated. Use `-` prefix for DESC. Example: `sort=-created_at,name` |
125+
| `page` | int | Page number (1-based). Default: `1` |
126+
| `page_size` | int | Number of rows per page (max 100). Default: `20` |
127+
128+
**Examples:**
129+
130+
- `GET /index.php?action=list&table=users&filter=name:Alice`
131+
- `GET /index.php?action=list&table=users&sort=-created_at,name`
132+
- `GET /index.php?action=list&table=users&page=2&page_size=10`
133+
- `GET /index.php?action=list&table=users&filter=email:%gmail.com&sort=name&page=1&page_size=5`
134+
135+
**Response:**
136+
```json
137+
{
138+
"data": [ ... array of rows ... ],
139+
"meta": {
140+
"total": 47,
141+
"page": 2,
142+
"page_size": 10,
143+
"pages": 5
144+
}
145+
}
146+
```
147+
148+
---
149+
150+
### 📝 OpenAPI Path Example
151+
152+
For `/index.php?action=list&table={table}`:
153+
154+
```yaml
155+
get:
156+
summary: List rows in {table} with optional filtering, sorting, and pagination
157+
parameters:
158+
- name: table
159+
in: query
160+
required: true
161+
schema: { type: string }
162+
- name: filter
163+
in: query
164+
required: false
165+
schema: { type: string }
166+
description: |
167+
Filter rows by column values. Example: filter=name:Alice,email:%gmail.com
168+
- name: sort
169+
in: query
170+
required: false
171+
schema: { type: string }
172+
description: |
173+
Sort by columns. Example: sort=-created_at,name
174+
- name: page
175+
in: query
176+
required: false
177+
schema: { type: integer, default: 1 }
178+
description: Page number (1-based)
179+
- name: page_size
180+
in: query
181+
required: false
182+
schema: { type: integer, default: 20, maximum: 100 }
183+
description: Number of rows per page (max 100)
184+
responses:
185+
'200':
186+
description: List of rows with pagination meta
187+
content:
188+
application/json:
189+
schema:
190+
type: object
191+
properties:
192+
data:
193+
type: array
194+
items: { type: object }
195+
meta:
196+
type: object
197+
properties:
198+
total: { type: integer }
199+
page: { type: integer }
200+
page_size: { type: integer }
201+
pages: { type: integer }
202+
```
203+
113204
## 🛡️ Security Notes
114205
115206
- **Enable authentication for any public deployment!**
@@ -128,10 +219,11 @@ curl -u admin:secret "http://localhost/index.php?action=list&table=users"
128219

129220
## 🗺️ Roadmap
130221

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

136228
---
137229

config/api.example.php

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
11
<?php
22
return [
3-
'auth_enabled' => false, // true to require authentication
4-
'auth_method' => 'apikey', // 'apikey', 'basic', 'jwt', 'oauth'
3+
// ... existing config ...
4+
'auth_enabled' => true,
5+
'auth_method' => 'basic', // or 'apikey', 'jwt', etc.
56
'api_keys' => ['changeme123'],
6-
'basic_users' => ['admin' => 'secret'],
7-
'jwt_secret' => 'YourSuperSecretKey',
8-
'jwt_issuer' => 'yourdomain.com',
9-
'jwt_audience' => 'yourdomain.com',
10-
'oauth_providers' => [
11-
// 'google' => ['client_id' => '', 'client_secret' => '', ...]
12-
]
7+
'basic_users' => [
8+
'admin' => 'secret',
9+
'user' => 'userpass'
10+
],
11+
// RBAC config: map users to roles, and roles to table permissions
12+
'roles' => [
13+
'admin' => [
14+
// full access
15+
'*' => ['list', 'read', 'create', 'update', 'delete']
16+
],
17+
'readonly' => [
18+
// read only on all tables
19+
'*' => ['list', 'read']
20+
],
21+
'users_manager' => [
22+
'users' => ['list', 'read', 'create', 'update'],
23+
'orders' => ['list', 'read']
24+
]
25+
],
26+
// Map users to roles
27+
'user_roles' => [
28+
'admin' => 'admin',
29+
'user' => 'readonly'
30+
],
1331
];

public/index.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

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

6+
// Add this line if admin React is enabled.
7+
// \App\Cors::sendHeaders();
8+
69
use App\Database;
710
use App\Router;
811
use App\Authenticator;

src/ApiGenerator.php

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<?php
2+
23
namespace App;
34

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

17-
public function list(string $table): array
18+
/**
19+
* Enhanced list: supports filtering, sorting, pagination.
20+
*/
21+
public function list(string $table, array $opts = []): array
1822
{
19-
$stmt = $this->pdo->query("SELECT * FROM `$table`");
20-
return $stmt->fetchAll(PDO::FETCH_ASSOC);
23+
$columns = $this->inspector->getColumns($table);
24+
$colNames = array_column($columns, 'Field');
25+
26+
// --- Filtering ---
27+
$where = [];
28+
$params = [];
29+
if (!empty($opts['filter'])) {
30+
// Example filter: ['name:Alice', 'email:gmail.com']
31+
$filters = explode(',', $opts['filter']);
32+
foreach ($filters as $f) {
33+
$parts = explode(':', $f, 2);
34+
if (count($parts) === 2 && in_array($parts[0], $colNames, true)) {
35+
$col = $parts[0];
36+
$val = $parts[1];
37+
// Use LIKE for partial match, = for exact
38+
if (str_contains($val, '%')) {
39+
$where[] = "`$col` LIKE :$col";
40+
$params[$col] = $val;
41+
} else {
42+
$where[] = "`$col` = :$col";
43+
$params[$col] = $val;
44+
}
45+
}
46+
}
47+
}
48+
49+
// --- Sorting ---
50+
$orderBy = '';
51+
if (!empty($opts['sort'])) {
52+
$orders = [];
53+
$sorts = explode(',', $opts['sort']);
54+
foreach ($sorts as $sort) {
55+
$direction = 'ASC';
56+
$col = $sort;
57+
if (str_starts_with($sort, '-')) {
58+
$direction = 'DESC';
59+
$col = substr($sort, 1);
60+
}
61+
if (in_array($col, $colNames, true)) {
62+
$orders[] = "`$col` $direction";
63+
}
64+
}
65+
if ($orders) {
66+
$orderBy = 'ORDER BY ' . implode(', ', $orders);
67+
}
68+
}
69+
70+
// --- Pagination ---
71+
$page = max(1, (int)($opts['page'] ?? 1));
72+
$pageSize = max(1, min(100, (int)($opts['page_size'] ?? 20))); // max 100 rows per page
73+
$offset = ($page - 1) * $pageSize;
74+
$limit = "LIMIT $pageSize OFFSET $offset";
75+
76+
$sql = "SELECT * FROM `$table`";
77+
if ($where) {
78+
$sql .= ' WHERE ' . implode(' AND ', $where);
79+
}
80+
if ($orderBy) {
81+
$sql .= ' ' . $orderBy;
82+
}
83+
$sql .= " $limit";
84+
85+
$stmt = $this->pdo->prepare($sql);
86+
$stmt->execute($params);
87+
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
88+
89+
// Optionally: include pagination meta info
90+
$countStmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table`" . ($where ? ' WHERE ' . implode(' AND ', $where) : ''));
91+
$countStmt->execute($params);
92+
$total = (int)$countStmt->fetchColumn();
93+
94+
return [
95+
'data' => $rows,
96+
'meta' => [
97+
'total' => $total,
98+
'page' => $page,
99+
'page_size' => $pageSize,
100+
'pages' => (int)ceil($total / $pageSize)
101+
]
102+
];
21103
}
22104

23105
public function read(string $table, $id): ?array
@@ -52,6 +134,10 @@ public function update(string $table, $id, array $data): array
52134
foreach ($data as $col => $val) {
53135
$sets[] = "`$col` = :$col";
54136
}
137+
// Handle no fields to update
138+
if (empty($sets)) {
139+
return ["error" => "No fields to update. Send at least one column."];
140+
}
55141
$sql = sprintf(
56142
"UPDATE `%s` SET %s WHERE `$pk` = :id",
57143
$table,
@@ -60,13 +146,32 @@ public function update(string $table, $id, array $data): array
60146
$stmt = $this->pdo->prepare($sql);
61147
$data['id'] = $id;
62148
$stmt->execute($data);
63-
return $this->read($table, $id);
149+
// Check if any row was actually updated
150+
if ($stmt->rowCount() === 0) {
151+
// Check if the row exists at all
152+
$existing = $this->read($table, $id);
153+
if ($existing === null) {
154+
return ["error" => "Item with id $id not found in $table."];
155+
} else {
156+
// The row exists but there was no change (e.g., same data)
157+
return $existing;
158+
}
159+
}
160+
$updated = $this->read($table, $id);
161+
if ($updated === null) {
162+
return ["error" => "Unexpected error: item not found after update."];
163+
}
164+
return $updated;
64165
}
65166

66-
public function delete(string $table, $id): bool
167+
public function delete(string $table, $id): array
67168
{
68169
$pk = $this->inspector->getPrimaryKey($table);
69170
$stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `$pk` = :id");
70-
return $stmt->execute(['id' => $id]);
171+
$stmt->execute(['id' => $id]);
172+
if ($stmt->rowCount() === 0) {
173+
return ['error' => "Item with id $id not found in $table."];
174+
}
175+
return ['success' => true];
71176
}
72-
}
177+
}

src/Authenticator.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<?php
2+
23
namespace App;
34

45
use Firebase\JWT\JWT;
@@ -117,4 +118,38 @@ private function requireBasicAuth()
117118
echo json_encode(['error' => 'Unauthorized']);
118119
exit;
119120
}
120-
}
121+
122+
// ... existing code ...
123+
124+
public function getCurrentUser(): ?string
125+
{
126+
// Basic Auth
127+
if ($this->config['auth_method'] === 'basic' && isset($_SERVER['PHP_AUTH_USER'])) {
128+
return $_SERVER['PHP_AUTH_USER'];
129+
}
130+
// JWT
131+
if ($this->config['auth_method'] === 'jwt') {
132+
$headers = $this->getHeaders();
133+
$authHeader = $headers['Authorization'] ?? '';
134+
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
135+
try {
136+
$decoded = \Firebase\JWT\JWT::decode($matches[1], new \Firebase\JWT\Key($this->config['jwt_secret'], 'HS256'));
137+
return $decoded->sub ?? null;
138+
} catch (\Exception $e) {
139+
}
140+
}
141+
}
142+
// For API key or other methods, you can add user tracking as needed
143+
return null;
144+
}
145+
146+
public function getCurrentUserRole(): ?string
147+
{
148+
$user = $this->getCurrentUser();
149+
if ($user && !empty($this->config['user_roles'][$user])) {
150+
return $this->config['user_roles'][$user];
151+
}
152+
// For API key, assign a default role (optional)
153+
return null;
154+
}
155+
}

0 commit comments

Comments
 (0)