From f445ede6c4e65f629ba2bcf8b374ffb89b53c2a5 Mon Sep 17 00:00:00 2001 From: Jan Kristinus Date: Sun, 23 Aug 2026 12:13:50 +0200 Subject: [PATCH 1/7] =?UTF-8?q?Selbstauskunft-Endpunkt=20/api/me=20hinzuf?= =?UTF-8?q?=C3=BCgen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients und Agenten konnten bisher nicht ermitteln, welche Endpunkte ein Token nutzen darf: Die OpenAPI-Spec lag nur hinter der Backend-Seite (page=api/openapi, perm api[]), und ein Token hatte keine Selbstauskunft über seine Scopes. Die Pfade mussten damit außerhalb der API mitgeteilt werden, weil sie sich nicht zuverlässig aus dem Scope-Namen ableiten lassen. - GET /api/me listet ausschließlich die Endpunkte, deren Scope vorhanden ist, mit Methoden, Pfad, Beschreibung und Parametern (path/query/body inkl. Typ, required, Default, Beschreibung). Default-Format kompakt, ?format=openapi liefert dieselbe Menge als gefilterte OpenAPI-3.0-Spec über den bestehenden OpenAPIConfig-Generator. - Der Endpunkt braucht keinen eigenen Scope: Auth::requiresScope() und new BearerAuth(false) autorisieren jedes gültige Token, sonst fehlte die Auskunft genau bei den Tokens, bei denen der Scope vergessen wurde. Token::getAvailableScopes() filtert solche Routen aus der Token-Seite. - Backend-Spiegel GET /api/backend/me für Session-Zugriffe. Dort wird nicht vorab gefiltert, da Backend-Permissions pro Request geprüft werden; der Hinweis steht in meta.note. - RouteCollection::handle() klont die Routen vor dem Präfixen, damit die registrierten Route-Objekte ihren unpräfixierten Pfad behalten und Controller, die getRoutes() auslesen, /api nicht doppelt sehen. - Bei gültigem Token ohne passenden Scope nennt die 401-Antwort den fehlenden Scope (required_scope); bei ungültigem Token bleibt das Feld weg. Statuscode unverändert. Refs #55 --- CLAUDE.md | 8 +- README.md | 50 +++++- boot.php | 4 + lang/de_de.lang | 1 + lib/Auth/Auth.php | 9 + lib/Auth/BearerAuth.php | 17 ++ lib/RouteCollection.php | 16 +- lib/RoutePackage/Backend/Discovery.php | 31 ++++ lib/RoutePackage/Discovery.php | 235 +++++++++++++++++++++++++ lib/Token.php | 2 +- tests/BackendApiTest.php | 44 +++++ tests/MeApiTest.php | 213 ++++++++++++++++++++++ 12 files changed, 624 insertions(+), 6 deletions(-) create mode 100644 lib/RoutePackage/Backend/Discovery.php create mode 100644 lib/RoutePackage/Discovery.php create mode 100644 tests/MeApiTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 483a092..254c6da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,11 @@ Tests sind **Integrationstests**, die echte HTTP-Requests via cURL an eine laufe - `BearerAuth` — Token-basierte Authentifizierung via `Authorization: Bearer ` Header, validiert gegen `rex_api_token`-Tabelle mit Scope-Prüfung - `BackendUser` — Session-Cookie-Authentifizierung für reine Backend-Endpunkte - **`Token`** (`lib/Token.php`) — Verwaltet API-Tokens in der `rex_api_token`-Tabelle. Tokens haben Scopes (kommagetrennte Route-Scope-Namen). -- **`OpenAPIConfig`** (`lib/OpenAPIConfig.php`) — Generiert OpenAPI-3.0-Spezifikation aus registrierten Routen für Swagger UI. +- **`OpenAPIConfig`** (`lib/OpenAPIConfig.php`) — Generiert OpenAPI-3.0-Spezifikation aus registrierten Routen für Swagger UI. Wird auch von `/api/me?format=openapi` genutzt, dort mit einer gefilterten Routen-Teilmenge. + +**Scope-freie Routen:** `Auth::requiresScope()` (Default `true`) entscheidet, ob der Route-Scope explizit vergeben sein muss. `new BearerAuth(false)` autorisiert jedes gültige Token ohne Scope-Prüfung — genutzt von `/api/me`, damit die Selbstauskunft nicht genau bei den Tokens fehlt, bei denen der Scope vergessen wurde. Solche Routen werden von `Token::getAvailableScopes()` ausgefiltert und erscheinen deshalb nicht als Checkbox auf der Token-Seite. + +**Route-Objekte nicht mutieren:** `RouteCollection::handle()` klont jede Route, bevor es `/api` an den Pfad hängt. Die registrierten `Route`-Objekte behalten ihren unpräfixierten Pfad — Handler, die `RouteCollection::getRoutes()` auslesen (z.B. `Discovery`), würden sonst `/api` doppelt sehen. ### Route Packages (lib/RoutePackage/) @@ -67,6 +71,7 @@ Jede Datei definiert Routen und Handler-Methoden für eine Ressourcengruppe: | `Templates.php` | Templates CRUD | `templates/` | | `Clangs.php` | Sprachen CRUD | `system/clangs/` | | `Metainfo.php` | Metainfo-Felddefinitionen + Werte (Artikel/Kategorie/Medium/Sprache) | `metainfo/` | +| `Discovery.php` | Selbstauskunft `/api/me` (erlaubte Endpunkte, OpenAPI gefiltert) | — (scope-frei) | Die `lib/RoutePackage/Backend/`-Klassen erweitern jeweils ihre Bearer-Variante, klonen alle passenden Routen, hängen `backend/` an Pfad und Scope und ersetzen das Auth-Objekt durch `BackendUser`. Beim Anlegen eines neuen Bearer-Endpunkts entsteht der Backend-Spiegel automatisch — eigene `Backend/*.php`-Implementierungen sind nur nötig, wenn das Standardverhalten überschrieben werden soll (Beispiel: `Backend/Media.php`). @@ -111,6 +116,7 @@ RouteCollection::registerRoute( - **PRE-Extension-Points & API-Kontext**: Manche Extension Points (z.B. `SLICE_UPDATE`, `SLICE_DELETE`) rufen `rex::requireUser()` auf — das schlägt im Bearer-Token-Kontext fehl. Im API-Kontext entweder den EP nur firen, wenn `rex::getUser() !== null`, oder die Service-Methode bewusst umgehen und nur den POST-EP firen (siehe `Structure::handleUpdateArticleSlice` / `handleDeleteArticleSlice`). - **Service-Exceptions**: `rex_api_exception` trägt eine i18n-übersetzte Message. Status-Code daher nicht über `str_contains($e->getMessage(), 'not found')` ermitteln (locale-abhängig), sondern über einen Helper, der EN- und DE-Marker prüft (siehe `Users::statusFromApiException`). - Rückgabe: `new Response(json_encode($data), $statusCode)` +- **401 mit `required_scope`**: Ist das Bearer-Token gültig, fehlt aber der Scope, ergänzt `handle()` den benötigten Scope-Namen im Fehler-Body. Bei ungültigem Token bleibt das Feld weg — der Statuscode ist in beiden Fällen 401. ### Verbindlich: Exaktes Spiegeln des REDAXO-Core-Verhaltens diff --git a/README.md b/README.md index ebcb991..a518425 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ Spalten: **Status** = Endpoint implementiert · **Test** = Bearer-API-Test vorha | /api/media/{filename}/metainfo | PUT/PATCH | Medien-Metainfo ändern | ✅ | ✅ | ✅ | ✅ | | /api/system/clangs/{id}/metainfo | GET | Sprach-Metainfo lesen | ✅ | ✅ | ✅ | ✅ | | /api/system/clangs/{id}/metainfo | PUT/PATCH | Sprach-Metainfo ändern | ✅ | ✅ | ✅ | ✅ | +| /api/me | GET | Selbstauskunft: erlaubte Endpunkte | ✅ | ✅ | ✅ | ✅ | **Metainfo & Backend:** Wert-Endpunkte (Article/Category/Media/Clang) sind via Backend-Session erreichbar und prüfen die jeweiligen User-Rechte: `structure`-Perm für Article/Category, `media`-Perm für Media, **admin-only für Clang** (REDAXO-Core's Sprachen-Page `pages/system.clangs.php` ist via `setRequiredPermissions('isAdmin')` ebenfalls admin-only — wir spiegeln das exakt). Field-Management (`/metainfo/types`, `/metainfo/fields`, `/metainfo/fields/{id}`) bleibt bewusst Bearer-only — Schema-Änderungen sind kein typischer Backend-User-Job. @@ -105,9 +106,56 @@ RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] Die meisten APIs haben Authentifizierung. Das heisst, es muss ein API-Token im Backend angelegt werden, um die Endpunkte nutzen zu können, wie auch der entsprechende Scope gesetzt werden. Andere APIs haben eine Backend-Authentifizierung, die dann über den Backend-User läuft, d.h. es kann der Session Cookie verwendet werden, um die Endpunkte zu nutzen. +## Selbstauskunft: /api/me + +`GET /api/me` beantwortet für den *aufrufenden* Zugang die Frage, was er darf. Gedacht für Clients und Agenten, die die API ohne externe Doku bedienen sollen: + +* Gelistet werden **nur Endpunkte, für die der Scope tatsächlich vorhanden ist** — nicht die komplette Routentabelle. +* Der Endpunkt braucht **keinen eigenen Scope**. Jedes gültige Token bekommt eine Antwort, auch ein neu angelegtes. +* Das Token selbst wird nicht ausgegeben, nur sein Name und seine Scopes. + +```bash +curl -H "Authorization: Bearer DEIN_TOKEN" https://example.org/api/me +``` + +```json +{ + "meta": { + "api_base": "/api", + "auth": { "type": "bearer", "token_name": "Sync", "scopes": ["structure/articles/list", "..."] }, + "endpoint_count": 26, + "openapi_url": "/api/me?format=openapi" + }, + "endpoints": [ + { + "scope": "structure/articles/get", + "methods": ["GET"], + "path": "/api/structure/articles/{id}", + "description": "Get article details", + "tags": ["default"], + "path_parameters": { "id": { "required": true, "type": "string", "pattern": "\\d+" } } + } + ] +} +``` + +Pro Endpunkt werden `path_parameters`, `query` und `body` mit Typ, `required`, Default und Beschreibung ausgegeben — leere Blöcke werden weggelassen. `required` folgt der Validierung: ein Feld ohne explizites `required` **ist** erforderlich. + +`GET /api/me?format=openapi` liefert dieselbe Menge als vollständige OpenAPI-3.0-Spezifikation — gleicher Generator wie die Swagger-UI im Backend, nur auf die erlaubten Routen gefiltert. Das kompakte Format ist der Default, weil es bei vielen Routen deutlich weniger Kontext kostet. + +Für Backend-Session-Zugriffe gibt es `GET /api/backend/me`. Dort wird nicht vorab gefiltert: Backend-Permissions werden pro Request geprüft, ein gelisteter Endpunkt kann also weiterhin mit 403 antworten. Der Hinweis steht in `meta.note`. + +### Fehlender Scope ist unterscheidbar + +Bei einem gültigen Token ohne den benötigten Scope nennt die 401-Antwort den Scope, der fehlt. Bei ungültigem oder fehlendem Token fehlt das Feld: + +```json +{ "error": "Authorization failed", "required_scope": "users/list" } +``` + ## API Struktur -Am besten direkt im AddOn unter OpenAPI nachsehen. Dort werden alle verfügbaren Endpunkte aufgelistet. +Am besten direkt im AddOn unter OpenAPI nachsehen. Dort werden alle verfügbaren Endpunkte aufgelistet. Programmatisch übernimmt das `/api/me` (siehe oben). ### Response-Format für Listen-Endpunkte diff --git a/boot.php b/boot.php index 422d720..2999acb 100644 --- a/boot.php +++ b/boot.php @@ -2,6 +2,7 @@ use FriendsOfRedaxo\Api\RouteCollection; use FriendsOfRedaxo\Api\RoutePackage\Backend\Clangs as BackendClangs; +use FriendsOfRedaxo\Api\RoutePackage\Backend\Discovery as BackendDiscovery; use FriendsOfRedaxo\Api\RoutePackage\Backend\Media as BackendMedia; use FriendsOfRedaxo\Api\RoutePackage\Backend\Metainfo as BackendMetainfo; use FriendsOfRedaxo\Api\RoutePackage\Backend\Modules as BackendModules; @@ -9,6 +10,7 @@ use FriendsOfRedaxo\Api\RoutePackage\Backend\Templates as BackendTemplates; use FriendsOfRedaxo\Api\RoutePackage\Backend\Users as BackendUsers; use FriendsOfRedaxo\Api\RoutePackage\Clangs; +use FriendsOfRedaxo\Api\RoutePackage\Discovery; use FriendsOfRedaxo\Api\RoutePackage\Media; use FriendsOfRedaxo\Api\RoutePackage\Metainfo; use FriendsOfRedaxo\Api\RoutePackage\Modules; @@ -23,6 +25,7 @@ RouteCollection::registerRoutePackage(new Media()); RouteCollection::registerRoutePackage(new Users()); RouteCollection::registerRoutePackage(new Metainfo()); +RouteCollection::registerRoutePackage(new Discovery()); RouteCollection::registerRoutePackage(new BackendClangs()); RouteCollection::registerRoutePackage(new BackendMedia()); RouteCollection::registerRoutePackage(new BackendMetainfo()); @@ -30,6 +33,7 @@ RouteCollection::registerRoutePackage(new BackendStructure()); RouteCollection::registerRoutePackage(new BackendTemplates()); RouteCollection::registerRoutePackage(new BackendUsers()); +RouteCollection::registerRoutePackage(new BackendDiscovery()); if (!rex::getConsole()) { rex_extension::register('YREWRITE_PREPARE', static function (rex_extension_point $ep) { diff --git a/lang/de_de.lang b/lang/de_de.lang index 277f47e..d678dc3 100644 --- a/lang/de_de.lang +++ b/lang/de_de.lang @@ -28,5 +28,6 @@ api_token_added = API-Token wurde hinzugefügt api_openapi_title = OpenAPI Dokumentation api_openapi_description = Hier werden alle erfassten Endpunkte ausgegeben und können eingesehen und getestet werden. Um sie nutzen zu können, muss vorher ein Token mit den entsprechenden Zugriffen erstellt werden. +api_openapi_tag_meta_description = Selbstauskunft: welche Endpunkte das aktuelle Token bzw. der Backend-User nutzen darf readme = ReadMe diff --git a/lib/Auth/Auth.php b/lib/Auth/Auth.php index 5196ffe..7196a64 100644 --- a/lib/Auth/Auth.php +++ b/lib/Auth/Auth.php @@ -13,6 +13,15 @@ public function __construct() abstract public function isAuthorized(array $parameters): bool; + /** + * Whether the route scope must be granted explicitly for this auth handler. + * Discovery routes answer for every valid credential and therefore return false. + */ + public function requiresScope(): bool + { + return true; + } + public function getAuthorizationObject(): mixed { return null; diff --git a/lib/Auth/BearerAuth.php b/lib/Auth/BearerAuth.php index 023be4d..2925a88 100644 --- a/lib/Auth/BearerAuth.php +++ b/lib/Auth/BearerAuth.php @@ -11,12 +11,29 @@ class BearerAuth extends Auth { private ?Token $Token = null; + /** + * @param bool $requireScope false: every valid token is authorized, no scope needed (discovery routes) + */ + public function __construct( + private bool $requireScope = true, + ) { + parent::__construct(); + } + + public function requiresScope(): bool + { + return $this->requireScope; + } + public function isAuthorized($parameters): bool { $this->Token = Token::getFromBearerToken(); if (null === $this->Token) { return false; } + if (!$this->requireScope) { + return true; + } if (in_array($parameters['_route'], $this->Token->getScopes(), true)) { return true; } diff --git a/lib/RouteCollection.php b/lib/RouteCollection.php index 9d5b977..8300434 100644 --- a/lib/RouteCollection.php +++ b/lib/RouteCollection.php @@ -4,6 +4,7 @@ use Exception; use FriendsOfRedaxo\Api\Auth as ApiAuth; +use FriendsOfRedaxo\Api\Auth\BearerAuth; use rex; use rex_logger; use rex_response; @@ -114,8 +115,11 @@ public static function handle(): void $RegisterdRoutes = self::getRoutes(); foreach ($RegisterdRoutes as $AddedRouteScope => $AddedRoute) { - $AddedRoute['route']->setPath('/' . self::$preRoute . $AddedRoute['route']->getPath()); - $routes->add($AddedRouteScope, $AddedRoute['route']); + // clone, so the registered route keeps its unprefixed path: + // controllers reading getRoutes() (e.g. Discovery) would otherwise see /api twice + $MatchRoute = clone $AddedRoute['route']; + $MatchRoute->setPath('/' . self::$preRoute . $MatchRoute->getPath()); + $routes->add($AddedRouteScope, $MatchRoute); } $context = new RequestContext(); @@ -144,7 +148,13 @@ public static function handle(): void // if no AuthObject is set, we assume that the route is public if ($AuthObject && !$AuthObject->isAuthorized($parameters)) { - $Response = new JsonResponse(['error' => 'Authorization failed'], 401); + $Error = ['error' => 'Authorization failed']; + // credential is valid, only the scope is missing: name it, so callers can tell + // an invalid token from a missing permission + if ($AuthObject instanceof BearerAuth && null !== $AuthObject->getAuthorizationObject()) { + $Error['required_scope'] = $parameters['_route']; + } + $Response = new JsonResponse($Error, 401); } else { try { $Response = $controller($parameters, $RegisterdRoutes[$parameters['_route']]); diff --git a/lib/RoutePackage/Backend/Discovery.php b/lib/RoutePackage/Backend/Discovery.php new file mode 100644 index 0000000..818af4a --- /dev/null +++ b/lib/RoutePackage/Backend/Discovery.php @@ -0,0 +1,31 @@ +setPath('backend' . $route->getPath()); + + RouteCollection::registerRoute( + 'backend/me', + $route, + $Route['description'], + $Route['responses'], + new BackendUser(), + ['backend'], + ); + } + } + } +} diff --git a/lib/RoutePackage/Discovery.php b/lib/RoutePackage/Discovery.php new file mode 100644 index 0000000..2e27c7e --- /dev/null +++ b/lib/RoutePackage/Discovery.php @@ -0,0 +1,235 @@ + 'FriendsOfRedaxo\Api\RoutePackage\Discovery::handleMe', + 'query' => [ + 'format' => [ + 'type' => 'string', + 'required' => false, + 'default' => 'compact', + 'description' => 'Response format: "compact" (default) or "openapi" (OpenAPI 3.0 specification)', + ], + ], + ], + [], + [], + '', + [], + ['GET']), + 'List the endpoints and parameters the current credential may use', + null, + new BearerAuth(false), + ['meta'], + ); + } + + /** @api */ + public static function handleMe($Parameter, array $Route = []): Response + { + try { + $Query = RouteCollection::getQuerySet($_REQUEST, $Parameter['query']); + } catch (Exception $e) { + return new JsonResponse(['error' => 'query field: ' . $e->getMessage() . ' is required'], 400); + } + + $format = mb_strtolower((string) ($Query['format'] ?? 'compact')); + if (!in_array($format, self::FORMATS, true)) { + return new JsonResponse(['error' => 'query field: format must be one of ' . implode(', ', self::FORMATS)], 400); + } + + $Auth = $Route['authorization'] ?? null; + $AuthObject = (null === $Auth) ? null : $Auth->getAuthorizationObject(); + + $Allowed = []; + $Meta = [ + 'api_base' => '/' . RouteCollection::$preRoute, + ]; + + if ($AuthObject instanceof Token) { + $Scopes = $AuthObject->getScopes(); + foreach (RouteCollection::getRoutes() as $Scope => $RouteArray) { + $RouteAuth = $RouteArray['authorization'] ?? null; + if (!$RouteAuth instanceof BearerAuth) { + continue; + } + if ($RouteAuth->requiresScope() && !in_array($Scope, $Scopes, true)) { + continue; + } + $Allowed[$Scope] = $RouteArray; + } + + $Meta['auth'] = [ + 'type' => 'bearer', + 'token_name' => $AuthObject->getName(), + 'scopes' => $Scopes, + ]; + } elseif ($AuthObject instanceof rex_user) { + foreach (RouteCollection::getRoutes() as $Scope => $RouteArray) { + if (!($RouteArray['authorization'] ?? null) instanceof BackendUser) { + continue; + } + $Allowed[$Scope] = $RouteArray; + } + + $Meta['auth'] = [ + 'type' => 'backend_session', + 'login' => $AuthObject->getLogin(), + 'admin' => $AuthObject->isAdmin(), + ]; + $Meta['note'] = 'Backend endpoints are not filtered by user permissions: those are checked per request, a listed endpoint may still answer 403.'; + } else { + return new JsonResponse(['error' => 'Authorization failed'], 401); + } + + if ('openapi' === $format) { + $Config = OpenAPIConfig::getByRoutes($Allowed); + return new JsonResponse(json_encode($Config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 200, [], true); + } + + $Endpoints = []; + foreach ($Allowed as $Scope => $RouteArray) { + $Endpoints[] = self::describeRoute($Scope, $RouteArray); + } + + $Meta['endpoint_count'] = count($Endpoints); + $Meta['openapi_url'] = self::path($Route['route'] ?? null) . '?format=openapi'; + + $Result = [ + 'meta' => $Meta, + 'endpoints' => $Endpoints, + ]; + + return new JsonResponse(json_encode($Result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 200, [], true); + } + + /** + * @return array + */ + private static function describeRoute(string $Scope, array $RouteArray): array + { + /** @var Route $Route */ + $Route = $RouteArray['route']; + $Methods = $Route->getMethods(); + + $Entry = [ + 'scope' => $Scope, + 'methods' => (0 === count($Methods)) ? ['GET'] : $Methods, + 'path' => self::path($Route), + 'description' => $RouteArray['description'], + 'tags' => $RouteArray['tags'] ?? ['default'], + ]; + + $PathParameters = []; + foreach ($Route->getRequirements() as $Key => $Requirement) { + $Parameter = ['required' => true]; + if (is_array($Requirement)) { + $Parameter['type'] = $Requirement['type'] ?? 'string'; + if (isset($Requirement['description'])) { + $Parameter['description'] = $Requirement['description']; + } + } else { + $Parameter['type'] = 'string'; + $Parameter['pattern'] = $Requirement; + } + $PathParameters[$Key] = $Parameter; + } + + if (0 < count($PathParameters)) { + $Entry['path_parameters'] = $PathParameters; + } + + $QueryFields = self::describeFields($Route->getDefault('query') ?? []); + if (0 < count($QueryFields)) { + $Entry['query'] = $QueryFields; + } + + $BodyFields = self::describeFields($Route->getDefault('Body') ?? []); + if (0 < count($BodyFields)) { + $Entry['body'] = $BodyFields; + } + + return $Entry; + } + + /** + * Mirrors the semantics of RouteCollection::getQuerySet(): a field without + * an explicit "required" key is required. + * + * @return array + */ + private static function describeFields(array $Definition): array + { + $Fields = []; + foreach ($Definition as $Key => $Field) { + if (!is_array($Field)) { + continue; + } + + $Described = [ + 'type' => $Field['type'] ?? 'string', + 'required' => $Field['required'] ?? true, + ]; + if (array_key_exists('default', $Field)) { + $Described['default'] = $Field['default']; + } + if (isset($Field['description']) && '' !== $Field['description']) { + $Described['description'] = $Field['description']; + } + if (isset($Field['fields']) && is_array($Field['fields'])) { + $Described['fields'] = self::describeFields($Field['fields']); + } + + $Fields[$Key] = $Described; + } + return $Fields; + } + + private static function path(?Route $Route): string + { + if (null === $Route) { + return '/' . RouteCollection::$preRoute; + } + return '/' . RouteCollection::$preRoute . $Route->getPath(); + } +} diff --git a/lib/Token.php b/lib/Token.php index 0eba37a..4c61ee3 100644 --- a/lib/Token.php +++ b/lib/Token.php @@ -84,7 +84,7 @@ public static function getAvailableScopes(): array { $Scopes = []; foreach (RouteCollection::getRoutes() as $RouteScope => $Route) { - if ($Route['authorization'] instanceof BearerAuth) { + if ($Route['authorization'] instanceof BearerAuth && $Route['authorization']->requiresScope()) { $Scopes[] = $Route['scope']; } } diff --git a/tests/BackendApiTest.php b/tests/BackendApiTest.php index 7cbb078..ac617e8 100644 --- a/tests/BackendApiTest.php +++ b/tests/BackendApiTest.php @@ -1176,4 +1176,48 @@ public function testAdminRoleCRUD(): void $this->assertSame(200, $deleteResponse['status']); } } + + // --------------------------------------------------------------------- + // Selbstauskunft (/api/backend/me) + // --------------------------------------------------------------------- + + public function testAdminCanReadOwnCapabilities(): void + { + $response = $this->adminGet('me'); + + $this->assertSame(200, $response['status'], 'Selbstauskunft muss ohne eigene Permission antworten.'); + $this->assertSame('backend_session', $response['data']['meta']['auth']['type'] ?? null); + $this->assertTrue($response['data']['meta']['auth']['admin'] ?? false); + $this->assertNotEmpty($response['data']['endpoints'] ?? []); + + foreach ($response['data']['endpoints'] as $endpoint) { + $this->assertStringStartsWith('backend/', $endpoint['scope']); + $this->assertStringStartsWith('/api/backend/', $endpoint['path']); + } + } + + public function testRestrictedUserCanReadOwnCapabilities(): void + { + // Permissions werden pro Request geprüft, nicht bei der Auflistung — + // der eingeschränkte User bekommt dieselbe Liste, aber admin = false. + $response = $this->restrictedGet('me'); + + $this->assertSame(200, $response['status']); + $this->assertSame('backend_session', $response['data']['meta']['auth']['type'] ?? null); + $this->assertFalse($response['data']['meta']['auth']['admin'] ?? true); + $this->assertNotEmpty($response['data']['meta']['note'] ?? ''); + } + + public function testCapabilitiesOpenApiFormat(): void + { + $response = $this->adminGet('me?format=openapi'); + + $this->assertSame(200, $response['status']); + $this->assertSame('3.0.0', $response['data']['openapi'] ?? null); + $this->assertNotEmpty($response['data']['paths'] ?? []); + + foreach (array_keys($response['data']['paths']) as $path) { + $this->assertStringStartsWith('/backend/', $path, 'Backend-Spec darf nur Backend-Routen enthalten: ' . $path); + } + } } diff --git a/tests/MeApiTest.php b/tests/MeApiTest.php new file mode 100644 index 0000000..b480034 --- /dev/null +++ b/tests/MeApiTest.php @@ -0,0 +1,213 @@ +doRequest(self::$token); + + $this->assertSame(200, $response['status']); + $this->assertSame('bearer', $response['data']['meta']['auth']['type'] ?? null); + $this->assertArrayHasKey('endpoints', $response['data']); + $this->assertNotEmpty($response['data']['endpoints']); + $this->assertArrayNotHasKey('openapi', $response['data'], 'Default-Format muss kompakt sein, nicht OpenAPI.'); + $this->assertSame( + count($response['data']['endpoints']), + $response['data']['meta']['endpoint_count'] ?? null, + ); + } + + public function testEndpointEntriesAreSelfDescribing(): void + { + $response = $this->doRequest(self::$token); + $this->assertSame(200, $response['status']); + + foreach ($response['data']['endpoints'] as $endpoint) { + $this->assertArrayHasKey('scope', $endpoint); + $this->assertArrayHasKey('methods', $endpoint); + $this->assertArrayHasKey('path', $endpoint); + $this->assertArrayHasKey('description', $endpoint); + $this->assertNotEmpty($endpoint['methods']); + $this->assertStringStartsWith(self::$config['api_prefix'] . '/', $endpoint['path']); + } + } + + public function testMeListsItselfWithoutOwnScope(): void + { + // Der Endpunkt darf keinen eigenen Scope brauchen — sonst fehlt er + // genau bei den Tokens, bei denen der Scope vergessen wurde. + $response = $this->doRequest(self::$token); + $scopes = array_column($response['data']['endpoints'], 'scope'); + + $this->assertContains('me', $scopes); + $this->assertNotContains('me', $response['data']['meta']['auth']['scopes'] ?? []); + } + + public function testOnlyGrantedScopesAreListed(): void + { + $response = $this->doRequest(self::$token); + $granted = $response['data']['meta']['auth']['scopes'] ?? []; + + foreach ($response['data']['endpoints'] as $endpoint) { + if ('me' === $endpoint['scope']) { + continue; + } + $this->assertContains( + $endpoint['scope'], + $granted, + 'Gelistet wurde ein Scope, den das Token nicht hat: ' . $endpoint['scope'], + ); + } + } + + public function testBackendRoutesAreNotListedForBearerToken(): void + { + $response = $this->doRequest(self::$token); + + foreach ($response['data']['endpoints'] as $endpoint) { + $this->assertStringStartsNotWith('backend/', $endpoint['scope']); + } + } + + public function testRestrictedTokenSeesFewerEndpoints(): void + { + $restricted = self::$config['restricted_token'] ?? ''; + if ('' === $restricted) { + self::markTestSkipped('Kein Restricted-Token in tests/.env (API_TEST_RESTRICTED_TOKEN).'); + } + + $full = $this->doRequest(self::$token); + $limited = $this->doRequest($restricted); + + $this->assertSame(200, $limited['status'], 'Auch ein eingeschränktes Token muss /me nutzen dürfen.'); + $this->assertLessThan( + count($full['data']['endpoints']), + count($limited['data']['endpoints']), + ); + + $paths = array_column($limited['data']['endpoints'], 'path'); + $allowed = self::$config['api_prefix'] . '/' . ltrim(self::$config['restricted_token_allowed_path'], '/'); + $denied = self::$config['api_prefix'] . '/' . ltrim(self::$config['restricted_token_denied_path'], '/'); + + $this->assertContains($allowed, $paths); + $this->assertNotContains($denied, $paths); + } + + public function testInvalidTokenIsRejected(): void + { + $response = $this->doRequest('thisisnotavalidtoken_' . bin2hex(random_bytes(8))); + + $this->assertSame(401, $response['status']); + $this->assertArrayNotHasKey('required_scope', $response['data'] ?? []); + } + + public function testMissingScopeNamesTheRequiredScope(): void + { + $restricted = self::$config['restricted_token'] ?? ''; + if ('' === $restricted) { + self::markTestSkipped('Kein Restricted-Token in tests/.env (API_TEST_RESTRICTED_TOKEN).'); + } + + $response = $this->doRequest($restricted, self::$config['restricted_token_denied_path']); + + $this->assertSame(401, $response['status']); + $this->assertSame('Authorization failed', $response['data']['error'] ?? null); + $this->assertNotEmpty( + $response['data']['required_scope'] ?? '', + 'Bei gültigem Token und fehlendem Scope muss der benötigte Scope benannt werden.', + ); + } + + public function testOpenApiFormatReturnsSpecForGrantedRoutesOnly(): void + { + $compact = $this->doRequest(self::$token); + $spec = $this->doRequest(self::$token, 'me', ['format' => 'openapi']); + + $this->assertSame(200, $spec['status']); + $this->assertSame('3.0.0', $spec['data']['openapi'] ?? null); + $this->assertNotEmpty($spec['data']['paths'] ?? []); + + $prefix = self::$config['api_prefix']; + foreach (array_keys($spec['data']['paths']) as $path) { + // Regression: handle() darf die registrierten Routen nicht mutieren, + // sonst steht das Prefix doppelt in der Spec. + $this->assertStringStartsNotWith($prefix . '/', $path, 'Pfad enthält das API-Prefix doppelt: ' . $path); + } + + $specPaths = array_keys($spec['data']['paths']); + foreach ($compact['data']['endpoints'] as $endpoint) { + $expected = substr($endpoint['path'], strlen($prefix)); + $this->assertContains($expected, $specPaths); + } + } + + public function testUnknownFormatIsRejected(): void + { + $response = $this->doRequest(self::$token, 'me', ['format' => 'yaml']); + + $this->assertSame(400, $response['status']); + $this->assertArrayHasKey('error', $response['data'] ?? []); + } + + /** + * @param array $query + * @return array{status: int, data: ?array} + */ + private function doRequest(string $token, string $endpoint = 'me', array $query = []): array + { + $url = self::$baseUrl . '/' . ltrim($endpoint, '/'); + if ([] !== $query) { + $url .= '?' . http_build_query($query); + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => self::$config['timeout'], + CURLOPT_SSL_VERIFYPEER => self::$config['verify_ssl'], + CURLOPT_SSL_VERIFYHOST => self::$config['verify_ssl'] ? 2 : 0, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'Authorization: Bearer ' . $token, + ], + ]); + + $body = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + + return [ + 'status' => $status, + 'data' => is_string($body) ? json_decode($body, true) : null, + ]; + } +} From 1b13af6be17d25130bdaf5d1fadc2e0a019ef8c1 Mon Sep 17 00:00:00 2001 From: Jan Kristinus Date: Sun, 23 Aug 2026 12:26:19 +0200 Subject: [PATCH 2/7] OpenAPI-Ausgabe spec-konform machen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Spezifikation wird durch /api/me?format=openapi jetzt maschinenlesbar ausgeliefert und nicht mehr nur von Swagger UI gerendert. Dabei fielen zwei Punkte auf, die getypte Parser stolpern lassen: - "tags" war ein nach Tag-Namen indiziertes Objekt statt eines Arrays und damit kein gültiges OpenAPI-Dokument (array_values). - Fehlt für einen Tag der Sprachschlüssel, stand "[translate:...]" als Beschreibung in der Ausgabe. Tags können von fremden AddOns kommen, deren Schlüssel das api-AddOn nicht kennt — ohne Übersetzung bleibt die Beschreibung jetzt leer. Zusätzlich die Sprachschlüssel für die eigenen Tags "default" und "backend" ergänzt und ein Test, der beides für /api/me?format=openapi absichert. --- lang/de_de.lang | 2 ++ lib/OpenAPIConfig.php | 9 +++++++-- tests/MeApiTest.php | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lang/de_de.lang b/lang/de_de.lang index d678dc3..b2e2f95 100644 --- a/lang/de_de.lang +++ b/lang/de_de.lang @@ -28,6 +28,8 @@ api_token_added = API-Token wurde hinzugefügt api_openapi_title = OpenAPI Dokumentation api_openapi_description = Hier werden alle erfassten Endpunkte ausgegeben und können eingesehen und getestet werden. Um sie nutzen zu können, muss vorher ein Token mit den entsprechenden Zugriffen erstellt werden. +api_openapi_tag_default_description = Endpunkte mit Bearer-Token-Authentifizierung +api_openapi_tag_backend_description = Endpunkte mit Backend-Session-Authentifizierung (Cookie) api_openapi_tag_meta_description = Selbstauskunft: welche Endpunkte das aktuelle Token bzw. der Backend-User nutzen darf readme = ReadMe diff --git a/lib/OpenAPIConfig.php b/lib/OpenAPIConfig.php index 25c9790..562a213 100644 --- a/lib/OpenAPIConfig.php +++ b/lib/OpenAPIConfig.php @@ -17,9 +17,12 @@ public static function getByRoutes(array $Routes): array foreach ($Routes as $Scope => $RouteArray) { foreach ($RouteArray['tags'] ?? [] as $Tag) { if (!isset($tags[$Tag])) { + // tags may come from other addons: without a language key, emit an + // empty description instead of a "[translate:…]" placeholder + $Key = 'api_openapi_tag_' . $Tag . '_description'; $tags[$Tag] = [ 'name' => $Tag, - 'description' => rex_i18n::msg('api_openapi_tag_' . $Tag . '_description'), + 'description' => rex_i18n::hasMsg($Key) ? rex_i18n::msg($Key) : '', ]; } } @@ -44,7 +47,9 @@ public static function getByRoutes(array $Routes): array 'description' => rex_i18n::msg('api_openapi_description'), 'version' => '1.0.0', ], - 'tags' => $tags, + // array_values: OpenAPI requires "tags" to be an array, a keyed map + // makes the document invalid for typed parsers + 'tags' => array_values($tags), 'servers' => [ [ 'url' => '/' . RouteCollection::$preRoute, diff --git a/tests/MeApiTest.php b/tests/MeApiTest.php index b480034..504750d 100644 --- a/tests/MeApiTest.php +++ b/tests/MeApiTest.php @@ -170,6 +170,23 @@ public function testOpenApiFormatReturnsSpecForGrantedRoutesOnly(): void } } + public function testOpenApiTagsAreAList(): void + { + // OpenAPI verlangt für "tags" ein Array; ein Objekt (String-Keys) macht + // das Dokument für getypte Parser unlesbar. + $spec = $this->doRequest(self::$token, 'me', ['format' => 'openapi']); + + $this->assertSame(200, $spec['status']); + $this->assertIsList($spec['data']['tags'] ?? null); + $this->assertNotEmpty($spec['data']['tags']); + + foreach ($spec['data']['tags'] as $tag) { + $this->assertArrayHasKey('name', $tag); + $this->assertArrayHasKey('description', $tag); + $this->assertStringNotContainsString('[translate:', $tag['description'], 'Fehlender Sprachschlüssel für Tag ' . $tag['name']); + } + } + public function testUnknownFormatIsRejected(): void { $response = $this->doRequest(self::$token, 'me', ['format' => 'yaml']); From 4892d760c42d057fb9856a04938ad3d348d8b5a5 Mon Sep 17 00:00:00 2001 From: Jan Kristinus Date: Sun, 23 Aug 2026 12:48:13 +0200 Subject: [PATCH 3/7] Query-Parameter in der OpenAPI-Spec mit schema ausgeben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI verlangt Typ und Default eines Parameters innerhalb von "schema"; bisher stand "default" direkt am Parameter und "schema" fehlte bei allen einfachen Query-Parametern ganz. Die Typen der Route-Definitionen (int, bool, float) werden dabei auf die OpenAPI-Namen gemappt, ein Default von null wird weggelassen, weil er dem deklarierten Typ widerspricht. Bei den deepObject-Parametern (filter[...]) trugen die Properties "required" als Bool — dort gehört es als Liste auf Objektebene. Außerdem kamen "description" und "required" des Objekt-Parameters selbst aus der letzten Iteration der Feld-Schleife, also vom letzten Filterfeld. Test prüft für /api/me?format=openapi, dass jeder Parameter ein schema mit gültigem Typ hat und kein default auf Parameter-Ebene trägt. --- README.md | 2 +- lib/OpenAPIConfig.php | 69 ++++++++++++++++++++++++++++++++++++------- tests/MeApiTest.php | 31 +++++++++++++++++++ 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a518425..5abbbd3 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ curl -H "Authorization: Bearer DEIN_TOKEN" https://example.org/api/me Pro Endpunkt werden `path_parameters`, `query` und `body` mit Typ, `required`, Default und Beschreibung ausgegeben — leere Blöcke werden weggelassen. `required` folgt der Validierung: ein Feld ohne explizites `required` **ist** erforderlich. -`GET /api/me?format=openapi` liefert dieselbe Menge als vollständige OpenAPI-3.0-Spezifikation — gleicher Generator wie die Swagger-UI im Backend, nur auf die erlaubten Routen gefiltert. Das kompakte Format ist der Default, weil es bei vielen Routen deutlich weniger Kontext kostet. +`GET /api/me?format=openapi` liefert dieselbe Menge als vollständige OpenAPI-3.0-Spezifikation — gleicher Generator wie die Swagger-UI im Backend, nur auf die erlaubten Routen gefiltert. Das kompakte Format ist der Default, weil es bei vielen Routen deutlich weniger Kontext kostet. Response-Schemas pro Route enthält die Spec nicht: Listen liefern `{data, meta}` (siehe unten), Detail-Routen das Objekt flach. Für Backend-Session-Zugriffe gibt es `GET /api/backend/me`. Dort wird nicht vorab gefiltert: Backend-Permissions werden pro Request geprüft, ein gelisteter Endpunkt kann also weiterhin mit 403 antworten. Der Hinweis steht in `meta.note`. diff --git a/lib/OpenAPIConfig.php b/lib/OpenAPIConfig.php index 562a213..f5ca8a1 100644 --- a/lib/OpenAPIConfig.php +++ b/lib/OpenAPIConfig.php @@ -152,22 +152,28 @@ public static function getByRoutes(array $Routes): array foreach ($Route->getDefault('query') ?? [] as $Key => $Parameter) { if (isset($Parameter['fields']) && is_array($Parameter['fields'])) { $Properties = []; + $RequiredProperties = []; foreach ($Parameter['fields'] as $FieldKey => $Field) { - $Properties[$FieldKey] = [ - 'required' => $Field['required'] ?? false, - 'default' => $Field['default'] ?? null, - ]; + $Properties[$FieldKey] = self::getSchema($Field); + if ($Field['required'] ?? false) { + $RequiredProperties[] = $FieldKey; + } + } + + $Schema = [ + 'type' => 'object', + 'properties' => $Properties, + ]; + if (0 < count($RequiredProperties)) { + $Schema['required'] = $RequiredProperties; } $Parameters[] = [ 'name' => $Key, 'in' => 'query', - 'description' => $Field['description'] ?? '', - 'required' => $Field['required'] ?? false, - 'schema' => [ - 'type' => 'object', - 'properties' => $Properties, - ], + 'description' => $Parameter['description'] ?? '', + 'required' => $Parameter['required'] ?? false, + 'schema' => $Schema, 'style' => 'deepObject', 'explode' => true, ]; @@ -179,7 +185,7 @@ public static function getByRoutes(array $Routes): array 'in' => 'query', 'description' => $Parameter['description'] ?? '', 'required' => $Parameter['required'] ?? false, - 'default' => $Parameter['default'] ?? null, + 'schema' => self::getSchema($Parameter), ]; } } @@ -205,4 +211,45 @@ public static function getByRoutes(array $Routes): array return $config; } + + /** + * Builds the OpenAPI schema object for a single parameter definition. + * + * OpenAPI requires a parameter to carry its type and default inside "schema"; + * both at parameter level would make the document invalid. + * + * @param array $Definition + * @return array + */ + private static function getSchema(array $Definition): array + { + $Schema = [ + 'type' => self::getSchemaType($Definition['type'] ?? 'string'), + ]; + + // a null default is left out: it would contradict the declared type + if (isset($Definition['default'])) { + $Schema['default'] = $Definition['default']; + } + if (isset($Definition['description']) && '' !== $Definition['description']) { + $Schema['description'] = $Definition['description']; + } + + return $Schema; + } + + /** + * Maps the addon's parameter types to the type names OpenAPI knows. + */ + private static function getSchemaType(string $Type): string + { + return match ($Type) { + 'int', 'integer' => 'integer', + 'float', 'double' => 'number', + 'bool', 'boolean' => 'boolean', + 'array' => 'array', + 'object' => 'object', + default => 'string', + }; + } } diff --git a/tests/MeApiTest.php b/tests/MeApiTest.php index 504750d..0cc6006 100644 --- a/tests/MeApiTest.php +++ b/tests/MeApiTest.php @@ -187,6 +187,37 @@ public function testOpenApiTagsAreAList(): void } } + public function testOpenApiParametersCarryASchema(): void + { + // OpenAPI verlangt schema (oder content) pro Parameter; Typ und Default + // direkt am Parameter machen das Dokument ungültig. + $spec = $this->doRequest(self::$token, 'me', ['format' => 'openapi']); + $this->assertSame(200, $spec['status']); + + $allowedTypes = ['integer', 'number', 'boolean', 'string', 'array', 'object']; + $checked = 0; + + foreach ($spec['data']['paths'] as $path => $operations) { + foreach ($operations as $method => $operation) { + foreach ($operation['parameters'] ?? [] as $parameter) { + $where = strtoupper($method) . ' ' . $path . ' → ' . ($parameter['name'] ?? '?'); + + $this->assertArrayHasKey('schema', $parameter, $where); + $this->assertArrayNotHasKey('default', $parameter, $where); + $this->assertContains($parameter['schema']['type'], $allowedTypes, $where); + + foreach ($parameter['schema']['properties'] ?? [] as $name => $property) { + $this->assertContains($property['type'], $allowedTypes, $where . '[' . $name . ']'); + $this->assertArrayNotHasKey('required', $property, $where . '[' . $name . ']'); + } + ++$checked; + } + } + } + + $this->assertGreaterThan(0, $checked, 'Es wurde kein Parameter geprüft.'); + } + public function testUnknownFormatIsRejected(): void { $response = $this->doRequest(self::$token, 'me', ['format' => 'yaml']); From 07a6870c0e6be7a43fa7c86037008c0ebddf0b9b Mon Sep 17 00:00:00 2001 From: Jan Kristinus Date: Sun, 23 Aug 2026 13:06:38 +0200 Subject: [PATCH 4/7] =?UTF-8?q?BearerAuth=20robust=20f=C3=BCr=20Subklassen?= =?UTF-8?q?,=20doppelte=20Beschreibung=20entfernen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - $requireScope war eine promoted Constructor-Property und damit auf Klassenebene ohne Default. Erweitert ein anderes AddOn BearerAuth und überschreibt __construct() ohne parent::__construct(), lief requiresScope() in "Typed property must not be accessed before initialization". Property jetzt klassisch mit Default deklariert. - Die Parameter-Beschreibung stand doppelt in der Spec: am Parameter und im schema. Im schema bleibt sie nur für Objekt-Properties, die keine Parameter-Ebene haben. --- lib/Auth/BearerAuth.php | 13 ++++++++++--- lib/OpenAPIConfig.php | 10 +++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/Auth/BearerAuth.php b/lib/Auth/BearerAuth.php index 2925a88..ff530f2 100644 --- a/lib/Auth/BearerAuth.php +++ b/lib/Auth/BearerAuth.php @@ -11,13 +11,20 @@ class BearerAuth extends Auth { private ?Token $Token = null; + /** + * Declared with a class default instead of a promoted constructor property: + * a subclass in another addon may override __construct() without calling + * parent::__construct(), and requiresScope() must still work. + */ + private bool $requireScope = true; + /** * @param bool $requireScope false: every valid token is authorized, no scope needed (discovery routes) */ - public function __construct( - private bool $requireScope = true, - ) { + public function __construct(bool $requireScope = true) + { parent::__construct(); + $this->requireScope = $requireScope; } public function requiresScope(): bool diff --git a/lib/OpenAPIConfig.php b/lib/OpenAPIConfig.php index f5ca8a1..5a18251 100644 --- a/lib/OpenAPIConfig.php +++ b/lib/OpenAPIConfig.php @@ -154,7 +154,8 @@ public static function getByRoutes(array $Routes): array $Properties = []; $RequiredProperties = []; foreach ($Parameter['fields'] as $FieldKey => $Field) { - $Properties[$FieldKey] = self::getSchema($Field); + // description im Property: dort gibt es keine Parameter-Ebene + $Properties[$FieldKey] = self::getSchema($Field, true); if ($Field['required'] ?? false) { $RequiredProperties[] = $FieldKey; } @@ -186,6 +187,7 @@ public static function getByRoutes(array $Routes): array 'description' => $Parameter['description'] ?? '', 'required' => $Parameter['required'] ?? false, 'schema' => self::getSchema($Parameter), + ]; } } @@ -219,9 +221,11 @@ public static function getByRoutes(array $Routes): array * both at parameter level would make the document invalid. * * @param array $Definition + * @param bool $withDescription true for object properties, which have no + * parameter level of their own to carry it * @return array */ - private static function getSchema(array $Definition): array + private static function getSchema(array $Definition, bool $withDescription = false): array { $Schema = [ 'type' => self::getSchemaType($Definition['type'] ?? 'string'), @@ -231,7 +235,7 @@ private static function getSchema(array $Definition): array if (isset($Definition['default'])) { $Schema['default'] = $Definition['default']; } - if (isset($Definition['description']) && '' !== $Definition['description']) { + if ($withDescription && isset($Definition['description']) && '' !== $Definition['description']) { $Schema['description'] = $Definition['description']; } From fa404915a873d6e402fff11ffa3b7fab4228b1ae Mon Sep 17 00:00:00 2001 From: Jan Kristinus Date: Sun, 23 Aug 2026 19:47:30 +0200 Subject: [PATCH 5/7] =?UTF-8?q?Swagger=20UI:=20lange=20Endpoint-Beschreibu?= =?UTF-8?q?ngen=20k=C3=BCrzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beschreibungen können sehr lang werden — die Routen des ai_platform-AddOns liegen bei bis zu 2095 Zeichen und schieben die Zeile der Endpunktliste mehrzeilig auseinander. In der Zeile stehen jetzt 50 Zeichen, der volle Text sitzt im title-Attribut und erscheint beim Hover. Nur Darstellung: die ausgelieferte Spezifikation bleibt unverändert, damit Clients die vollständige Beschreibung weiter bekommen. Ein MutationObserver fängt das Neu-Rendern beim Auf- und Zuklappen ab; das title-Attribut dient dabei als Quelle des Originaltexts, nicht ein Merker-Flag. --- pages/openapi.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/pages/openapi.php b/pages/openapi.php index 27922ee..1a2b875 100644 --- a/pages/openapi.php +++ b/pages/openapi.php @@ -23,7 +23,61 @@ url: "index.php?page=api/openapi&load_config=1", // URL to your OpenAPI specification file dom_id: "#swagger-ui", }); + + shortenSummaries(); }; + + // Endpoint-Beschreibungen können sehr lang werden (eigene und die von + // anderen AddOns). In der Zeile bleibt eine kurze Fassung stehen, der + // vollständige Text steckt im title-Attribut und erscheint beim Hover. + // Die Spec selbst bleibt unverändert. + const SUMMARY_MAX_LENGTH = 50; + + function shortenSummaries() { + const target = document.getElementById('swagger-ui'); + if (!target) { + return; + } + + let running = false; + const run = () => { + document.querySelectorAll('#swagger-ui .opblock-summary-description').forEach((element) => { + // Beim Neu-Rendern durch Swagger UI kann der lange Text zurückkommen, + // deshalb ist das title-Attribut die Quelle, nicht ein Merker-Flag. + const full = (element.getAttribute('title') || element.textContent).trim(); + if (full.length <= SUMMARY_MAX_LENGTH) { + return; + } + + const short = full.slice(0, SUMMARY_MAX_LENGTH).trimEnd() + '\u2026'; + if (element.textContent === short) { + return; + } + + element.setAttribute('title', full); + element.classList.add('rex-api-summary-shortened'); + element.textContent = short; + }); + }; + + // Swagger UI rendert asynchron und baut Zeilen beim Filtern/Aufklappen neu. + // Direkt statt über requestAnimationFrame, weil das in einem Tab im + // Hintergrund nicht ausgeführt wird. Die eigenen Änderungen lösen einen + // weiteren Durchlauf aus, der nichts mehr zu tun findet — keine Schleife. + new MutationObserver(() => { + if (running) { + return; + } + running = true; + try { + run(); + } finally { + running = false; + } + }).observe(target, {childList: true, subtree: true}); + + run(); + } Date: Sun, 23 Aug 2026 20:14:47 +0200 Subject: [PATCH 6/7] =?UTF-8?q?Doku:=20Swagger-UI-K=C3=BCrzung,=20Generato?= =?UTF-8?q?r-Konventionen,=20Hinweise=20f=C3=BCr=20AddOn-Entwickler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: die Kürzung der Beschreibungen in der Endpunktliste (Anzeige, nicht Spec), und was AddOn-Entwickler von der Selbstauskunft haben — eigene Routen erscheinen automatisch, ausgegeben wird aber nur, was die Route deklariert; Datei-Uploads brauchen 'type' => 'file', eigene Tags einen Sprachschlüssel. CLAUDE.md: Konventionen des OpenAPI-Generators (Typmapping über getSchema, tags als Array, Tag-Beschreibung ohne Sprachschlüssel leer) inklusive der bekannten Lücke im Body-Zweig, plus der Hintergrund zur Swagger-UI-Kürzung. --- CLAUDE.md | 4 ++++ README.md | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 254c6da..c616dfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,10 @@ Tests sind **Integrationstests**, die echte HTTP-Requests via cURL an eine laufe **Scope-freie Routen:** `Auth::requiresScope()` (Default `true`) entscheidet, ob der Route-Scope explizit vergeben sein muss. `new BearerAuth(false)` autorisiert jedes gültige Token ohne Scope-Prüfung — genutzt von `/api/me`, damit die Selbstauskunft nicht genau bei den Tokens fehlt, bei denen der Scope vergessen wurde. Solche Routen werden von `Token::getAvailableScopes()` ausgefiltert und erscheinen deshalb nicht als Checkbox auf der Token-Seite. +**OpenAPI-Generator (`OpenAPIConfig`):** Parameter-Typen der Route-Definitionen (`int`, `bool`, `float`) werden über `getSchema()`/`getSchemaType()` auf OpenAPI-Typen gemappt; Typ und Default gehören ins `schema`, nicht an den Parameter. Tags ohne Sprachschlüssel (`api_openapi_tag__description`) bekommen eine leere Beschreibung statt eines `[translate:…]`-Platzhalters — Tags können aus fremden AddOns kommen. `tags` muss ein Array bleiben (`array_values`), sonst ist das Dokument für getypte Parser ungültig. **Bekannte Lücke:** Der `Body`-Zweig läuft noch nicht über `getSchema()` — dort landen `type: int` und ein `required`-Bool je Property in der Spec. + +**Swagger-UI-Anzeige:** `pages/openapi.php` kürzt die Beschreibungen in der Endpunktliste per JS auf 50 Zeichen und legt den Originaltext ins `title`-Attribut (Hover). Nur Darstellung — die Spec bleibt vollständig. Der `MutationObserver` dort arbeitet ohne `requestAnimationFrame`, weil das in einem Hintergrund-Tab nicht ausgeführt wird. + **Route-Objekte nicht mutieren:** `RouteCollection::handle()` klont jede Route, bevor es `/api` an den Pfad hängt. Die registrierten `Route`-Objekte behalten ihren unpräfixierten Pfad — Handler, die `RouteCollection::getRoutes()` auslesen (z.B. `Discovery`), würden sonst `/api` doppelt sehen. ### Route Packages (lib/RoutePackage/) diff --git a/README.md b/README.md index 5abbbd3..16b2872 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ Bei einem gültigen Token ohne den benötigten Scope nennt die 401-Antwort den S Am besten direkt im AddOn unter OpenAPI nachsehen. Dort werden alle verfügbaren Endpunkte aufgelistet. Programmatisch übernimmt das `/api/me` (siehe oben). +In der Endpunktliste der Swagger-UI wird die Beschreibung auf 50 Zeichen gekürzt, damit jeder Endpunkt eine Zeile bleibt — der vollständige Text erscheint beim Hover über der Beschreibung. Gekürzt wird nur die Anzeige: die ausgelieferte Spezifikation enthält die Beschreibung unverändert. + ### Response-Format für Listen-Endpunkte Alle Listen-Endpunkte liefern ein einheitliches Response-Format mit Daten und Meta-Informationen: @@ -211,6 +213,12 @@ Jeder Endpunkt hat eine eigene Whitelist erlaubter Sortierfelder (siehe OpenAPI- ## Was funktioniert vielleicht nicht, und müssen AddOn Entwickler beachten +Eigene Endpunkte anderer AddOns erscheinen automatisch in `/api/me` und in der OpenAPI-Spezifikation — es ist nichts zusätzlich zu registrieren. Ausgegeben wird dabei genau das, was die Route deklariert: gepflegte `query`- und `Body`-Definitionen samt `description` machen den Endpunkt für einen aufrufenden Client oder Agenten benutzbar, fehlende Definitionen lassen ihn ohne Parameter erscheinen. Datei-Uploads sollten `'type' => 'file'` verwenden, dann wird in der Spezifikation `multipart/form-data` mit `format: binary` erzeugt. + +Wer eigene Tags vergibt, sollte auch den Sprachschlüssel `api_openapi_tag__description` mitliefern — sonst bleibt die Tag-Beschreibung in Swagger UI und in der Spezifikation leer. + +`new BearerAuth(false)` autorisiert jedes gültige Token ohne Scope-Prüfung. Das ist für Selbstauskunft-artige Endpunkte gedacht; alles, was Daten liest oder schreibt, gehört hinter `new BearerAuth()` mit eigenem Scope. + Das API AddON funktioniert aus dem Frontend-User-Kontext heraus. Das heisst, sollte es registrierte Methoden an bestimmten ExtensionPoints geben, welche nur im Backend-User-Kontext gesetzt wurden, z.B. (rex::isBackend) -> registerEP, dann werden diese nicht in der dieser API ausgeführt. D.h. diese AddOns müssen entsprechend angepasst werden. From 8ae52e7df0816dc5ea64ae2a075cfff19cc4ef77 Mon Sep 17 00:00:00 2001 From: Jan Kristinus Date: Sun, 23 Aug 2026 22:47:16 +0200 Subject: [PATCH 7/7] =?UTF-8?q?Body-Felder=20in=20der=20OpenAPI-Spec=20?= =?UTF-8?q?=C3=BCber=20getSchema=20ausgeben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Body-Zweig übernahm den Typ wörtlich aus der Route-Definition, dort stehen aber die Typnamen von rex_type::cast(): "int" ist kein OpenAPI-Typ, Client-Generatoren verlieren damit die Typinformation. Betroffen waren 13 Properties in Structure und Metainfo. Zwei weitere Verstöße an derselben Stelle behoben: - "required" stand als Bool in der Property; die Liste auf Objektebene wird ohnehin korrekt gefüllt, der Bool war eine Doppelung in falscher Form (406 Properties). - Hat eine Route kein Pflichtfeld, entstand "required": [] — JSON Schema verlangt mindestens ein Element, der Schlüssel bleibt jetzt weg (21 Operationen). Der file-Sonderfall bleibt: 'type' => 'file' ergänzt format: binary und schaltet den Content-Type auf multipart/form-data. Als Nebeneffekt stehen Defaults jetzt auch im Body-Schema — Swagger UI zeigt für Slices "module_id": 0 und "ctype_id": 1 statt "string". --- CLAUDE.md | 2 +- README.md | 2 +- lib/OpenAPIConfig.php | 33 +++++++++++++++++---------------- tests/MeApiTest.php | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c616dfb..7fb1687 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Tests sind **Integrationstests**, die echte HTTP-Requests via cURL an eine laufe **Scope-freie Routen:** `Auth::requiresScope()` (Default `true`) entscheidet, ob der Route-Scope explizit vergeben sein muss. `new BearerAuth(false)` autorisiert jedes gültige Token ohne Scope-Prüfung — genutzt von `/api/me`, damit die Selbstauskunft nicht genau bei den Tokens fehlt, bei denen der Scope vergessen wurde. Solche Routen werden von `Token::getAvailableScopes()` ausgefiltert und erscheinen deshalb nicht als Checkbox auf der Token-Seite. -**OpenAPI-Generator (`OpenAPIConfig`):** Parameter-Typen der Route-Definitionen (`int`, `bool`, `float`) werden über `getSchema()`/`getSchemaType()` auf OpenAPI-Typen gemappt; Typ und Default gehören ins `schema`, nicht an den Parameter. Tags ohne Sprachschlüssel (`api_openapi_tag__description`) bekommen eine leere Beschreibung statt eines `[translate:…]`-Platzhalters — Tags können aus fremden AddOns kommen. `tags` muss ein Array bleiben (`array_values`), sonst ist das Dokument für getypte Parser ungültig. **Bekannte Lücke:** Der `Body`-Zweig läuft noch nicht über `getSchema()` — dort landen `type: int` und ein `required`-Bool je Property in der Spec. +**OpenAPI-Generator (`OpenAPIConfig`):** Alle Feld-Definitionen — `query` wie `Body` — laufen über `getSchema()`/`getSchemaType()`: die Typnamen der Route-Definitionen (`int`, `bool`, `float`) werden auf OpenAPI-Typen gemappt, Typ und Default gehören ins `schema`, nicht an den Parameter. `required` steht als Liste auf Objektebene, nie als Bool in der Property, und ein leeres `required` wird weggelassen. `'type' => 'file'` ergänzt `format: binary` und schaltet den Content-Type auf `multipart/form-data`. Tags ohne Sprachschlüssel (`api_openapi_tag__description`) bekommen eine leere Beschreibung statt eines `[translate:…]`-Platzhalters — Tags können aus fremden AddOns kommen. `tags` muss ein Array bleiben (`array_values`), sonst ist das Dokument für getypte Parser ungültig. **Was weiter fehlt:** Response-Schemas pro Route (Listen liefern `{data, meta}`, Detail-Routen das Objekt flach). **Swagger-UI-Anzeige:** `pages/openapi.php` kürzt die Beschreibungen in der Endpunktliste per JS auf 50 Zeichen und legt den Originaltext ins `title`-Attribut (Hover). Nur Darstellung — die Spec bleibt vollständig. Der `MutationObserver` dort arbeitet ohne `requestAnimationFrame`, weil das in einem Hintergrund-Tab nicht ausgeführt wird. diff --git a/README.md b/README.md index 16b2872..221d046 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ curl -H "Authorization: Bearer DEIN_TOKEN" https://example.org/api/me Pro Endpunkt werden `path_parameters`, `query` und `body` mit Typ, `required`, Default und Beschreibung ausgegeben — leere Blöcke werden weggelassen. `required` folgt der Validierung: ein Feld ohne explizites `required` **ist** erforderlich. -`GET /api/me?format=openapi` liefert dieselbe Menge als vollständige OpenAPI-3.0-Spezifikation — gleicher Generator wie die Swagger-UI im Backend, nur auf die erlaubten Routen gefiltert. Das kompakte Format ist der Default, weil es bei vielen Routen deutlich weniger Kontext kostet. Response-Schemas pro Route enthält die Spec nicht: Listen liefern `{data, meta}` (siehe unten), Detail-Routen das Objekt flach. +`GET /api/me?format=openapi` liefert dieselbe Menge als vollständige OpenAPI-3.0-Spezifikation — gleicher Generator wie die Swagger-UI im Backend, nur auf die erlaubten Routen gefiltert. Das kompakte Format ist der Default, weil es bei vielen Routen deutlich weniger Kontext kostet. Parameter und Body-Felder tragen dort ihren Typ und Default im `schema`, sind also für Client-Generatoren verwendbar. Was die Spec nicht enthält, sind Response-Schemas pro Route: Listen liefern `{data, meta}` (siehe unten), Detail-Routen das Objekt flach. Für Backend-Session-Zugriffe gibt es `GET /api/backend/me`. Dort wird nicht vorab gefiltert: Backend-Permissions werden pro Request geprüft, ein gelisteter Endpunkt kann also weiterhin mit 403 antworten. Der Hinweis steht in `meta.note`. diff --git a/lib/OpenAPIConfig.php b/lib/OpenAPIConfig.php index 5a18251..cabbafe 100644 --- a/lib/OpenAPIConfig.php +++ b/lib/OpenAPIConfig.php @@ -129,20 +129,16 @@ public static function getByRoutes(array $Routes): array // in Body $hasFileField = false; foreach ($Route->getDefault('Body') ?? [] as $Key => $Parameter) { + $Schema = self::getSchema($Parameter, true); + if ('file' === ($Parameter['type'] ?? '')) { $hasFileField = true; - $RequestBodyProperties[$Key] = [ - 'type' => 'string', - 'format' => 'binary', - 'description' => $Parameter['description'] ?? '', - ]; - } else { - $RequestBodyProperties[$Key] = [ - 'type' => $Parameter['type'], - 'description' => $Parameter['description'] ?? '', - 'required' => $Parameter['required'] ?? false, - ]; + $Schema['format'] = 'binary'; } + + $RequestBodyProperties[$Key] = $Schema; + + // required gehört als Liste auf Objektebene, nicht in die Property if ($Parameter['required'] ?? false) { $RequestBodyRequired[] = $Key; } @@ -194,15 +190,20 @@ public static function getByRoutes(array $Routes): array if (0 < count($RequestBodyProperties)) { $contentType = $hasFileField ? 'multipart/form-data' : 'application/json'; + $BodySchema = [ + 'type' => 'object', + 'properties' => $RequestBodyProperties, + ]; + // ein leeres required-Array ist kein gültiges JSON Schema + if (0 < count($RequestBodyRequired)) { + $BodySchema['required'] = $RequestBodyRequired; + } + $config['paths'][$Route->getPath()][strtolower($Route->getMethods()[0])]['requestBody'] = [ 'required' => true, 'content' => [ $contentType => [ - 'schema' => [ - 'type' => 'object', - 'properties' => $RequestBodyProperties, - 'required' => $RequestBodyRequired, - ], + 'schema' => $BodySchema, ], ], ]; diff --git a/tests/MeApiTest.php b/tests/MeApiTest.php index 0cc6006..19d5dac 100644 --- a/tests/MeApiTest.php +++ b/tests/MeApiTest.php @@ -218,6 +218,45 @@ public function testOpenApiParametersCarryASchema(): void $this->assertGreaterThan(0, $checked, 'Es wurde kein Parameter geprüft.'); } + public function testOpenApiRequestBodiesAreValid(): void + { + // Body-Properties sind JSON-Schema-Objekte: gültiger Typ, kein required-Bool + // in der Property (das gehört als Liste auf Objektebene), kein leeres required. + $spec = $this->doRequest(self::$token, 'me', ['format' => 'openapi']); + $this->assertSame(200, $spec['status']); + + $allowedTypes = ['integer', 'number', 'boolean', 'string', 'array', 'object']; + $checked = 0; + + foreach ($spec['data']['paths'] as $path => $operations) { + foreach ($operations as $method => $operation) { + foreach ($operation['requestBody']['content'] ?? [] as $contentType => $content) { + $schema = $content['schema']; + $where = strtoupper($method) . ' ' . $path; + + $this->assertSame('object', $schema['type'], $where); + $this->assertNotEmpty($schema['properties'], $where); + + if (array_key_exists('required', $schema)) { + $this->assertNotEmpty($schema['required'], $where . ' → leeres required'); + } + + foreach ($schema['properties'] as $name => $property) { + $this->assertContains($property['type'], $allowedTypes, $where . ' → ' . $name); + $this->assertArrayNotHasKey('required', $property, $where . ' → ' . $name); + + if ('multipart/form-data' === $contentType && isset($property['format'])) { + $this->assertSame('binary', $property['format'], $where . ' → ' . $name); + } + ++$checked; + } + } + } + } + + $this->assertGreaterThan(0, $checked, 'Es wurde kein Body-Feld geprüft.'); + } + public function testUnknownFormatIsRejected(): void { $response = $this->doRequest(self::$token, 'me', ['format' => 'yaml']);