From 449d4069b68932a7fd50f191016b588b8667476e Mon Sep 17 00:00:00 2001 From: correct-horse-battery-bench Date: Fri, 14 Aug 2026 09:01:19 +0200 Subject: [PATCH 1/3] State the API info in the builder instead of patching it in the sanitizer Title and version were built by SpecBuilder, while description and contact were filled in afterwards by SpecSanitizer. The two halves of the same info object lived in different classes, and the raw spec (which the builder tests and the fixtures compare against) was missing them. Build the whole info object in SpecBuilder, so both spec variants identify the API the same way, and state the license of the server beside them, which neither half carried before. The sanitizer no longer touches info at all. Move the coverage accordingly: the SpecSanitizerTest case is replaced by one FullSpecTest case asserting the info of both variants. The fixtures now carry the full info object, and the published spec gains the license. --- .../fixtures/openapi/abortchunk.spec.json | 11 ++++++++++- ci/phpunit/fixtures/openapi/config.spec.json | 11 ++++++++++- .../openapi/crackerbinarytype.spec.json | 11 ++++++++++- ci/phpunit/fixtures/openapi/hashtype.spec.json | 11 ++++++++++- ci/phpunit/inc/apiv2/openapi/FullSpecTest.php | 18 ++++++++++++++++++ .../inc/apiv2/openapi/SpecSanitizerTest.php | 6 ------ openapi.json | 4 ++++ src/inc/apiv2/openapi/SpecBuilder.php | 12 +++++++++++- src/inc/apiv2/openapi/SpecSanitizer.php | 11 ----------- 9 files changed, 73 insertions(+), 22 deletions(-) diff --git a/ci/phpunit/fixtures/openapi/abortchunk.spec.json b/ci/phpunit/fixtures/openapi/abortchunk.spec.json index 96195e90b..f33e190be 100644 --- a/ci/phpunit/fixtures/openapi/abortchunk.spec.json +++ b/ci/phpunit/fixtures/openapi/abortchunk.spec.json @@ -2,7 +2,16 @@ "openapi": "3.1.0", "info": { "title": "Hashtopolis API", - "version": "v2" + "version": "v2", + "description": "Hashtopolis REST API", + "contact": { + "name": "Hashtopolis", + "url": "https://github.com/hashtopolis/server" + }, + "license": { + "name": "GPL-3.0", + "url": "https://github.com/hashtopolis/server/blob/master/LICENSE.txt" + } }, "servers": [ { diff --git a/ci/phpunit/fixtures/openapi/config.spec.json b/ci/phpunit/fixtures/openapi/config.spec.json index 3f772f101..f60e5670b 100644 --- a/ci/phpunit/fixtures/openapi/config.spec.json +++ b/ci/phpunit/fixtures/openapi/config.spec.json @@ -2,7 +2,16 @@ "openapi": "3.1.0", "info": { "title": "Hashtopolis API", - "version": "v2" + "version": "v2", + "description": "Hashtopolis REST API", + "contact": { + "name": "Hashtopolis", + "url": "https://github.com/hashtopolis/server" + }, + "license": { + "name": "GPL-3.0", + "url": "https://github.com/hashtopolis/server/blob/master/LICENSE.txt" + } }, "servers": [ { diff --git a/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json b/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json index 62fca0036..fc338d7dd 100644 --- a/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json +++ b/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json @@ -2,7 +2,16 @@ "openapi": "3.1.0", "info": { "title": "Hashtopolis API", - "version": "v2" + "version": "v2", + "description": "Hashtopolis REST API", + "contact": { + "name": "Hashtopolis", + "url": "https://github.com/hashtopolis/server" + }, + "license": { + "name": "GPL-3.0", + "url": "https://github.com/hashtopolis/server/blob/master/LICENSE.txt" + } }, "servers": [ { diff --git a/ci/phpunit/fixtures/openapi/hashtype.spec.json b/ci/phpunit/fixtures/openapi/hashtype.spec.json index 83f876220..5f7481ca4 100644 --- a/ci/phpunit/fixtures/openapi/hashtype.spec.json +++ b/ci/phpunit/fixtures/openapi/hashtype.spec.json @@ -2,7 +2,16 @@ "openapi": "3.1.0", "info": { "title": "Hashtopolis API", - "version": "v2" + "version": "v2", + "description": "Hashtopolis REST API", + "contact": { + "name": "Hashtopolis", + "url": "https://github.com/hashtopolis/server" + }, + "license": { + "name": "GPL-3.0", + "url": "https://github.com/hashtopolis/server/blob/master/LICENSE.txt" + } }, "servers": [ { diff --git a/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php b/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php index e9a089d8b..edd728514 100644 --- a/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php +++ b/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php @@ -31,6 +31,24 @@ public function testSpecsDeclareOpenApi310AndEncodeAsJson(): void { $this->assertJson(json_encode(self::$raw, JSON_THROW_ON_ERROR)); } + /** + * The document identifies the API and the terms it is served under, which + * the builder states once for both variants. + */ + public function testInfoIdentifiesTheApiAndItsLicense(): void { + foreach (['raw' => self::$raw, 'sanitized' => self::$sanitized] as $variant => $spec) { + $this->assertSame('Hashtopolis API', $spec['info']['title'], $variant); + $this->assertSame('Hashtopolis REST API', $spec['info']['description'], $variant); + $this->assertSame('https://github.com/hashtopolis/server', $spec['info']['contact']['url'], $variant); + $this->assertSame('GPL-3.0', $spec['info']['license']['name'], $variant); + $this->assertSame( + 'https://github.com/hashtopolis/server/blob/master/LICENSE.txt', + $spec['info']['license']['url'], + $variant + ); + } + } + public function testExpectedCoverage(): void { $this->assertGreaterThanOrEqual(160, count(self::$sanitized['paths'])); $this->assertGreaterThanOrEqual(200, count(self::$sanitized['components']['schemas'])); diff --git a/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php b/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php index 0d61c03ae..15498970b 100644 --- a/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php +++ b/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php @@ -22,12 +22,6 @@ private function minimalSpec(array $paths = [], array $schemas = []): array { ]; } - public function testAddsMissingInfoFields(): void { - $result = $this->sanitize($this->minimalSpec()); - $this->assertSame('Hashtopolis REST API', $result['info']['description']); - $this->assertSame('https://github.com/hashtopolis/server', $result['info']['contact']['url']); - } - public function testRenamesBackslashSchemaNamesAndRewritesRefs(): void { $fqcn = 'Hashtopolis\\inc\\apiv2\\helper\\ThingHelperAPI'; $result = $this->sanitize($this->minimalSpec( diff --git a/openapi.json b/openapi.json index b848b910b..b3b67b947 100644 --- a/openapi.json +++ b/openapi.json @@ -7,6 +7,10 @@ "contact": { "name": "Hashtopolis", "url": "https://github.com/hashtopolis/server" + }, + "license": { + "name": "GPL-3.0", + "url": "https://github.com/hashtopolis/server/blob/master/LICENSE.txt" } }, "servers": [ diff --git a/src/inc/apiv2/openapi/SpecBuilder.php b/src/inc/apiv2/openapi/SpecBuilder.php index 5133e2434..b5a2bf7a0 100644 --- a/src/inc/apiv2/openapi/SpecBuilder.php +++ b/src/inc/apiv2/openapi/SpecBuilder.php @@ -77,7 +77,17 @@ public function buildFromApp(App $app): array { "openapi" => "3.1.0", "info" => [ "title" => "Hashtopolis API", - "version" => "v2" + "version" => "v2", + "description" => "Hashtopolis REST API", + "contact" => [ + "name" => "Hashtopolis", + "url" => "https://github.com/hashtopolis/server" + ], + /* The license of the server itself, as stated by LICENSE.txt in its repository */ + "license" => [ + "name" => "GPL-3.0", + "url" => "https://github.com/hashtopolis/server/blob/master/LICENSE.txt" + ] ], "servers" => [ [ diff --git a/src/inc/apiv2/openapi/SpecSanitizer.php b/src/inc/apiv2/openapi/SpecSanitizer.php index 0cc9d1409..31986e14e 100644 --- a/src/inc/apiv2/openapi/SpecSanitizer.php +++ b/src/inc/apiv2/openapi/SpecSanitizer.php @@ -7,17 +7,6 @@ */ class SpecSanitizer { public function sanitize(array $spec): array { - // Fix: Add missing info fields - if (!isset($spec['info']['description'])) { - $spec['info']['description'] = 'Hashtopolis REST API'; - } - if (!isset($spec['info']['contact'])) { - $spec['info']['contact'] = [ - 'name' => 'Hashtopolis', - 'url' => 'https://github.com/hashtopolis/server' - ]; - } - // Phase 1: Build rename map for schema names containing backslashes $renameMap = []; if (isset($spec['components']['schemas'])) { From 62e5c63cc6751ef36e5bf300800fa441943bb9bd Mon Sep 17 00:00:00 2001 From: correct-horse-battery-bench Date: Fri, 14 Aug 2026 09:03:02 +0200 Subject: [PATCH 2/3] Derive the OpenAPI path template where the route is resolved The generator keyed its paths by the raw Slim pattern, constraints and all ("/api/v2/ui/configs/{id:[0-9]+}/{relation:configSection}"), and SpecSanitizer rewrote them into path templates as one of its phases. Everything in between therefore worked on strings that are not valid OpenAPI paths: StaticFragments had to spell out a regex constraint to hit the right path item, and the raw spec the builder tests and fixtures assert against was never a valid document. Translate the pattern into a path template in RouteIntrospector, where the route is resolved, and carry both on RouteTarget. The path template is what the spec is keyed by; the raw pattern stays available because a constraint encodes more than a validation rule, namely the relation name of a relationship route, which ModelApiPathBuilder still reads from it. Consequently the sanitizer loses its path cleaning phase (the remaining phases are renumbered) and StaticFragments addresses the importFile paths by template. The relationship routes of one model collapse into a single templated path item now, which is what the sanitized spec always contained, so openapi.json is unchanged; the fixtures show the collapse because they snapshot the raw spec. RouteIntrospectorTest covers the translation, including the balanced braces of a quantifier inside a constraint, which the two removed sanitizer cases used to cover. --- .../fixtures/openapi/abortchunk.spec.json | 2 +- ci/phpunit/fixtures/openapi/config.spec.json | 10 +- .../openapi/crackerbinarytype.spec.json | 403 +----------------- .../fixtures/openapi/hashtype.spec.json | 4 +- ci/phpunit/inc/apiv2/openapi/FullSpecTest.php | 12 + .../apiv2/openapi/RouteIntrospectorTest.php | 102 +++++ .../apiv2/openapi/SpecBuilderModelApiTest.php | 8 +- .../inc/apiv2/openapi/SpecSanitizerTest.php | 30 +- .../apiv2/openapi/HelperApiPathBuilder.php | 2 +- src/inc/apiv2/openapi/ModelApiPathBuilder.php | 15 +- src/inc/apiv2/openapi/RouteIntrospector.php | 54 ++- src/inc/apiv2/openapi/RouteTarget.php | 7 + src/inc/apiv2/openapi/SpecSanitizer.php | 61 +-- src/inc/apiv2/openapi/StaticFragments.php | 14 +- 14 files changed, 222 insertions(+), 502 deletions(-) create mode 100644 ci/phpunit/inc/apiv2/openapi/RouteIntrospectorTest.php diff --git a/ci/phpunit/fixtures/openapi/abortchunk.spec.json b/ci/phpunit/fixtures/openapi/abortchunk.spec.json index f33e190be..4293eee9e 100644 --- a/ci/phpunit/fixtures/openapi/abortchunk.spec.json +++ b/ci/phpunit/fixtures/openapi/abortchunk.spec.json @@ -197,7 +197,7 @@ } } }, - "/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}": { + "/api/v2/helper/importFile/{id}": { "patch": { "parameters": [ { diff --git a/ci/phpunit/fixtures/openapi/config.spec.json b/ci/phpunit/fixtures/openapi/config.spec.json index f60e5670b..b9dc77a58 100644 --- a/ci/phpunit/fixtures/openapi/config.spec.json +++ b/ci/phpunit/fixtures/openapi/config.spec.json @@ -329,7 +329,7 @@ ] } }, - "/api/v2/ui/configs/{id:[0-9]+}/{relation:configSection}": { + "/api/v2/ui/configs/{id}/{relation}": { "get": { "tags": [ "Configs" @@ -410,7 +410,7 @@ ] } }, - "/api/v2/ui/configs/{id:[0-9]+}/relationships/{relation:configSection}": { + "/api/v2/ui/configs/{id}/relationships/{relation}": { "get": { "tags": [ "Configs" @@ -572,7 +572,7 @@ "parameters": [] } }, - "/api/v2/ui/configs/{id:[0-9]+}": { + "/api/v2/ui/configs/{id}": { "get": { "tags": [ "Configs" @@ -966,7 +966,7 @@ ] } }, - "/api/v2/ui/configsections/{id:[0-9]+}": { + "/api/v2/ui/configsections/{id}": { "get": { "tags": [ "ConfigSections" @@ -1170,7 +1170,7 @@ } } }, - "/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}": { + "/api/v2/helper/importFile/{id}": { "patch": { "parameters": [ { diff --git a/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json b/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json index fc338d7dd..4ae24e389 100644 --- a/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json +++ b/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json @@ -463,402 +463,7 @@ ] } }, - "/api/v2/ui/crackertypes/{id:[0-9]+}/{relation:crackerVersions}": { - "get": { - "tags": [ - "CrackerBinaryTypes" - ], - "responses": { - "400": { - "description": "Invalid request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "Authentication failed", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "Permission denied", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "200": { - "description": "successful operation", - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/CrackerBinaryTypeRelationCrackerVersionsGetResponse" - } - } - } - } - }, - "security": [ - { - "bearerAuth": [ - [ - "permCrackerBinaryTypeRead" - ] - ] - } - ], - "description": "GET request to retrieve a single object.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32", - "example": 10 - } - } - ] - } - }, - "/api/v2/ui/crackertypes/{id:[0-9]+}/relationships/{relation:crackerVersions}": { - "get": { - "tags": [ - "CrackerBinaryTypes" - ], - "responses": { - "400": { - "description": "Invalid request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "Authentication failed", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "Permission denied", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "200": { - "description": "successful operation", - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/CrackerBinaryTypeResponse" - } - } - } - } - }, - "security": [ - { - "bearerAuth": [ - [ - "permCrackerBinaryTypeRead" - ] - ] - } - ], - "description": "GET request for for a to-one relationship link. Returns the resource record of the object that is part of the specified relation.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32", - "example": 10 - } - } - ] - }, - "patch": { - "tags": [ - "CrackerBinaryTypes" - ], - "responses": { - "400": { - "description": "Invalid request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "Authentication failed", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "Permission denied", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "204": { - "description": "Successfull operation" - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Resource already exists", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "bearerAuth": [ - [ - "permCrackerBinaryTypeUpdate" - ] - ] - } - ], - "description": "PATCH request to update a to one relationship.", - "requestBody": { - "required": true, - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/CrackerBinaryTypeRelationCrackerVersions" - } - } - } - }, - "parameters": [] - }, - "post": { - "tags": [ - "CrackerBinaryTypes" - ], - "responses": { - "400": { - "description": "Invalid request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "Authentication failed", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "Permission denied", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "204": { - "description": "successfully created" - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Resource already exists", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "bearerAuth": [ - [ - "permCrackerBinaryTypeCreate" - ] - ] - } - ], - "description": "POST request to create a to-one relationship link.", - "requestBody": { - "required": true, - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/CrackerBinaryTypeRelationCrackerVersions" - } - } - } - }, - "parameters": [] - }, - "delete": { - "tags": [ - "CrackerBinaryTypes" - ], - "responses": { - "400": { - "description": "Invalid request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "Authentication failed", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "Permission denied", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "204": { - "description": "successfully deleted" - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "bearerAuth": [ - [ - "permCrackerBinaryTypeDelete" - ] - ] - } - ], - "description": "", - "requestBody": { - "required": true, - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/CrackerBinaryTypeRelationCrackerVersions" - } - } - } - }, - "parameters": [] - } - }, - "/api/v2/ui/crackertypes/{id:[0-9]+}/{relation:tasks}": { + "/api/v2/ui/crackertypes/{id}/{relation}": { "get": { "tags": [ "CrackerBinaryTypes" @@ -939,7 +544,7 @@ ] } }, - "/api/v2/ui/crackertypes/{id:[0-9]+}/relationships/{relation:tasks}": { + "/api/v2/ui/crackertypes/{id}/relationships/{relation}": { "get": { "tags": [ "CrackerBinaryTypes" @@ -1253,7 +858,7 @@ "parameters": [] } }, - "/api/v2/ui/crackertypes/{id:[0-9]+}": { + "/api/v2/ui/crackertypes/{id}": { "get": { "tags": [ "CrackerBinaryTypes" @@ -1614,7 +1219,7 @@ } } }, - "/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}": { + "/api/v2/helper/importFile/{id}": { "patch": { "parameters": [ { diff --git a/ci/phpunit/fixtures/openapi/hashtype.spec.json b/ci/phpunit/fixtures/openapi/hashtype.spec.json index 5f7481ca4..ba19e9616 100644 --- a/ci/phpunit/fixtures/openapi/hashtype.spec.json +++ b/ci/phpunit/fixtures/openapi/hashtype.spec.json @@ -455,7 +455,7 @@ ] } }, - "/api/v2/ui/hashtypes/{id:[0-9]+}": { + "/api/v2/ui/hashtypes/{id}": { "get": { "tags": [ "HashTypes" @@ -808,7 +808,7 @@ } } }, - "/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}": { + "/api/v2/helper/importFile/{id}": { "patch": { "parameters": [ { diff --git a/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php b/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php index edd728514..c72885844 100644 --- a/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php +++ b/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php @@ -49,6 +49,18 @@ public function testInfoIdentifiesTheApiAndItsLicense(): void { } } + /** + * Paths are OpenAPI path templates from the moment they are built, so no + * Slim regex constraint may survive into either variant. + */ + public function testPathsCarryNoRouteConstraints(): void { + foreach (['raw' => self::$raw, 'sanitized' => self::$sanitized] as $variant => $spec) { + foreach (array_keys($spec['paths']) as $path) { + $this->assertStringNotContainsString(':', $path, "Route constraint in $variant spec: $path"); + } + } + } + public function testExpectedCoverage(): void { $this->assertGreaterThanOrEqual(160, count(self::$sanitized['paths'])); $this->assertGreaterThanOrEqual(200, count(self::$sanitized['components']['schemas'])); diff --git a/ci/phpunit/inc/apiv2/openapi/RouteIntrospectorTest.php b/ci/phpunit/inc/apiv2/openapi/RouteIntrospectorTest.php new file mode 100644 index 000000000..c7f70196b --- /dev/null +++ b/ci/phpunit/inc/apiv2/openapi/RouteIntrospectorTest.php @@ -0,0 +1,102 @@ + targets by path template + */ + private function introspect(callable $registerRoutes): array { + $app = AppFactory::create(); + $registerRoutes($app); + + $targets = []; + foreach ((new RouteIntrospector())->introspect($app) as $target) { + $targets[$target->pathTemplate] = $target; + } + return $targets; + } + + public function testDropsTheRegexConstraintOfAPlaceholder(): void { + $targets = $this->introspect(function ($app) { + $app->delete('/api/v2/ui/things/{id:[0-9]+}', [RouteIntrospectorTestStub::class, 'handle']); + }); + + $this->assertSame(['/api/v2/ui/things/{id}'], array_keys($targets)); + $this->assertSame('delete', $targets['/api/v2/ui/things/{id}']->httpMethod); + } + + /** + * A constraint carries balanced braces of its own, which must neither end + * the placeholder early nor be mistaken for a second one. + */ + public function testDropsAConstraintContainingBraces(): void { + $targets = $this->introspect(function ($app) { + $app->patch('/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}', [RouteIntrospectorTestStub::class, 'handle']); + }); + + $this->assertSame(['/api/v2/helper/importFile/{id}'], array_keys($targets)); + } + + public function testKeepsAPlaceholderWithoutAConstraintAndPlainSegments(): void { + $targets = $this->introspect(function ($app) { + $app->get('/api/v2/ui/things/{id}/count', [RouteIntrospectorTestStub::class, 'handle']); + $app->get('/api/v2/helper/abortChunk', [RouteIntrospectorTestStub::class, 'handle']); + }); + + $this->assertSame( + ['/api/v2/ui/things/{id}/count', '/api/v2/helper/abortChunk'], + array_keys($targets) + ); + } + + /** + * The raw pattern stays available because its constraints say more than how + * to match: a relationship route names its relation in the constraint of the + * "relation" placeholder, which ModelApiPathBuilder reads back out. + */ + public function testKeepsTheRawPatternNextToTheTemplate(): void { + $pattern = '/api/v2/ui/accessgroups/{id:[0-9]+}/relationships/{relation:userMembers}'; + $targets = $this->introspect(function ($app) use ($pattern) { + $app->get($pattern, [RouteIntrospectorTestStub::class, 'handle']); + }); + + $target = $targets['/api/v2/ui/accessgroups/{id}/relationships/{relation}']; + $this->assertSame($pattern, $target->pattern); + $this->assertSame(RouteIntrospectorTestStub::class, $target->className); + $this->assertSame('handle', $target->methodName); + } + + /** + * OPTIONS routes are registered as closures for CORS and carry no API class + * to introspect. + */ + public function testSkipsRoutesWithoutAnApiClass(): void { + $targets = $this->introspect(function ($app) { + $app->options('/api/v2/ui/things', function (Request $request, Response $response): Response { + return $response; + }); + }); + + $this->assertSame([], $targets); + } +} + +/** + * Stand-in for an API class: the introspector only resolves the callable to a + * class and method name, it never invokes it. + */ +final class RouteIntrospectorTestStub { + public function handle(Request $request, Response $response): Response { + return $response; + } +} diff --git a/ci/phpunit/inc/apiv2/openapi/SpecBuilderModelApiTest.php b/ci/phpunit/inc/apiv2/openapi/SpecBuilderModelApiTest.php index 8b425309c..50937cbe5 100644 --- a/ci/phpunit/inc/apiv2/openapi/SpecBuilderModelApiTest.php +++ b/ci/phpunit/inc/apiv2/openapi/SpecBuilderModelApiTest.php @@ -28,7 +28,7 @@ public function testHashTypeSpec(): void { $this->assertSame('3.1.0', $spec['openapi']); $this->assertArrayHasKey('/api/v2/ui/hashtypes', $spec['paths']); $this->assertArrayHasKey('/api/v2/ui/hashtypes/count', $spec['paths']); - $this->assertArrayHasKey('/api/v2/ui/hashtypes/{id:[0-9]+}', $spec['paths']); + $this->assertArrayHasKey('/api/v2/ui/hashtypes/{id}', $spec['paths']); $response = $spec['components']['schemas']['HashTypeResponse']; $this->assertSame(['jsonapi', 'links', 'data'], $response['required']); @@ -47,8 +47,8 @@ public function testConfigSpecWithToOneRelationship(): void { $this->assertMatchesJsonFixture($spec, 'config.spec.json'); - $this->assertArrayHasKey('/api/v2/ui/configs/{id:[0-9]+}/{relation:configSection}', $spec['paths']); - $this->assertArrayHasKey('/api/v2/ui/configs/{id:[0-9]+}/relationships/{relation:configSection}', $spec['paths']); + $this->assertArrayHasKey('/api/v2/ui/configs/{id}/{relation}', $spec['paths']); + $this->assertArrayHasKey('/api/v2/ui/configs/{id}/relationships/{relation}', $spec['paths']); $response = $spec['components']['schemas']['ConfigResponse']; // The attributes object is derived from the features of the model @@ -80,7 +80,7 @@ public function testCrackerBinaryTypeSpecWithMapperOnlySeeding(): void { // Routes of mapper-only classes must not appear $this->assertArrayNotHasKey('/api/v2/ui/crackers', $spec['paths']); $this->assertArrayNotHasKey('/api/v2/ui/tasks', $spec['paths']); - $this->assertArrayHasKey('/api/v2/ui/crackertypes/{id:[0-9]+}/{relation:crackerVersions}', $spec['paths']); + $this->assertArrayHasKey('/api/v2/ui/crackertypes/{id}/{relation}', $spec['paths']); $response = $spec['components']['schemas']['CrackerBinaryTypeResponse']; // toMany relationship linkage is an array of resource identifiers diff --git a/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php b/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php index 15498970b..867b2e2fe 100644 --- a/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php +++ b/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php @@ -49,34 +49,24 @@ public function testRemovesBearerAuthScopesFromSecuritySchemes(): void { $this->assertArrayNotHasKey('scopes', $result['components']['securitySchemes']['bearerAuth']); } - public function testCleansPathTemplatesAndAddsMissingPathParams(): void { + /** + * Paths arrive as OpenAPI path templates (RouteIntrospector), so a + * placeholder in one names a path parameter the operation has to declare. + */ + public function testAddsMissingPathParams(): void { $result = $this->sanitize($this->minimalSpec( - ['/api/v2/ui/things/{id:[0-9]+}' => ['delete' => ['responses' => ['204' => ['description' => 'gone']]]]] + ['/api/v2/ui/things/{id}' => ['delete' => ['responses' => ['204' => ['description' => 'gone']]]]] )); - $this->assertArrayHasKey('/api/v2/ui/things/{id}', $result['paths']); - $this->assertArrayNotHasKey('/api/v2/ui/things/{id:[0-9]+}', $result['paths']); + $operation = $result['paths']['/api/v2/ui/things/{id}']['delete']; $this->assertContains([ 'name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer'], - ], $result['paths']['/api/v2/ui/things/{id}']['delete']['parameters']); - } - - public function testCleansPathTemplatesWithBracesInsideRegexConstraint(): void { - $result = $this->sanitize($this->minimalSpec( - ['/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}' => [ - 'delete' => ['responses' => ['204' => ['description' => 'gone']]], - ]] - )); - - $this->assertArrayHasKey('/api/v2/helper/importFile/{id}', $result['paths']); - $operation = $result['paths']['/api/v2/helper/importFile/{id}']['delete']; - /* The quantifier braces of the constraint must not leak into the - operationId, nor be mistaken for a second path parameter */ - $this->assertSame('deleteImportFileById', $operation['operationId']); + ], $operation['parameters']); $this->assertSame(['id'], array_column($operation['parameters'], 'name')); + $this->assertSame('deleteThingsById', $operation['operationId']); } public function testMovesPaginationParamsToQueryAndFixesStyleCasing(): void { @@ -115,7 +105,7 @@ public function testUnwrapsIndexedRequestBodyAndFixesRequiredString(): void { public function testFillsEmptyMediaTypeObjects(): void { $result = $this->sanitize($this->minimalSpec( - ['/api/v2/ui/things/{id:[0-9]+}' => ['delete' => [ + ['/api/v2/ui/things/{id}' => ['delete' => [ 'requestBody' => ['required' => true, 'content' => ['application/json' => []]], 'responses' => ['200' => ['description' => 'ok', 'content' => ['application/json' => []]]], ]]] diff --git a/src/inc/apiv2/openapi/HelperApiPathBuilder.php b/src/inc/apiv2/openapi/HelperApiPathBuilder.php index e033d649a..6f04accae 100644 --- a/src/inc/apiv2/openapi/HelperApiPathBuilder.php +++ b/src/inc/apiv2/openapi/HelperApiPathBuilder.php @@ -18,7 +18,7 @@ public function __construct( } public function addRoute(RouteTarget $target, AbstractHelperAPI $api, array &$paths, array &$components): void { - $path = $target->pattern; + $path = $target->pathTemplate; $method = $target->httpMethod; $apiMethod = $target->methodName; $class = $api; diff --git a/src/inc/apiv2/openapi/ModelApiPathBuilder.php b/src/inc/apiv2/openapi/ModelApiPathBuilder.php index 16264e619..7404a2d6f 100644 --- a/src/inc/apiv2/openapi/ModelApiPathBuilder.php +++ b/src/inc/apiv2/openapi/ModelApiPathBuilder.php @@ -22,12 +22,15 @@ public function __construct( * @throws HttpErrorException */ public function addRoute(RouteTarget $target, AbstractModelAPI $api, ContainerInterface $container, array &$paths, array &$components, array &$all_scopes): void { - $path = $target->pattern; + /* The spec is keyed by the path template, while the placeholder + constraints of the raw pattern still say what kind of route this is */ + $path = $target->pathTemplate; + $pattern = $target->pattern; $method = $target->httpMethod; $class = $api; /* Quick to find out if single parameter object is used */ - $singleObject = ((strstr($path, '/{id:')) !== false); + $singleObject = ((strstr($pattern, '/{id:')) !== false); $isCount = str_ends_with($path, '/count'); $api_name_parts = explode('\\', get_class($class)); $name = substr(end($api_name_parts), 0, -3); // Remove "API" suffix @@ -35,8 +38,8 @@ public function addRoute(RouteTarget $target, AbstractModelAPI $api, ContainerIn $uri = $class->getBaseUri(); $isRelation = (strstr($path, "/relationships/")) !== false; - if (str_contains($path, "relation:")) { - $relation = rtrim(explode("relation:", $path)[1], "}"); + if (str_contains($pattern, "relation:")) { + $relation = rtrim(explode("relation:", $pattern)[1], "}"); $isToMany = array_key_exists($relation, $class::getToManyRelationships()); $isToOne = array_key_exists($relation, $class::getToOneRelationships()); assert(!($isToMany && $isToOne), "An relationship cant be a to one and to many at the same time."); @@ -254,7 +257,7 @@ public function addRoute(RouteTarget $target, AbstractModelAPI $api, ContainerIn /* Method specific responses and requests for single objects */ if ($method == 'get') { - if (!$isRelation && str_contains($path, "relation:")) { + if (!$isRelation && str_contains($pattern, "relation:")) { $paths[$path][$method]["responses"]["200"] = $this->jsonApiFragments->jsonApiResponse( "successful operation", "#/components/schemas/" . $name . "Relation" . ucfirst($relation) . "GetResponse" @@ -420,7 +423,7 @@ public function addRoute(RouteTarget $target, AbstractModelAPI $api, ContainerIn ] ]; - if (!str_contains($path, "relation:")) { + if (!str_contains($pattern, "relation:")) { $parameters[] = $this->makeIncludeParameter($class); }; } diff --git a/src/inc/apiv2/openapi/RouteIntrospector.php b/src/inc/apiv2/openapi/RouteIntrospector.php index 1a7290842..3a9f2189b 100644 --- a/src/inc/apiv2/openapi/RouteIntrospector.php +++ b/src/inc/apiv2/openapi/RouteIntrospector.php @@ -6,7 +6,9 @@ /** * Resolves the registered Slim routes to the API classes and methods handling - * them, so the spec builder can introspect those classes. + * them, so the spec builder can introspect those classes. The Slim route + * pattern is translated into an OpenAPI path template here, so that everything + * downstream only ever sees the template. */ class RouteIntrospector { /** @@ -24,7 +26,7 @@ public function introspect(App $app): array { /* Assume only one method per route call */ assert(sizeof($route->getMethods()) == 1, "More than 1 methods found for this route"); /* Path relative to basePath */ - $path = $route->getPattern(); + $pattern = $route->getPattern(); $method = strtolower($route->getMethods()[0]); /* Retrieve parameters. Model API routes register array callables @@ -40,8 +42,54 @@ public function introspect(App $app): array { } else { continue; } - $targets[] = new RouteTarget($path, $method, $apiClassName, $apiMethod); + $targets[] = new RouteTarget($pattern, $this->cleanPathTemplate($pattern), $method, $apiClassName, $apiMethod); } return $targets; } + + /** + * Turns a Slim route pattern into an OpenAPI path template, dropping the + * regex constraint of every placeholder, e.g. + * "/importFile/{id:[0-9]{14}-[0-9a-f]{32}}" becomes "/importFile/{id}". + * Such a constraint contains balanced braces of its own, so the brace + * closing the placeholder is found by counting depth rather than by + * matching up to the first "}". + */ + private function cleanPathTemplate(string $path): string { + $clean = ''; + $length = strlen($path); + + for ($i = 0; $i < $length; $i++) { + if ($path[$i] !== '{') { + $clean .= $path[$i]; + continue; + } + + $depth = 0; + $end = -1; + for ($j = $i; $j < $length; $j++) { + if ($path[$j] === '{') { + $depth++; + } elseif ($path[$j] === '}') { + $depth--; + if ($depth === 0) { + $end = $j; + break; + } + } + } + /* Unbalanced braces, keep the remainder as-is instead of mangling it */ + if ($end === -1) { + $clean .= substr($path, $i); + break; + } + + $placeholder = substr($path, $i + 1, $end - $i - 1); + $name = strstr($placeholder, ':', true); + $clean .= '{' . ($name === false ? $placeholder : $name) . '}'; + $i = $end; + } + + return $clean; + } } diff --git a/src/inc/apiv2/openapi/RouteTarget.php b/src/inc/apiv2/openapi/RouteTarget.php index b222922aa..c00823e6b 100644 --- a/src/inc/apiv2/openapi/RouteTarget.php +++ b/src/inc/apiv2/openapi/RouteTarget.php @@ -4,10 +4,17 @@ /** * A single Slim route resolved to the API class and method that handles it. + * + * The route is carried in two forms. $pathTemplate is the OpenAPI path + * template and is what the spec is keyed by. $pattern is the raw Slim + * pattern, kept because the placeholder constraints encode more than a + * validation rule: a relationship route names its relation in the constraint + * of its "relation" placeholder (see AbstractModelAPI::register). */ final readonly class RouteTarget { public function __construct( public string $pattern, + public string $pathTemplate, public string $httpMethod, public string $className, public string $methodName diff --git a/src/inc/apiv2/openapi/SpecSanitizer.php b/src/inc/apiv2/openapi/SpecSanitizer.php index 31986e14e..bfef15278 100644 --- a/src/inc/apiv2/openapi/SpecSanitizer.php +++ b/src/inc/apiv2/openapi/SpecSanitizer.php @@ -41,14 +41,7 @@ public function sanitize(array $spec): array { unset($spec['components']['securitySchemes']['bearerAuth']['scopes']); } - // Phase 3: Clean path templates (strip Slim regex patterns) - $newPaths = []; - foreach ($spec['paths'] as $path => $pathItem) { - $newPaths[$this->cleanPathTemplate((string)$path)] = $pathItem; - } - $spec['paths'] = $newPaths; - - // Phase 4: Walk operations for fixes + // Phase 3: Walk operations for fixes foreach ($spec['paths'] as $path => &$pathItem) { foreach ($pathItem as $method => &$operation) { if (!is_array($operation)) continue; @@ -218,10 +211,10 @@ public function sanitize(array $spec): array { } unset($pathItem); - // Phase 5: Recursive walk for $ref renaming, enum, required, description fixes + // Phase 4: Recursive walk for $ref renaming, enum, required, description fixes $spec = $this->recursiveFixValues($spec, $renameMap); - // Phase 6: Build global tags array from all operations + // Phase 5: Build global tags array from all operations $allTags = []; foreach ($spec['paths'] as $pathItem) { foreach ($pathItem as $op) { @@ -233,7 +226,7 @@ public function sanitize(array $spec): array { ksort($allTags); $spec['tags'] = array_map(fn($name) => ['name' => $name], array_keys($allTags)); - // Phase 7: Remove unreferenced component schemas (iterative until stable) + // Phase 6: Remove unreferenced component schemas (iterative until stable) if (isset($spec['components']['schemas'])) { $changed = true; while ($changed) { @@ -253,52 +246,6 @@ public function sanitize(array $spec): array { return $spec; } - /** - * Turns a Slim route pattern into an OpenAPI path template, dropping the - * regex constraint of every placeholder, e.g. - * "/importFile/{id:[0-9]{14}-[0-9a-f]{32}}" becomes "/importFile/{id}". - * Such a constraint contains balanced braces of its own, so the brace - * closing the placeholder is found by counting depth rather than by - * matching up to the first "}". - */ - private function cleanPathTemplate(string $path): string { - $clean = ''; - $length = strlen($path); - - for ($i = 0; $i < $length; $i++) { - if ($path[$i] !== '{') { - $clean .= $path[$i]; - continue; - } - - $depth = 0; - $end = -1; - for ($j = $i; $j < $length; $j++) { - if ($path[$j] === '{') { - $depth++; - } elseif ($path[$j] === '}') { - $depth--; - if ($depth === 0) { - $end = $j; - break; - } - } - } - /* Unbalanced braces, keep the remainder as-is instead of mangling it */ - if ($end === -1) { - $clean .= substr($path, $i); - break; - } - - $placeholder = substr($path, $i + 1, $end - $i - 1); - $name = strstr($placeholder, ':', true); - $clean .= '{' . ($name === false ? $placeholder : $name) . '}'; - $i = $end; - } - - return $clean; - } - private function collectSchemaRefs(mixed $data, array &$refs): void { if (!is_array($data) && !is_object($data)) return; if (is_object($data)) $data = (array)$data; diff --git a/src/inc/apiv2/openapi/StaticFragments.php b/src/inc/apiv2/openapi/StaticFragments.php index 4e4d4fc96..e65602d8b 100644 --- a/src/inc/apiv2/openapi/StaticFragments.php +++ b/src/inc/apiv2/openapi/StaticFragments.php @@ -163,6 +163,12 @@ public function tusHeader(): array { ]; } + /** + * The keys used here are OpenAPI path templates, matching what + * RouteIntrospector derives from the Slim patterns of the same routes, so + * that these fragments land on the existing path items instead of creating + * a second entry for the same endpoint. + */ public function applyImportFileTusPaths(array &$paths): void { //Hard coded headers for the importfile endpoints. $paths["/api/v2/helper/importFile"]["post"]["parameters"] = [ @@ -203,7 +209,7 @@ public function applyImportFileTusPaths(array &$paths): void { ] ]; - $paths["/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}"]["patch"]["parameters"] = [ + $paths["/api/v2/helper/importFile/{id}"]["patch"]["parameters"] = [ [ "name" => "Upload-Offset", "in" => "header", @@ -224,7 +230,7 @@ public function applyImportFileTusPaths(array &$paths): void { ], ], ]; - $paths["/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}"]["patch"]["requestBody"] = [ + $paths["/api/v2/helper/importFile/{id}"]["patch"]["requestBody"] = [ [ "required" => "true", "description" => "The binary data to push to the file", @@ -239,7 +245,7 @@ public function applyImportFileTusPaths(array &$paths): void { ] ]; - $paths["/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}"]["head"]["responses"]["200"] = [ + $paths["/api/v2/helper/importFile/{id}"]["head"]["responses"]["200"] = [ "description" => "successful request", "headers" => [ "Tus-Resumable" => $this->tusHeader(), @@ -283,7 +289,7 @@ public function applyImportFileTusPaths(array &$paths): void { ] ] ]; - $paths["/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}"]["patch"]["responses"]["204"] = [ + $paths["/api/v2/helper/importFile/{id}"]["patch"]["responses"]["204"] = [ "description" => "Chunk accepted", "headers" => [ "Tus-Resumable" => $this->tusHeader(), From 3283eda9a5d5e27bbb441fd73f101c2801eda73c Mon Sep 17 00:00:00 2001 From: correct-horse-battery-bench Date: Mon, 17 Aug 2026 13:42:46 +0200 Subject: [PATCH 3/3] Describe every tag the document declares The sanitizer collected the tag names of all operations and declared each as a bare name. A reader of the document, and every tool that renders it, was left with a heading and nothing under it, which is also what Redocly reported as its tag-description warning. Derive the description from the operations carrying the tag: the collection they are served under, and whether that collection can be modified, which follows from the methods those operations use. The tags that do not describe a resource collection (Helpers, Login, Authentication) are named explicitly. --- .../inc/apiv2/openapi/SpecSanitizerTest.php | 38 +++++++- openapi.json | 96 ++++++++++++------- src/inc/apiv2/openapi/SpecSanitizer.php | 50 ++++++++-- 3 files changed, 145 insertions(+), 39 deletions(-) diff --git a/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php b/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php index 867b2e2fe..11b18196e 100644 --- a/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php +++ b/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php @@ -172,7 +172,43 @@ public function testSynthesizesTagsSummaryOperationIdAndSecurity(): void { $this->assertSame('postAbortChunk', $operation['operationId']); $this->assertSame('Create Helpers', $operation['description']); $this->assertSame([['bearerAuth' => []]], $operation['security']); - $this->assertSame([['name' => 'Helpers']], $result['tags']); + $this->assertSame( + [[ + 'name' => 'Helpers', + 'description' => 'Helper endpoints under `/api/v2/helper/`: actions and file transfers that do not map onto a resource collection.' + ]], + $result['tags'] + ); + } + + /** + * Every tag needs a description. For a resource collection it names the + * collection and whether it can be modified, which follows from the methods + * its operations use. + */ + public function testDescribesTagsOfResourceCollections(): void { + $result = $this->sanitize($this->minimalSpec([ + '/api/v2/ui/things' => [ + 'get' => ['tags' => ['Things'], 'responses' => ['200' => ['description' => 'ok']]], + 'patch' => ['tags' => ['Things'], 'responses' => ['204' => ['description' => 'ok']]], + ], + '/api/v2/ui/things/{id}' => [ + 'get' => ['tags' => ['Things'], 'responses' => ['200' => ['description' => 'ok']]], + ], + '/api/v2/ui/readonlys' => [ + 'get' => ['tags' => ['Readonlys'], 'responses' => ['200' => ['description' => 'ok']]], + ], + ])); + + $descriptions = array_column($result['tags'], 'description', 'name'); + $this->assertSame( + 'Reading the resources served under `/api/v2/ui/readonlys`.', + $descriptions['Readonlys'] + ); + $this->assertSame( + 'Reading and writing the resources served under `/api/v2/ui/things`.', + $descriptions['Things'] + ); } public function testAddsMissing2xxResponse(): void { diff --git a/openapi.json b/openapi.json index b3b67b947..bfee5d19e 100644 --- a/openapi.json +++ b/openapi.json @@ -70098,100 +70098,132 @@ }, "tags": [ { - "name": "AccessGroups" + "name": "AccessGroups", + "description": "Reading and writing the resources served under `/api/v2/ui/accessgroups`." }, { - "name": "AgentAssignments" + "name": "AgentAssignments", + "description": "Reading and writing the resources served under `/api/v2/ui/agentassignments`." }, { - "name": "AgentBinarys" + "name": "AgentBinarys", + "description": "Reading and writing the resources served under `/api/v2/ui/agentbinaries`." }, { - "name": "AgentErrors" + "name": "AgentErrors", + "description": "Reading and writing the resources served under `/api/v2/ui/agenterrors`." }, { - "name": "AgentStats" + "name": "AgentStats", + "description": "Reading and writing the resources served under `/api/v2/ui/agentstats`." }, { - "name": "Agents" + "name": "Agents", + "description": "Reading and writing the resources served under `/api/v2/ui/agents`." }, { - "name": "ApiTokens" + "name": "ApiTokens", + "description": "Reading and writing the resources served under `/api/v2/ui/apiTokens`." }, { - "name": "Chunks" + "name": "Chunks", + "description": "Reading and writing the resources served under `/api/v2/ui/chunks`." }, { - "name": "ConfigSections" + "name": "ConfigSections", + "description": "Reading the resources served under `/api/v2/ui/configsections`." }, { - "name": "Configs" + "name": "Configs", + "description": "Reading and writing the resources served under `/api/v2/ui/configs`." }, { - "name": "CrackerBinaryTypes" + "name": "CrackerBinaryTypes", + "description": "Reading and writing the resources served under `/api/v2/ui/crackertypes`." }, { - "name": "CrackerBinarys" + "name": "CrackerBinarys", + "description": "Reading and writing the resources served under `/api/v2/ui/crackers`." }, { - "name": "Files" + "name": "Files", + "description": "Reading and writing the resources served under `/api/v2/ui/files`." }, { - "name": "GlobalPermissionGroups" + "name": "GlobalPermissionGroups", + "description": "Reading and writing the resources served under `/api/v2/ui/globalpermissiongroups`." }, { - "name": "HashTypes" + "name": "HashTypes", + "description": "Reading and writing the resources served under `/api/v2/ui/hashtypes`." }, { - "name": "Hashlists" + "name": "Hashlists", + "description": "Reading and writing the resources served under `/api/v2/ui/hashlists`." }, { - "name": "Hashs" + "name": "Hashs", + "description": "Reading and writing the resources served under `/api/v2/ui/hashes`." }, { - "name": "HealthCheckAgents" + "name": "HealthCheckAgents", + "description": "Reading and writing the resources served under `/api/v2/ui/healthcheckagents`." }, { - "name": "HealthChecks" + "name": "HealthChecks", + "description": "Reading and writing the resources served under `/api/v2/ui/healthchecks`." }, { - "name": "Helpers" + "name": "Helpers", + "description": "Helper endpoints under `/api/v2/helper/`: actions and file transfers that do not map onto a resource collection." }, { - "name": "LogEntrys" + "name": "LogEntrys", + "description": "Reading the resources served under `/api/v2/ui/logentries`." }, { - "name": "Login" + "name": "Login", + "description": "Exchanging basic auth credentials for the JWT that every other endpoint requires." }, { - "name": "NotificationSettings" + "name": "NotificationSettings", + "description": "Reading and writing the resources served under `/api/v2/ui/notifications`." }, { - "name": "PreTasks" + "name": "PreTasks", + "description": "Reading and writing the resources served under `/api/v2/ui/pretasks`." }, { - "name": "Preprocessors" + "name": "Preprocessors", + "description": "Reading and writing the resources served under `/api/v2/ui/preprocessors`." }, { - "name": "Speeds" + "name": "Speeds", + "description": "Reading and writing the resources served under `/api/v2/ui/speeds`." }, { - "name": "Supertasks" + "name": "Supertasks", + "description": "Reading and writing the resources served under `/api/v2/ui/supertasks`." }, { - "name": "TaskWrapperDisplays" + "name": "TaskWrapperDisplays", + "description": "Reading and writing the resources served under `/api/v2/ui/taskwrapperdisplays`." }, { - "name": "TaskWrappers" + "name": "TaskWrappers", + "description": "Reading and writing the resources served under `/api/v2/ui/taskwrappers`." }, { - "name": "Tasks" + "name": "Tasks", + "description": "Reading and writing the resources served under `/api/v2/ui/tasks`." }, { - "name": "Users" + "name": "Users", + "description": "Reading and writing the resources served under `/api/v2/ui/users`." }, { - "name": "Vouchers" + "name": "Vouchers", + "description": "Reading and writing the resources served under `/api/v2/ui/vouchers`." } ] } diff --git a/src/inc/apiv2/openapi/SpecSanitizer.php b/src/inc/apiv2/openapi/SpecSanitizer.php index bfef15278..602290aeb 100644 --- a/src/inc/apiv2/openapi/SpecSanitizer.php +++ b/src/inc/apiv2/openapi/SpecSanitizer.php @@ -215,16 +215,22 @@ public function sanitize(array $spec): array { $spec = $this->recursiveFixValues($spec, $renameMap); // Phase 5: Build global tags array from all operations - $allTags = []; - foreach ($spec['paths'] as $pathItem) { - foreach ($pathItem as $op) { + $tagOperations = []; + foreach ($spec['paths'] as $path => $pathItem) { + foreach ($pathItem as $method => $op) { if (is_array($op) && isset($op['tags'])) { - foreach ($op['tags'] as $tag) { $allTags[$tag] = true; } + foreach ($op['tags'] as $tag) { $tagOperations[$tag][$path][] = (string)$method; } } } } - ksort($allTags); - $spec['tags'] = array_map(fn($name) => ['name' => $name], array_keys($allTags)); + ksort($tagOperations); + $spec['tags'] = []; + foreach ($tagOperations as $name => $operations) { + $spec['tags'][] = [ + 'name' => $name, + 'description' => $this->tagDescription((string)$name, $operations) + ]; + } // Phase 6: Remove unreferenced component schemas (iterative until stable) if (isset($spec['components']['schemas'])) { @@ -246,6 +252,38 @@ public function sanitize(array $spec): array { return $spec; } + /** + * The description of a tag, derived from the operations carrying it: the + * collection they are served under and whether that collection can be + * modified. The helper and login tags do not describe a collection, so they + * are named explicitly. + * + * @param string $name tag name + * @param array> $operations methods per path carrying the tag + */ + private function tagDescription(string $name, array $operations): string { + if ($name === 'Helpers') { + return 'Helper endpoints under `/api/v2/helper/`: actions and file transfers that do not map onto a resource collection.'; + } + if ($name === 'Login') { + return 'Exchanging basic auth credentials for the JWT that every other endpoint requires.'; + } + if ($name === 'Authentication') { + return 'Endpoints under `/api/v2/auth/`.'; + } + + /* The shortest path carrying the tag is the collection itself */ + $paths = array_keys($operations); + usort($paths, fn($a, $b) => strlen($a) <=> strlen($b)); + $collection = $paths[0]; + + $methods = array_merge(...array_values($operations)); + $writable = count(array_intersect(['post', 'patch', 'delete'], $methods)) > 0; + + return ($writable ? 'Reading and writing' : 'Reading') + . ' the resources served under `' . $collection . '`.'; + } + private function collectSchemaRefs(mixed $data, array &$refs): void { if (!is_array($data) && !is_object($data)) return; if (is_object($data)) $data = (array)$data;