From 5af51949dbb16f72a0a1762908640c172918e1fb Mon Sep 17 00:00:00 2001 From: mwpn Date: Thu, 18 Dec 2025 06:53:52 +0700 Subject: [PATCH] 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 --- bin/generate-openapi.php | 65 +++++ docs/OPENAPI_AUTO_GENERATE.md | 197 +++++++++++++ public/index.php | 23 +- src/Support/OpenAPIGenerator.php | 480 +++++++++++++++++++++++++++++++ 4 files changed, 764 insertions(+), 1 deletion(-) create mode 100644 bin/generate-openapi.php create mode 100644 docs/OPENAPI_AUTO_GENERATE.md create mode 100644 src/Support/OpenAPIGenerator.php diff --git a/bin/generate-openapi.php b/bin/generate-openapi.php new file mode 100644 index 0000000..3545476 --- /dev/null +++ b/bin/generate-openapi.php @@ -0,0 +1,65 @@ +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); +} diff --git a/docs/OPENAPI_AUTO_GENERATE.md b/docs/OPENAPI_AUTO_GENERATE.md new file mode 100644 index 0000000..ee1f4c0 --- /dev/null +++ b/docs/OPENAPI_AUTO_GENERATE.md @@ -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 + diff --git a/public/index.php b/public/index.php index 05810f2..8884277 100644 --- a/public/index.php +++ b/public/index.php @@ -13,6 +13,7 @@ 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__ . '/..'); @@ -55,9 +56,29 @@ $app->get('/docs', function ($request, $response) { // Serve OpenAPI JSON // 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'; + $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)) { $response->getBody()->write(json_encode(['error' => 'OpenAPI spec not found'])); return $response diff --git a/src/Support/OpenAPIGenerator.php b/src/Support/OpenAPIGenerator.php new file mode 100644 index 0000000..acb2684 --- /dev/null +++ b/src/Support/OpenAPIGenerator.php @@ -0,0 +1,480 @@ +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; + } +}