feat: add OpenAPI auto-generate dari routes - Tambah OpenAPIGenerator class untuk scan routes dan generate spec - Tambah CLI command bin/generate-openapi.php - Support auto-generate on request via OPENAPI_AUTO_GENERATE env - Update public/index.php untuk auto-generate saat request /docs/openapi.json - Tambah dokumentasi OPENAPI_AUTO_GENERATE.md

This commit is contained in:
mwpn
2025-12-18 06:53:52 +07:00
parent eaa8ca97c1
commit 5af51949db
4 changed files with 764 additions and 1 deletions

65
bin/generate-openapi.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* CLI script to generate OpenAPI spec from routes
*
* Usage:
* php bin/generate-openapi.php
* php bin/generate-openapi.php --output public/docs/openapi.json
*
* Note: This script requires database connection to register routes.
* For auto-generation without DB, use OPENAPI_AUTO_GENERATE=true in .env
*/
require __DIR__ . '/../vendor/autoload.php';
use App\Bootstrap\AppBootstrap;
use App\Config\AppConfig;
use App\Modules\Auth\AuthRoutes;
use App\Modules\Health\HealthRoutes;
use App\Modules\Retribusi\Dashboard\DashboardRoutes;
use App\Modules\Retribusi\Realtime\RealtimeRoutes;
use App\Modules\Retribusi\RetribusiRoutes;
use App\Modules\Retribusi\Summary\SummaryRoutes;
use App\Support\OpenAPIGenerator;
// Load environment variables
AppConfig::loadEnv(__DIR__ . '/..');
// Parse command line arguments
$outputFile = $argv[1] ?? __DIR__ . '/../public/docs/openapi.json';
if (isset($argv[1]) && $argv[1] === '--output' && isset($argv[2])) {
$outputFile = $argv[2];
}
try {
// Bootstrap application
$app = AppBootstrap::create();
// Register all routes (same as public/index.php)
// Note: This requires database connection
HealthRoutes::register($app);
AuthRoutes::register($app);
RetribusiRoutes::register($app);
SummaryRoutes::register($app);
DashboardRoutes::register($app);
RealtimeRoutes::register($app);
// Generate OpenAPI spec
$generator = new OpenAPIGenerator($app);
$spec = $generator->generate();
// Write to file
$json = json_encode($spec, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
file_put_contents($outputFile, $json);
echo "✅ OpenAPI spec generated successfully!\n";
echo "📄 Output: {$outputFile}\n";
echo "📊 Total paths: " . count($spec['paths']) . "\n";
} catch (\Exception $e) {
echo "❌ Error generating OpenAPI spec: " . $e->getMessage() . "\n";
echo "💡 Tip: Make sure database is configured in .env file\n";
exit(1);
}

View File

@@ -0,0 +1,197 @@
# OpenAPI Auto-Generate
Sistem auto-generate OpenAPI spec dari routes yang terdaftar di Slim Framework.
## 🎯 Fitur
- **Auto-generate dari routes** - Scan semua routes yang terdaftar dan generate OpenAPI spec
- **CLI command** - Generate via command line
- **On-demand generation** - Auto-generate saat request `/docs/openapi.json` (optional)
- **No manual update** - Tidak perlu update manual `openapi.json` setiap kali tambah endpoint
## 📋 Cara Menggunakan
### Opsi 1: CLI Command (Recommended)
Generate OpenAPI spec via command line:
```bash
php bin/generate-openapi.php
```
Output default: `public/docs/openapi.json`
Custom output file:
```bash
php bin/generate-openapi.php --output custom/path/openapi.json
```
**Note:** CLI command memerlukan database connection karena routes perlu di-register.
### Opsi 2: Auto-Generate on Request
Enable auto-generate saat request `/docs/openapi.json`:
1. Tambahkan di `.env`:
```env
OPENAPI_AUTO_GENERATE=true
```
2. Setiap request ke `/docs/openapi.json` akan:
- Generate OpenAPI spec dari routes yang terdaftar
- Save ke file `public/docs/openapi.json`
- Return JSON response
**Keuntungan:**
- ✅ Selalu up-to-date dengan routes terbaru
- ✅ Tidak perlu manual update
- ✅ Swagger UI otomatis menampilkan endpoint baru
**Kekurangan:**
- ⚠️ Perlu database connection (routes butuh DB untuk register)
- ⚠️ Sedikit overhead saat request (generate setiap kali)
### Opsi 3: Manual Update (Default)
Default behavior: load dari file `public/docs/openapi.json`.
Jika `OPENAPI_AUTO_GENERATE=false` atau tidak di-set, akan load dari file.
## 🔧 Konfigurasi
### Environment Variables
```env
# Enable auto-generate on request
OPENAPI_AUTO_GENERATE=true
# Default: false (load from file)
```
### File Locations
- **Generator class:** `src/Support/OpenAPIGenerator.php`
- **CLI script:** `bin/generate-openapi.php`
- **Output file:** `public/docs/openapi.json`
- **Swagger UI:** `/docs`
## 📝 Cara Kerja
1. **Scan Routes** - Generator scan semua routes yang terdaftar di Slim App
2. **Extract Metadata** - Extract method, path, parameters, security dari routes
3. **Generate Schema** - Generate request/response schema berdasarkan path patterns
4. **Build OpenAPI Spec** - Build complete OpenAPI 3.0 spec
5. **Save/Return** - Save ke file atau return sebagai JSON
## 🎨 Customization
### Custom Request Body Schema
Edit method `getRequestBodySchema()` di `src/Support/OpenAPIGenerator.php`:
```php
private function getRequestBodySchema(string $pattern, $callable): ?array
{
// Add custom schema based on path pattern
if (strpos($pattern, '/custom-endpoint') !== false) {
return [
'required' => true,
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'field1' => ['type' => 'string'],
'field2' => ['type' => 'integer']
]
]
]
]
];
}
// ...
}
```
### Custom Tags
Edit method `getTagFromPath()` untuk custom tag assignment:
```php
private function getTagFromPath(string $path): string
{
if (strpos($path, '/custom') !== false) {
return 'Custom';
}
// ...
}
```
### Custom Parameters
Edit method `extractParameters()` untuk custom query parameters:
```php
private function extractParameters(string $pattern): array
{
$parameters = [];
// Add custom query params
if (strpos($pattern, '/custom') !== false) {
$parameters[] = [
'name' => 'custom_param',
'in' => 'query',
'schema' => ['type' => 'string']
];
}
return $parameters;
}
```
## 🚀 Workflow
### Development
1. Tambah endpoint baru di routes
2. Run `php bin/generate-openapi.php`
3. Commit `openapi.json` yang ter-update
4. Swagger UI otomatis menampilkan endpoint baru
### Production (with auto-generate)
1. Set `OPENAPI_AUTO_GENERATE=true` di `.env`
2. Tambah endpoint baru di routes
3. Deploy
4. Swagger UI otomatis menampilkan endpoint baru (no commit needed)
### Production (without auto-generate)
1. Tambah endpoint baru di routes
2. Run `php bin/generate-openapi.php` di local/staging
3. Commit `openapi.json` yang ter-update
4. Deploy (include updated `openapi.json`)
## ⚠️ Limitations
1. **Database Required** - Routes perlu database connection untuk register
2. **Schema Inference** - Request body schema di-infer dari path pattern (bisa kurang akurat)
3. **No Reflection** - Tidak scan controller methods untuk extract detailed schema
4. **Manual Customization** - Complex schemas perlu manual edit di generator
## 🔮 Future Improvements
- [ ] Support PHP annotations untuk detailed schema
- [ ] Reflection-based schema extraction dari controller methods
- [ ] Support untuk custom OpenAPI extensions
- [ ] Cache generated spec untuk performance
- [ ] Support untuk multiple OpenAPI versions
## 📚 Related Files
- `src/Support/OpenAPIGenerator.php` - Generator class
- `bin/generate-openapi.php` - CLI script
- `public/index.php` - Auto-generate on request handler
- `public/docs/openapi.json` - Generated OpenAPI spec
- `public/docs/index.html` - Swagger UI

View File

@@ -13,6 +13,7 @@ use App\Modules\Retribusi\Dashboard\DashboardRoutes;
use App\Modules\Retribusi\Realtime\RealtimeRoutes; use App\Modules\Retribusi\Realtime\RealtimeRoutes;
use App\Modules\Retribusi\RetribusiRoutes; use App\Modules\Retribusi\RetribusiRoutes;
use App\Modules\Retribusi\Summary\SummaryRoutes; use App\Modules\Retribusi\Summary\SummaryRoutes;
use App\Support\OpenAPIGenerator;
// Load environment variables // Load environment variables
AppConfig::loadEnv(__DIR__ . '/..'); AppConfig::loadEnv(__DIR__ . '/..');
@@ -55,9 +56,29 @@ $app->get('/docs', function ($request, $response) {
// Serve OpenAPI JSON // Serve OpenAPI JSON
// NOTE: Saat ini PUBLIC. Jika perlu protect, tambahkan middleware // NOTE: Saat ini PUBLIC. Jika perlu protect, tambahkan middleware
$app->get('/docs/openapi.json', function ($request, $response) { $app->get('/docs/openapi.json', function ($request, $response) use ($app) {
$openApiPath = __DIR__ . '/docs/openapi.json'; $openApiPath = __DIR__ . '/docs/openapi.json';
$autoGenerate = AppConfig::get('OPENAPI_AUTO_GENERATE', 'false') === 'true';
// Auto-generate if enabled
if ($autoGenerate) {
try {
$generator = new OpenAPIGenerator($app);
$spec = $generator->generate();
$json = json_encode($spec, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Optionally save to file
file_put_contents($openApiPath, $json);
$response->getBody()->write($json);
return $response->withHeader('Content-Type', 'application/json');
} catch (\Exception $e) {
// Fallback to file if generation fails
error_log('OpenAPI generation failed: ' . $e->getMessage());
}
}
// Load from file (default behavior)
if (!file_exists($openApiPath)) { if (!file_exists($openApiPath)) {
$response->getBody()->write(json_encode(['error' => 'OpenAPI spec not found'])); $response->getBody()->write(json_encode(['error' => 'OpenAPI spec not found']));
return $response return $response

View File

@@ -0,0 +1,480 @@
<?php
declare(strict_types=1);
namespace App\Support;
use ReflectionClass;
use ReflectionMethod;
use Slim\App;
use Slim\Routing\RouteCollector;
class OpenAPIGenerator
{
private App $app;
private array $baseSpec;
public function __construct(App $app)
{
$this->app = $app;
$this->baseSpec = $this->getBaseSpec();
}
/**
* Generate OpenAPI spec from registered routes
*/
public function generate(): array
{
$spec = $this->baseSpec;
$spec['paths'] = $this->scanRoutes();
return $spec;
}
/**
* Get base OpenAPI spec structure
*/
private function getBaseSpec(): array
{
return [
'openapi' => '3.0.0',
'info' => [
'title' => 'API Retribusi',
'description' => 'Sistem API Retribusi berbasis Slim Framework 4 untuk infrastruktur pemerintah',
'version' => '1.0.0',
'contact' => [
'name' => 'BTekno Development Team'
]
],
'servers' => [
[
'url' => 'https://api.btekno.cloud',
'description' => 'Production Server'
],
[
'url' => 'http://localhost',
'description' => 'Local Development'
]
],
'tags' => [
['name' => 'Health', 'description' => 'Health check endpoint'],
['name' => 'Authentication', 'description' => 'JWT authentication'],
['name' => 'Ingest', 'description' => 'Event ingestion (mesin YOLO)'],
['name' => 'Frontend', 'description' => 'Frontend CRUD operations'],
['name' => 'Summary', 'description' => 'Data summary & aggregation'],
['name' => 'Dashboard', 'description' => 'Dashboard visualization data'],
['name' => 'Realtime', 'description' => 'Real-time events (SSE)']
],
'components' => [
'securitySchemes' => [
'BearerAuth' => [
'type' => 'http',
'scheme' => 'bearer',
'bearerFormat' => 'JWT',
'description' => 'JWT token untuk frontend API'
],
'ApiKeyAuth' => [
'type' => 'apiKey',
'in' => 'header',
'name' => 'X-API-KEY',
'description' => 'API Key untuk ingest endpoint'
]
],
'schemas' => [
'Error' => [
'type' => 'object',
'properties' => [
'error' => ['type' => 'string', 'description' => 'Error code'],
'message' => ['type' => 'string', 'description' => 'Error message'],
'fields' => ['type' => 'object', 'description' => 'Validation errors (optional)']
]
],
'Success' => [
'type' => 'object',
'properties' => [
'success' => ['type' => 'boolean', 'example' => true],
'data' => ['type' => 'object'],
'timestamp' => ['type' => 'integer', 'description' => 'Unix timestamp']
]
]
]
],
'paths' => []
];
}
/**
* Scan all registered routes
*/
private function scanRoutes(): array
{
$paths = [];
$routeCollector = $this->app->getRouteCollector();
$routes = $routeCollector->getRoutes();
foreach ($routes as $route) {
$methods = $route->getMethods();
$pattern = $route->getPattern();
$callable = $route->getCallable();
// Skip internal routes
if ($this->shouldSkipRoute($pattern)) {
continue;
}
// Convert Slim route pattern to OpenAPI path
$openApiPath = $this->convertToOpenApiPath($pattern);
// Get route metadata
$tag = $this->getTagFromPath($pattern);
$summary = $this->getSummaryFromRoute($pattern, $methods[0] ?? 'GET');
$description = $this->getDescriptionFromRoute($pattern, $methods[0] ?? 'GET');
// Get security requirements
$security = $this->getSecurityFromRoute($route, $pattern);
// Get parameters from path
$parameters = $this->extractParameters($pattern);
// Get request body schema (for POST/PUT)
$requestBody = null;
if (in_array($methods[0] ?? '', ['POST', 'PUT', 'PATCH'])) {
$requestBody = $this->getRequestBodySchema($pattern, $callable);
}
// Process each HTTP method
foreach ($methods as $method) {
$methodLower = strtolower($method);
if (!isset($paths[$openApiPath])) {
$paths[$openApiPath] = [];
}
$paths[$openApiPath][$methodLower] = [
'tags' => [$tag],
'summary' => $summary,
'description' => $description,
'security' => $security,
'parameters' => $parameters,
'responses' => $this->getDefaultResponses($pattern, $method)
];
if ($requestBody !== null) {
$paths[$openApiPath][$methodLower]['requestBody'] = $requestBody;
}
}
}
return $paths;
}
/**
* Check if route should be skipped
*/
private function shouldSkipRoute(string $pattern): bool
{
$skipPatterns = [
'/',
'/docs',
'/docs/openapi.json',
'/{routes:.+}' // OPTIONS route
];
return in_array($pattern, $skipPatterns);
}
/**
* Convert Slim route pattern to OpenAPI path format
*/
private function convertToOpenApiPath(string $pattern): string
{
// Convert {param} to {param} (already OpenAPI format)
// Remove regex patterns like {routes:.+}
$path = preg_replace('/\{([^:}]+):[^}]+\}/', '{$1}', $pattern);
return $path;
}
/**
* Get tag from path
*/
private function getTagFromPath(string $path): string
{
if (strpos($path, '/health') === 0) {
return 'Health';
}
if (strpos($path, '/auth') === 0) {
return 'Authentication';
}
if (strpos($path, '/ingest') !== false) {
return 'Ingest';
}
if (strpos($path, '/frontend') !== false) {
return 'Frontend';
}
if (strpos($path, '/summary') !== false) {
return 'Summary';
}
if (strpos($path, '/dashboard') !== false) {
return 'Dashboard';
}
if (strpos($path, '/realtime') !== false) {
return 'Realtime';
}
return 'Frontend';
}
/**
* Get summary from route
*/
private function getSummaryFromRoute(string $path, string $method): string
{
$pathParts = explode('/', trim($path, '/'));
$lastPart = end($pathParts);
// Remove path parameters
$lastPart = preg_replace('/\{[^}]+\}/', '', $lastPart);
$lastPart = trim($lastPart, '/');
if (empty($lastPart)) {
$lastPart = $pathParts[count($pathParts) - 2] ?? 'list';
}
$action = match ($method) {
'GET' => 'Get',
'POST' => 'Create',
'PUT' => 'Update',
'PATCH' => 'Update',
'DELETE' => 'Delete',
default => 'Handle'
};
// Convert snake_case/kebab-case to Title Case
$lastPart = str_replace(['-', '_'], ' ', $lastPart);
$lastPart = ucwords($lastPart);
return $action . ' ' . $lastPart;
}
/**
* Get description from route
*/
private function getDescriptionFromRoute(string $path, string $method): string
{
$summary = $this->getSummaryFromRoute($path, $method);
return $summary . ' endpoint';
}
/**
* Get security requirements from route
*/
private function getSecurityFromRoute($route, string $path): array
{
$security = [];
// Check if route has JWT middleware (based on path patterns)
if (
strpos($path, '/frontend') !== false ||
strpos($path, '/summary') !== false ||
strpos($path, '/dashboard') !== false ||
strpos($path, '/realtime') !== false
) {
$security[] = ['BearerAuth' => []];
}
// Check if route has API key middleware
if (strpos($path, '/ingest') !== false) {
$security[] = ['ApiKeyAuth' => []];
}
return $security;
}
/**
* Extract parameters from path pattern
*/
private function extractParameters(string $pattern): array
{
$parameters = [];
preg_match_all('/\{([^}:]+)(?::[^}]+)?\}/', $pattern, $matches);
foreach ($matches[1] as $paramName) {
// Skip special parameters
if ($paramName === 'routes') {
continue;
}
$parameters[] = [
'name' => $paramName,
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
'description' => ucfirst(str_replace('_', ' ', $paramName))
];
}
// Add common query parameters for list endpoints
if (preg_match('/\/(locations|gates|tariffs|audit-logs|entry-events|events)(\/|$)/', $pattern)) {
$parameters[] = [
'name' => 'page',
'in' => 'query',
'schema' => ['type' => 'integer', 'default' => 1, 'minimum' => 1],
'description' => 'Page number'
];
$parameters[] = [
'name' => 'limit',
'in' => 'query',
'schema' => ['type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100],
'description' => 'Items per page'
];
}
// Add date parameters for summary/dashboard endpoints
if (strpos($pattern, '/summary') !== false || strpos($pattern, '/dashboard') !== false) {
$parameters[] = [
'name' => 'date',
'in' => 'query',
'schema' => ['type' => 'string', 'format' => 'date'],
'description' => 'Date (Y-m-d format)'
];
}
return $parameters;
}
/**
* Get request body schema
*/
private function getRequestBodySchema(string $pattern, $callable): ?array
{
// Try to infer schema from path and method
$schema = ['type' => 'object', 'properties' => []];
if (strpos($pattern, '/locations') !== false) {
$schema['required'] = ['code', 'name', 'type', 'is_active'];
$schema['properties'] = [
'code' => ['type' => 'string', 'example' => 'kerkof_01'],
'name' => ['type' => 'string', 'example' => 'Kerkof Garut'],
'type' => ['type' => 'string', 'example' => 'parkir'],
'is_active' => ['type' => 'integer', 'enum' => [0, 1], 'example' => 1]
];
} elseif (strpos($pattern, '/gates') !== false) {
$schema['required'] = ['location_code', 'gate_code', 'name', 'direction', 'is_active'];
$schema['properties'] = [
'location_code' => ['type' => 'string', 'example' => 'kerkof_01'],
'gate_code' => ['type' => 'string', 'example' => 'gate01'],
'name' => ['type' => 'string', 'example' => 'Gate 01'],
'direction' => ['type' => 'string', 'enum' => ['in', 'out'], 'example' => 'in'],
'camera' => ['type' => 'string', 'description' => 'Camera URL (HLS, RTSP, HTTP) atau camera ID', 'example' => 'https://example.com/stream.m3u8', 'maxLength' => 500],
'is_active' => ['type' => 'integer', 'enum' => [0, 1], 'example' => 1]
];
} elseif (strpos($pattern, '/tariffs') !== false) {
$schema['required'] = ['location_code', 'gate_code', 'category', 'price'];
$schema['properties'] = [
'location_code' => ['type' => 'string'],
'gate_code' => ['type' => 'string'],
'category' => ['type' => 'string', 'enum' => ['person_walk', 'motor', 'car']],
'price' => ['type' => 'integer', 'minimum' => 0]
];
} elseif (strpos($pattern, '/ingest') !== false) {
$schema['required'] = ['timestamp', 'location_code', 'gate_code', 'category'];
$schema['properties'] = [
'timestamp' => ['type' => 'integer', 'description' => 'Unix timestamp', 'example' => 1735123456],
'location_code' => ['type' => 'string', 'example' => 'kerkof_01'],
'gate_code' => ['type' => 'string', 'example' => 'gate01'],
'category' => ['type' => 'string', 'example' => 'motor']
];
} elseif (strpos($pattern, '/auth') !== false && strpos($pattern, '/login') !== false) {
$schema['required'] = ['username', 'password'];
$schema['properties'] = [
'username' => ['type' => 'string', 'example' => 'admin'],
'password' => ['type' => 'string', 'format' => 'password', 'example' => 'password123']
];
} else {
// Generic schema
$schema['properties'] = ['data' => ['type' => 'object']];
}
return [
'required' => true,
'content' => [
'application/json' => [
'schema' => $schema
]
]
];
}
/**
* Get default responses
*/
private function getDefaultResponses(string $pattern, string $method): array
{
$responses = [
'200' => [
'description' => 'Success',
'content' => [
'application/json' => [
'schema' => ['$ref' => '#/components/schemas/Success']
]
]
]
];
// Add method-specific responses
if ($method === 'POST') {
$responses['201'] = [
'description' => 'Created',
'content' => [
'application/json' => [
'schema' => ['$ref' => '#/components/schemas/Success']
]
]
];
}
// Add common error responses
$responses['401'] = [
'description' => 'Unauthorized',
'content' => [
'application/json' => [
'schema' => ['$ref' => '#/components/schemas/Error']
]
]
];
if (strpos($pattern, '/frontend') !== false) {
$responses['403'] = [
'description' => 'Forbidden',
'content' => [
'application/json' => [
'schema' => ['$ref' => '#/components/schemas/Error']
]
]
];
}
if ($method === 'POST' || $method === 'PUT') {
$responses['422'] = [
'description' => 'Validation error',
'content' => [
'application/json' => [
'schema' => ['$ref' => '#/components/schemas/Error']
]
]
];
}
if ($method === 'GET' || $method === 'PUT' || $method === 'DELETE') {
$responses['404'] = [
'description' => 'Not found',
'content' => [
'application/json' => [
'schema' => ['$ref' => '#/components/schemas/Error']
]
]
];
}
return $responses;
}
}