diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml new file mode 100644 index 000000000..0659d1aec --- /dev/null +++ b/.github/workflows/api-docs.yml @@ -0,0 +1,108 @@ +name: Generate API Documentation + +on: + push: + branches: [main, master, swagger] + paths: + - 'classes/Modules/Api/**' + - 'www/api/**' + - 'tools/generate-api-docs.php' + pull_request: + branches: [main, master, swagger] + paths: + - 'classes/Modules/Api/**' + - 'www/api/**' + workflow_dispatch: + +jobs: + # Job 1: Prüft ob die Dokumentation aktuell ist (für PRs) + check-docs: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + + - name: Install Composer dependencies + run: composer install --no-dev --no-progress --prefer-dist + + - name: Check API documentation is up-to-date + run: php tools/generate-api-docs.php --check + + # Job 2: Generiert und deployed die Dokumentation (für Push auf main) + generate-docs: + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + + permissions: + contents: write + pages: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Composer dependencies + run: composer install --no-dev --no-progress --prefer-dist + + - name: Generate RAML documentation + run: php tools/generate-api-docs.php --output=www/api/docs.raml + + - name: Generate OpenAPI documentation + run: php tools/generate-api-docs.php --format=openapi --output=www/api/openapi.json + + - name: Install raml2html + run: npm install -g raml2html + + - name: Generate HTML documentation + run: raml2html www/api/docs.raml > www/api/docs.html + continue-on-error: true + + - name: Commit generated documentation + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "docs: Auto-generate API documentation" + file_pattern: "www/api/docs.raml www/api/docs.html www/api/openapi.json" + commit_author: "GitHub Actions " + + # Optional: Deploy to GitHub Pages + - name: Setup Pages + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + uses: actions/configure-pages@v4 + continue-on-error: true + + - name: Create docs directory for Pages + run: | + mkdir -p docs-site + cp www/api/docs.html docs-site/index.html + cp www/api/openapi.json docs-site/ + cp -r www/api/assets docs-site/ 2>/dev/null || true + + - name: Upload Pages artifact + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + uses: actions/upload-pages-artifact@v3 + with: + path: docs-site + continue-on-error: true + + - name: Deploy to GitHub Pages + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + uses: actions/deploy-pages@v4 + continue-on-error: true diff --git a/.gitignore b/.gitignore index 00dc3a55a..233dc6e38 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,12 @@ www/cache/ node_modules/ www/themes/new/css/custom.css docker-compose.override.yml -.idea \ No newline at end of file +.idea + +# macOS +.DS_Store + +# Auto-generated API documentation +www/api/openapi.json +www/api/docs.generated.raml +www/api/docs.html \ No newline at end of file diff --git a/README.md b/README.md index f9ffa0bb6..b050f6bc7 100644 --- a/README.md +++ b/README.md @@ -30,5 +30,34 @@ https://github.com/OpenXE-org/OpenXE/releases [Hier gehts zur OpenXE Installation](INSTALL.md) +# API-Dokumentation + +Die REST-API wird automatisch aus dem Code generiert. + +## Dokumentation lokal generieren + +```bash +# OpenAPI 3.0 (JSON) +php tools/generate-api-docs.php --format=openapi + +# RAML +php tools/generate-api-docs.php --format=raml +``` + +Generierte Dateien: +- `www/api/openapi.json` - OpenAPI 3.0 Spezifikation +- `www/api/docs.generated.raml` - RAML Spezifikation + +## Dokumentation ansehen + +Nach der Installation ist die API-Dokumentation unter folgenden URLs verfügbar (mit API-Account Login): +- `/api/swagger.html` - Interaktive Swagger UI +- `/api/docs.html` - RAML HTML-Dokumentation +- `/api/openapi.json` - OpenAPI JSON (für Tools wie Postman) + +Die Dokumentation wird automatisch durch GitHub Actions bei jedem Push generiert. + +--- + OpenXE ist freie Software, lizensiert unter der EGPL 3.1. Diese Software ist eine Ableitung und Veränderung von Xentral ERP, Opensource Version. Xentral ERP wurde von embedded projects GmbH als Wawision und später Xentral entwickelt und steht unter der EGPLv3.1-Lizenz als Open Source Software. Informationen zu Xentral findet man unter http://www.xentral.de diff --git a/tools/generate-api-docs.php b/tools/generate-api-docs.php new file mode 100644 index 000000000..ae6389576 --- /dev/null +++ b/tools/generate-api-docs.php @@ -0,0 +1,886 @@ +#!/usr/bin/env php +classesPath = $classesPath; + } + + /** + * Extrahiert alle Routen aus ApiApplication.php + */ + public function extractRoutes(): array + { + $apiAppPath = $this->classesPath . '/Modules/Api/Engine/ApiApplication.php'; + $content = file_get_contents($apiAppPath); + + // Regex um addRoute-Aufrufe zu finden + $pattern = '/\$collection->addRoute\(\s*(\[?[\'"][A-Z,\s\'\"]+[\'\"]?\]?)\s*,\s*[\'"]([^\'\"]+)[\'"]\s*,\s*\[([^\]]+)\]\s*\)/'; + + preg_match_all($pattern, $content, $matches, PREG_SET_ORDER); + + $routes = []; + foreach ($matches as $match) { + $methods = $this->parseMethods($match[1]); + $path = $match[2]; + $handler = $this->parseHandler($match[3]); + + // Nur v1/v2 REST-API Routen (keine Legacy) + if (strpos($path, '/v1/') === 0 || strpos($path, '/v2/') === 0) { + if ($handler['version'] !== 'Legacy') { + $routes[] = [ + 'methods' => $methods, + 'path' => $path, + 'handler' => $handler, + ]; + } + } + } + + $this->routes = $routes; + return $routes; + } + + /** + * Extrahiert Metadaten aus Resource-Klassen + */ + public function extractResources(): array + { + $resourcePath = $this->classesPath . '/Modules/Api/Resource'; + $files = glob($resourcePath . '/*Resource.php'); + + $resources = []; + foreach ($files as $file) { + $className = basename($file, '.php'); + if ($className === 'AbstractResource') { + continue; + } + + $content = file_get_contents($file); + $resourceData = $this->parseResourceClass($content, $className); + if ($resourceData) { + $resources[$className] = $resourceData; + } + } + + $this->resources = $resources; + return $resources; + } + + /** + * Parst die Methoden aus einem Route-Aufruf + */ + private function parseMethods(string $methodStr): array + { + $methodStr = trim($methodStr, "[] \t\n\r"); + preg_match_all('/[\'"]([A-Z]+)[\'"]/', $methodStr, $matches); + return $matches[1] ?? ['GET']; + } + + /** + * Parst den Handler aus einem Route-Aufruf + */ + private function parseHandler(string $handlerStr): array + { + $parts = array_map(function($p) { + return trim($p, " \t\n\r'\""); + }, explode(',', $handlerStr)); + + return [ + 'version' => $parts[0] ?? '', + 'resource' => $parts[1] ?? null, + 'controller' => $parts[2] ?? '', + 'action' => $parts[3] ?? '', + 'permission' => $parts[4] ?? null, + ]; + } + + /** + * Parst eine Resource-Klasse + */ + private function parseResourceClass(string $content, string $className): ?array + { + $data = [ + 'className' => $className, + 'tableName' => null, + 'filterParams' => [], + 'sortingParams' => [], + ]; + + // TABLE_NAME extrahieren + if (preg_match('/const\s+TABLE_NAME\s*=\s*[\'"]([^\'\"]+)[\'"]/', $content, $match)) { + $data['tableName'] = $match[1]; + } + + // registerFilterParams extrahieren + if (preg_match('/registerFilterParams\s*\(\s*\[(.*?)\]\s*\)/s', $content, $match)) { + $data['filterParams'] = $this->parseArrayContent($match[1]); + } + + // registerSortingParams extrahieren + if (preg_match('/registerSortingParams\s*\(\s*\[(.*?)\]\s*\)/s', $content, $match)) { + $data['sortingParams'] = $this->parseArrayContent($match[1]); + } + + return $data; + } + + /** + * Parst Array-Inhalt aus PHP-Code + */ + private function parseArrayContent(string $content): array + { + $params = []; + // Einfaches Pattern für 'key' => 'value' + preg_match_all('/[\'"]([^\'\"]+)[\'"]\s*=>\s*[\'"]([^\'\"]+)[\'"]/', $content, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $params[$match[1]] = $match[2]; + } + + return $params; + } + + public function getRoutes(): array + { + return $this->routes; + } + + public function getResources(): array + { + return $this->resources; + } +} + +/** + * RAML-Generator + */ +class RamlGenerator +{ + private array $routes; + private array $resources; + private string $existingDocsPath; + + public function __construct(array $routes, array $resources, string $existingDocsPath) + { + $this->routes = $routes; + $this->resources = $resources; + $this->existingDocsPath = $existingDocsPath; + } + + /** + * Generiert die komplette RAML-Dokumentation + */ + public function generate(): string + { + $raml = $this->generateHeader(); + $raml .= $this->generateSecuritySchemes(); + $raml .= $this->generateDocumentation(); + $raml .= $this->generateEndpoints(); + + return $raml; + } + + private function generateHeader(): string + { + return <<existingDocsPath)) { + $existing = file_get_contents($this->existingDocsPath); + + // Extrahiere den documentation-Block + if (preg_match('/^documentation:\s*\n(.*?)(?=^\/v\d|^types:|$)/ms', $existing, $match)) { + return "\ndocumentation:\n" . $match[1]; + } + } + + // Fallback: Minimale Dokumentation + return << Einstellungen > API-Account*. + +RAML; + } + + private function generateEndpoints(): string + { + $output = "\n"; + + // Routen nach Pfad gruppieren + $grouped = []; + foreach ($this->routes as $route) { + $basePath = $this->getBasePath($route['path']); + if (!isset($grouped[$basePath])) { + $grouped[$basePath] = []; + } + $grouped[$basePath][] = $route; + } + + // Sortieren + ksort($grouped); + + foreach ($grouped as $basePath => $routes) { + $output .= $this->generateEndpointGroup($basePath, $routes); + } + + return $output; + } + + private function getBasePath(string $path): string + { + // /v1/adressen/{id} -> /v1/adressen + return preg_replace('/\/\{[^}]+\}.*$/', '', $path); + } + + private function generateEndpointGroup(string $basePath, array $routes): string + { + $output = "{$basePath}:\n"; + + // Finde die zugehörige Resource + $resourceName = $this->findResourceForPath($basePath); + $resource = $resourceName ? ($this->resources[$resourceName] ?? null) : null; + + foreach ($routes as $route) { + $isDetailRoute = strpos($route['path'], '{id}') !== false; + + if ($isDetailRoute) { + // Detail-Route unter /{id} + continue; // Wird separat behandelt + } + + foreach ($route['methods'] as $method) { + $output .= $this->generateMethodBlock(strtolower($method), $route, $resource); + } + } + + // Detail-Routen + $detailRoutes = array_filter($routes, fn($r) => strpos($r['path'], '{id}') !== false); + if (!empty($detailRoutes)) { + $output .= " /{id}:\n"; + foreach ($detailRoutes as $route) { + foreach ($route['methods'] as $method) { + $output .= $this->generateMethodBlock(strtolower($method), $route, $resource, 4); + } + } + } + + return $output; + } + + private function generateMethodBlock(string $method, array $route, ?array $resource, int $indent = 2): string + { + $spaces = str_repeat(' ', $indent); + $permission = $route['handler']['permission'] ?? ''; + $action = $route['handler']['action'] ?? ''; + + $displayName = $this->getDisplayName($method, $route['path']); + $description = $this->getDescription($method, $permission); + + $output = "{$spaces}{$method}:\n"; + $output .= "{$spaces} displayName: {$displayName}\n"; + + if ($description) { + $output .= "{$spaces} description: |\n"; + $output .= "{$spaces} {$description}\n"; + if ($permission) { + $output .= "{$spaces} \n"; + $output .= "{$spaces} Permission: `{$permission}`\n"; + } + } + + // Query-Parameter für GET-List-Routen + if ($method === 'get' && strpos($route['path'], '{id}') === false && $resource) { + $queryParams = $this->generateQueryParameters($resource, $indent); + if ($queryParams) { + $output .= "{$spaces} queryParameters:\n{$queryParams}"; + } + } + + return $output; + } + + private function generateQueryParameters(array $resource, int $indent): string + { + $spaces = str_repeat(' ', $indent + 4); + $output = ''; + + foreach ($resource['filterParams'] as $param => $definition) { + $output .= "{$spaces}{$param}:\n"; + $output .= "{$spaces} description: Filter nach {$param}\n"; + $output .= "{$spaces} type: string\n"; + $output .= "{$spaces} required: false\n"; + } + + return $output; + } + + private function getDisplayName(string $method, string $path): string + { + $resourceName = $this->extractResourceName($path); + + $actions = [ + 'get' => strpos($path, '{id}') !== false ? 'Einzelnen Eintrag abrufen' : 'Liste abrufen', + 'post' => 'Erstellen', + 'put' => 'Bearbeiten', + 'delete' => 'Löschen', + ]; + + return ucfirst($resourceName) . ' ' . ($actions[$method] ?? $method); + } + + private function getDescription(string $method, string $permission): string + { + $descriptions = [ + 'get' => 'Endpunkt zum Abrufen von Daten.', + 'post' => 'Endpunkt zum Erstellen eines neuen Eintrags.', + 'put' => 'Endpunkt zum Bearbeiten eines vorhandenen Eintrags.', + 'delete' => 'Endpunkt zum Löschen eines Eintrags.', + ]; + + return $descriptions[$method] ?? ''; + } + + private function extractResourceName(string $path): string + { + // /v1/adressen -> adressen + preg_match('/\/v\d\/([^\/\{]+)/', $path, $match); + return $match[1] ?? 'resource'; + } + + private function findResourceForPath(string $path): ?string + { + $resourceMap = [ + 'adressen' => 'AddressResource', + 'artikel' => 'ArticleResource', + 'artikelkategorien' => 'ArticleCategoryResource', + 'aboartikel' => 'ArticleSubscriptionResource', + 'abogruppen' => 'ArticleSubscriptionGroupResource', + 'lieferadressen' => 'DeliveryAddressResource', + 'dateien' => 'FileResource', + 'gruppen' => 'GroupResource', + 'laender' => 'CountryResource', + 'steuersaetze' => 'TaxRateResource', + 'versandarten' => 'ShippingMethodResource', + 'zahlungsweisen' => 'PaymentMethodResource', + 'eigenschaften' => 'PropertyResource', + 'eigenschaftenwerte' => 'PropertyValueResource', + 'trackingnummern' => 'TrackingNumberResource', + 'wiedervorlagen' => 'ResubmissionResource', + 'adresstyp' => 'AddressTypeResource', + 'crmdokumente' => 'CrmDocumentResource', + 'docscan' => 'DocumentScannerResource', + ]; + + $resourceName = $this->extractResourceName($path); + return $resourceMap[$resourceName] ?? null; + } +} + +/** + * OpenAPI 3.0 Generator + */ +class OpenApiGenerator +{ + private array $routes; + private array $resources; + + public function __construct(array $routes, array $resources) + { + $this->routes = $routes; + $this->resources = $resources; + } + + public function generate(): string + { + $spec = [ + 'openapi' => '3.0.3', + 'info' => [ + 'title' => 'OpenXE REST-API', + 'description' => 'Die API befindet sich in Ihrer OpenXE-Installation im Unterordner `/www/api/`.', + 'version' => '1.0.0', + ], + 'servers' => [ + ['url' => 'http://localhost/api', 'description' => 'Lokale Installation'], + ], + 'security' => [ + ['digestAuth' => []], + ], + 'paths' => $this->generatePaths(), + 'components' => [ + 'securitySchemes' => [ + 'digestAuth' => [ + 'type' => 'http', + 'scheme' => 'digest', + ], + ], + ], + ]; + + return json_encode($spec, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + private function generatePaths(): array + { + $paths = []; + + foreach ($this->routes as $route) { + $path = $this->convertPath($route['path']); + + if (!isset($paths[$path])) { + $paths[$path] = []; + } + + foreach ($route['methods'] as $method) { + $paths[$path][strtolower($method)] = $this->generateOperation($route, $method); + } + } + + ksort($paths); + return $paths; + } + + private function convertPath(string $path): string + { + // /v1/adressen/{id:\d+} -> /v1/adressen/{id} + return preg_replace('/\{([^:}]+):[^}]+\}/', '{$1}', $path); + } + + private function generateOperation(array $route, string $method): array + { + $resourceName = $this->extractResourceName($route['path']); + $pathParams = $this->extractPathParameters($route['path']); + $isDetail = !empty($pathParams); + $permission = $route['handler']['permission'] ?? null; + + $operation = [ + 'tags' => [ucfirst($resourceName)], + 'summary' => $this->getSummary($method, $resourceName, $isDetail), + 'operationId' => $this->getOperationId($method, $route['path'], $isDetail), + ]; + + if ($permission) { + $operation['description'] = "Permission: `{$permission}`"; + } + + // Parameter initialisieren + $parameters = []; + + // Path-Parameter hinzufügen (für alle Routen mit Variablen im Pfad) + if (!empty($pathParams)) { + $parameters = array_merge($parameters, $pathParams); + } + + // Query-Parameter für GET List-Routen (ohne Path-Parameter) + if (strtoupper($method) === 'GET' && empty($pathParams)) { + $parameters = array_merge($parameters, [ + [ + 'name' => 'page', + 'in' => 'query', + 'schema' => ['type' => 'integer', 'default' => 1, 'minimum' => 1, 'maximum' => 1000], + 'description' => 'Seite der Ergebnisliste', + ], + [ + 'name' => 'items', + 'in' => 'query', + 'schema' => ['type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 1000], + 'description' => 'Anzahl der Ergebnisse pro Seite', + ], + [ + 'name' => 'sort', + 'in' => 'query', + 'schema' => ['type' => 'string'], + 'description' => 'Sortierung (z.B. "name" oder "-name" für absteigend)', + ], + ]); + } + + // Parameter nur hinzufügen wenn vorhanden + if (!empty($parameters)) { + $operation['parameters'] = $parameters; + } + + // Request Body für POST/PUT - generisches Schema + if (in_array(strtoupper($method), ['POST', 'PUT'])) { + $operation['requestBody'] = [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => ['type' => 'object', 'additionalProperties' => true], + ], + 'application/xml' => [ + 'schema' => ['type' => 'object', 'additionalProperties' => true], + ], + ], + ]; + } + + // Response-Schemas + $responses = []; + + if (strtoupper($method) === 'GET') { + if ($isDetail) { + $responses['200'] = [ + 'description' => 'Erfolgreiche Anfrage', + 'content' => [ + 'application/json' => [ + 'schema' => ['type' => 'object', 'properties' => ['data' => ['type' => 'object']]], + ], + ], + ]; + } else { + $responses['200'] = [ + 'description' => 'Erfolgreiche Anfrage', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'data' => ['type' => 'array', 'items' => ['type' => 'object']], + 'pagination' => [ + 'type' => 'object', + 'properties' => [ + 'items_total' => ['type' => 'integer'], + 'items_current' => ['type' => 'integer'], + 'items_per_page' => ['type' => 'integer'], + 'page_current' => ['type' => 'integer'], + 'page_last' => ['type' => 'integer'], + ], + ], + ], + ], + ], + ], + ]; + } + } elseif (in_array(strtoupper($method), ['POST', 'PUT'])) { + $responses['200'] = [ + 'description' => strtoupper($method) === 'POST' ? 'Erfolgreich erstellt' : 'Erfolgreich aktualisiert', + 'content' => [ + 'application/json' => [ + 'schema' => ['type' => 'object', 'properties' => ['data' => ['type' => 'object']]], + ], + ], + ]; + } elseif (strtoupper($method) === 'DELETE') { + $responses['200'] = [ + 'description' => 'Erfolgreich gelöscht', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'data' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + ], + ], + 'success' => ['type' => 'boolean'], + ], + ], + ], + ], + ]; + } else { + $responses['200'] = ['description' => 'Erfolgreiche Anfrage']; + } + + $responses['401'] = [ + 'description' => 'Nicht authentifiziert', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'error' => [ + 'type' => 'object', + 'properties' => [ + 'code' => ['type' => 'integer'], + 'http_code' => ['type' => 'integer'], + 'message' => ['type' => 'string'], + ], + ], + ], + ], + ], + ], + ]; + + $responses['404'] = [ + 'description' => 'Ressource nicht gefunden', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'error' => [ + 'type' => 'object', + 'properties' => [ + 'code' => ['type' => 'integer'], + 'http_code' => ['type' => 'integer'], + 'message' => ['type' => 'string'], + ], + ], + ], + ], + ], + ], + ]; + + $operation['responses'] = $responses; + + return $operation; + } + + /** + * Extrahiert den Resource-Pfad (z.B. belege/angebote) + */ + private function extractResourcePath(string $path): string + { + // /v1/belege/angebote/{id} -> belege/angebote + if (preg_match('/\/v\d\/(.+?)(?:\/\{|$)/', $path, $match)) { + return rtrim($match[1], '/'); + } + return $this->extractResourceName($path); + } + + private function extractResourceName(string $path): string + { + preg_match('/\/v\d\/([^\/\{]+)/', $path, $match); + return $match[1] ?? 'resource'; + } + + private function getSummary(string $method, string $resource, bool $isDetail): string + { + $actions = [ + 'GET' => $isDetail ? 'Einzelnen Eintrag abrufen' : 'Liste abrufen', + 'POST' => 'Erstellen', + 'PUT' => 'Bearbeiten', + 'DELETE' => 'Löschen', + ]; + + return ucfirst($resource) . ': ' . ($actions[strtoupper($method)] ?? $method); + } + + private function getOperationId(string $method, string $fullPath, bool $isDetail): string + { + $actions = [ + 'GET' => $isDetail ? 'get' : 'list', + 'POST' => 'create', + 'PUT' => 'update', + 'DELETE' => 'delete', + ]; + + $action = $actions[strtoupper($method)] ?? strtolower($method); + + // Extrahiere Version und kompletten Resource-Pfad (auch nach {id}) + // Beispiel: /v1/dateien/{id}/base64 -> V1DateienBase64 + if (preg_match('/\/(v\d+)\/(.+)$/', $fullPath, $match)) { + $version = ucfirst($match[1]); // V1, V2 + $resourcePath = $match[2]; + + // Entferne Parameter wie {id}, {param} etc. + $resourcePath = preg_replace('/\{[^}]+\}/', '', $resourcePath); + // Entferne mehrfache/trailing slashes + $resourcePath = trim(preg_replace('#/+#', '/', $resourcePath), '/'); + + $parts = explode('/', $resourcePath); + $camelCaseName = implode('', array_map('ucfirst', $parts)); + return $action . $version . $camelCaseName; + } + + // Fallback + return $action . 'Resource'; + } + + /** + * Extrahiert alle Path-Parameter aus einem Pfad + */ + private function extractPathParameters(string $path): array + { + $params = []; + + // Finde alle {param} oder {param:regex} Muster + if (preg_match_all('/\{([^:}]+)(?::[^}]+)?\}/', $path, $matches)) { + foreach ($matches[1] as $paramName) { + $params[] = [ + 'name' => $paramName, + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => $paramName === 'id' ? 'integer' : 'string'], + 'description' => $paramName === 'id' ? 'ID des Eintrags' : ucfirst($paramName), + ]; + } + } + + return $params; + } +} + +// ============================================================================= +// MAIN +// ============================================================================= + +echo "OpenXE API Documentation Generator\n"; +echo "===================================\n\n"; + +$extractor = new ApiDocumentationExtractor(ROOT_PATH . '/classes'); + +echo "📖 Extrahiere Routen aus ApiApplication.php...\n"; +$routes = $extractor->extractRoutes(); +echo " Gefunden: " . count($routes) . " REST-API Routen\n"; + +echo "📦 Extrahiere Resource-Metadaten...\n"; +$resources = $extractor->extractResources(); +echo " Gefunden: " . count($resources) . " Resource-Klassen\n\n"; + +$existingDocsPath = ROOT_PATH . '/www/api/docs.raml'; + +if ($format === 'openapi') { + echo "🔧 Generiere OpenAPI 3.0 Spezifikation...\n"; + $generator = new OpenApiGenerator($routes, $resources); + $output = $generator->generate(); + $defaultOutput = ROOT_PATH . '/www/api/openapi.json'; +} else { + echo "🔧 Generiere RAML-Dokumentation...\n"; + $generator = new RamlGenerator($routes, $resources, $existingDocsPath); + $output = $generator->generate(); + $defaultOutput = ROOT_PATH . '/www/api/docs.generated.raml'; +} + +$targetFile = $outputFile ?? $defaultOutput; + +if ($checkOnly) { + // Prüfmodus: Vergleiche mit bestehender Datei + if (!file_exists($targetFile)) { + echo "❌ Zieldatei existiert nicht: {$targetFile}\n"; + exit(1); + } + + $existing = file_get_contents($targetFile); + if ($existing === $output) { + echo "✅ Dokumentation ist aktuell.\n"; + exit(0); + } else { + echo "❌ Dokumentation ist nicht aktuell. Bitte `php tools/generate-api-docs.php` ausführen.\n"; + exit(1); + } +} + +// Schreibe Ausgabe +file_put_contents($targetFile, $output); +echo "✅ Dokumentation geschrieben: {$targetFile}\n"; + +// Statistik +echo "\n📊 Statistik:\n"; +echo " - REST-API Endpunkte: " . count($routes) . "\n"; +echo " - Resource-Klassen: " . count($resources) . "\n"; + +// Hinweis für raml2html +if ($format === 'raml') { + echo "\n💡 Tipp: HTML-Dokumentation generieren mit:\n"; + echo " npx raml2html www/api/docs.generated.raml > www/api/docs.html\n"; +} + +if ($format === 'openapi') { + echo "\n💡 Tipp: Swagger UI starten mit:\n"; + echo " npx @redocly/cli preview-docs www/api/openapi.json\n"; +} + +echo "\n✨ Fertig!\n"; diff --git a/www/api/.htaccess b/www/api/.htaccess new file mode 100644 index 000000000..bc8e3905f --- /dev/null +++ b/www/api/.htaccess @@ -0,0 +1,15 @@ +# Redirect documentation files through authenticated PHP handler +RewriteEngine On +RewriteRule ^(swagger\.html|docs\.html|openapi\.json|docs\.raml|docs\.generated\.raml)$ docs.php?file=$1 [L,QSA] + +# Allow PHP files + + Order Allow,Deny + Allow from all + + +# Deny direct access to static documentation files (force through docs.php) + + Order Deny,Allow + Deny from all + diff --git a/www/api/docs.php b/www/api/docs.php new file mode 100644 index 000000000..4f47945c6 --- /dev/null +++ b/www/api/docs.php @@ -0,0 +1,50 @@ + 'text/html', + 'json' => 'application/json', + 'raml' => 'application/raml+yaml', +]; + +$extension = pathinfo($file, PATHINFO_EXTENSION); +$contentType = $contentTypes[$extension] ?? 'text/plain'; + +// Set appropriate headers +header('Content-Type: ' . $contentType . '; charset=utf-8'); +header('X-Content-Type-Options: nosniff'); + +// Output the file +readfile($filePath); diff --git a/www/api/swagger.html b/www/api/swagger.html new file mode 100644 index 000000000..08af2f2d1 --- /dev/null +++ b/www/api/swagger.html @@ -0,0 +1,54 @@ + + + + + + OpenXE REST-API - Swagger UI + + + + +
+ + + + +