From 21012ce33ab7dd253b29c26f83225b77fb80381b Mon Sep 17 00:00:00 2001 From: correct-horse-battery-bench Date: Mon, 17 Aug 2026 13:36:52 +0200 Subject: [PATCH 1/3] Generate the APIv2 OpenAPI spec from a dedicated library The spec was assembled inline in the openapi.json route closure, with the schema helpers spread over OpenAPISchemaUtils and openAPISchema.routes.php. It moves into a library under src/inc/apiv2/openapi/, with ApiRegistry as the single source of truth for which APIv2 classes exist. The document declares openapi 3.1.0 and describes the surface the API actually has: per model Count, bulk and single response schemas, the getGlobalConfig, getCompletedCount and getCracksPerDay helper paths, and the pagination links the runtime answers with (self, first, last, next, prev), with a single resource document carrying only a self link. It also describes only what the server answers today. A resource id is an integer, because that is what obj2Resource puts in the document; the string form JSON:API asks for is described once the runtime serializes it as one. There is one spec endpoint, GET /api/v2/openapi.json, answering the sanitized document, plus ci/tools/generate-openapi.php, which produces the identical document without a database or a server. Two changes follow from generating it offline: - AgentAPI reads hideIpInfo when it filters instead of in the constructor, because the generator instantiates the API classes purely to introspect them. - GetUserPermissionHelperAPI names the schema prefix of the model API it answers with (GlobalPermissionGroup), not the name of the DBA model. --- .gitattributes | 4 + .../fixtures/openapi/abortchunk.spec.json | 439 ++ ci/phpunit/fixtures/openapi/config.spec.json | 2962 ++++++++++++++ .../openapi/crackerbinarytype.spec.json | 3642 +++++++++++++++++ .../fixtures/openapi/hashtype.spec.json | 1628 ++++++++ .../apiv2/openapi/FeatureTypeMapperTest.php | 129 + ci/phpunit/inc/apiv2/openapi/FullSpecTest.php | 227 + .../openapi/SpecBuilderHelperApiTest.php | 64 + .../apiv2/openapi/SpecBuilderModelApiTest.php | 97 + .../inc/apiv2/openapi/SpecFixtureTrait.php | 33 + .../inc/apiv2/openapi/SpecSanitizerTest.php | 241 ++ ci/tools/generate-openapi.php | 27 + composer.json | 3 +- src/api/v2/index.php | 133 +- src/inc/apiv2/common/ApiRegistry.php | 150 + src/inc/apiv2/common/OpenAPISchemaUtils.php | 377 -- src/inc/apiv2/common/openAPISchema.routes.php | 1046 +---- .../helper/GetUserPermissionHelperAPI.php | 5 +- src/inc/apiv2/model/AgentAPI.php | 17 +- src/inc/apiv2/openapi/FeatureTypeMapper.php | 133 + .../apiv2/openapi/HelperApiPathBuilder.php | 106 + src/inc/apiv2/openapi/JsonApiFragments.php | 403 ++ src/inc/apiv2/openapi/ModelApiPathBuilder.php | 731 ++++ src/inc/apiv2/openapi/RouteIntrospector.php | 47 + src/inc/apiv2/openapi/RouteTarget.php | 16 + src/inc/apiv2/openapi/SpecBuilder.php | 142 + src/inc/apiv2/openapi/SpecSanitizer.php | 390 ++ src/inc/apiv2/openapi/StaticFragments.php | 299 ++ 28 files changed, 11939 insertions(+), 1552 deletions(-) create mode 100644 .gitattributes create mode 100644 ci/phpunit/fixtures/openapi/abortchunk.spec.json create mode 100644 ci/phpunit/fixtures/openapi/config.spec.json create mode 100644 ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json create mode 100644 ci/phpunit/fixtures/openapi/hashtype.spec.json create mode 100644 ci/phpunit/inc/apiv2/openapi/FeatureTypeMapperTest.php create mode 100644 ci/phpunit/inc/apiv2/openapi/FullSpecTest.php create mode 100644 ci/phpunit/inc/apiv2/openapi/SpecBuilderHelperApiTest.php create mode 100644 ci/phpunit/inc/apiv2/openapi/SpecBuilderModelApiTest.php create mode 100644 ci/phpunit/inc/apiv2/openapi/SpecFixtureTrait.php create mode 100644 ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php create mode 100644 ci/tools/generate-openapi.php create mode 100644 src/inc/apiv2/common/ApiRegistry.php delete mode 100644 src/inc/apiv2/common/OpenAPISchemaUtils.php create mode 100644 src/inc/apiv2/openapi/FeatureTypeMapper.php create mode 100644 src/inc/apiv2/openapi/HelperApiPathBuilder.php create mode 100644 src/inc/apiv2/openapi/JsonApiFragments.php create mode 100644 src/inc/apiv2/openapi/ModelApiPathBuilder.php create mode 100644 src/inc/apiv2/openapi/RouteIntrospector.php create mode 100644 src/inc/apiv2/openapi/RouteTarget.php create mode 100644 src/inc/apiv2/openapi/SpecBuilder.php create mode 100644 src/inc/apiv2/openapi/SpecSanitizer.php create mode 100644 src/inc/apiv2/openapi/StaticFragments.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..add0e8f9c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Generated OpenAPI spec snapshots. They are produced by running the PHPUnit +# suite with UPDATE_OPENAPI_FIXTURES=1 and are never hand-edited, so collapse +# them in code review and keep them out of the language statistics. +ci/phpunit/fixtures/openapi/*.spec.json linguist-generated=true diff --git a/ci/phpunit/fixtures/openapi/abortchunk.spec.json b/ci/phpunit/fixtures/openapi/abortchunk.spec.json new file mode 100644 index 000000000..96195e90b --- /dev/null +++ b/ci/phpunit/fixtures/openapi/abortchunk.spec.json @@ -0,0 +1,439 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Hashtopolis API", + "version": "v2" + }, + "servers": [ + { + "url": "/" + } + ], + "paths": { + "/api/v2/helper/abortChunk": { + "post": { + "description": "Endpoint to stop a running chunk.", + "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/Hashtopolis\\inc\\apiv2\\helper\\AbortChunkHelperAPIResponse" + } + } + } + } + }, + "requestBody": { + "description": "ChunkID is the ID of the chunk that needs to be aborted.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Hashtopolis\\inc\\apiv2\\helper\\AbortChunkHelperAPI" + } + } + } + } + } + }, + "/api/v2/auth/token": { + "post": { + "tags": [ + "Login" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Token" + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] + } + }, + "/api/v2/helper/importFile": { + "post": { + "parameters": [ + { + "name": "Upload-Metadata", + "in": "header", + "required": "true", + "schema": { + "type": "string", + "pattern": "^([a-zA-Z0-9]+ [A-Za-z0-9+/=]+)(,[a-zA-Z0-9]+ [A-Za-z0-9+/=]+)*$" + }, + "example": "filename ZXhhbXBsZS50eHQ=", + "description": " The Upload-Metadata header contains one or more comma-separated key-value pairs.\n Each pair is formatted as ` `, where:\n - `key` is a string without spaces.\n - `value` is base64-encoded" + }, + { + "name": "Upload-Length", + "in": "header", + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 10000, + "description": "The total size of the upload in bytes. Must be a positive integer.\n Required if `Upload-Defer-Length` is not set." + }, + { + "name": "Upload-Defer-Length", + "in": "header", + "schema": { + "type": "integer" + }, + "example": 1, + "description": "Indicates that the upload length is not known at creation time.\n Value must be `1`. If present, `Upload-Length` must be omitted." + } + ], + "responses": { + "201": { + "description": "Upload created", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Location": { + "description": "Location of the file where the user can push to.", + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}": { + "patch": { + "parameters": [ + { + "name": "Upload-Offset", + "in": "header", + "required": "true", + "schema": { + "type": "integer" + }, + "example": 512, + "description": " The Upload-Offset header's value MUST be equal to the current offset of the resource" + }, + { + "name": "Content-Type", + "in": "header", + "required": "true", + "schema": { + "type": "string", + "enum": [ + "application/offset+octet-stream" + ] + } + } + ], + "requestBody": [ + { + "required": "true", + "description": "The binary data to push to the file", + "content": { + "application/offset+octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + ], + "responses": { + "204": { + "description": "Chunk accepted", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Upload-Offset": { + "description": "The new offset after the chunk is accepted. Indicates how many bytes were received so far.", + "schema": { + "type": "integer" + } + } + } + } + } + }, + "head": { + "responses": { + "200": { + "description": "successful request", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Upload-Offset": { + "description": "Number of bytes already received", + "schema": { + "type": "integer" + } + }, + "Upload-Length": { + "description": "Total upload length (if known)", + "schema": { + "type": "integer" + } + }, + "Upload-Defer-Length": { + "description": "Indicates deferred upload length (if applicable)", + "schema": { + "type": "string" + } + }, + "Upload-Metadata": { + "description": "Original metadata sent during creation", + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ErrorResponse": { + "type": "object", + "required": [ + "status" + ], + "description": "RFC 7807 problem document", + "properties": { + "title": { + "type": "string", + "example": "No access to this object!" + }, + "type": { + "type": "string", + "example": "about:blank" + }, + "status": { + "type": "integer", + "example": 400 + } + } + }, + "Hashtopolis\\inc\\apiv2\\helper\\AbortChunkHelperAPI": { + "type": "object", + "properties": { + "chunkId": { + "type": "integer" + } + } + }, + "Hashtopolis\\inc\\apiv2\\helper\\AbortChunkHelperAPIResponse": { + "type": "object", + "required": [ + "jsonapi", + "meta", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "meta": { + "type": "object", + "properties": { + "Abort": { + "type": "string", + "default": "Success" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object" + }, + "maxItems": 0, + "description": "Always empty: a helper answers with meta only." + } + } + }, + "Token": { + "type": "object", + "required": [ + "token", + "expires" + ], + "properties": { + "token": { + "type": "string" + }, + "expires": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "TokenRequest": { + "type": "array", + "items": { + "type": "string", + "example": "role.all" + } + }, + "ObjectRequest": { + "type": "object", + "properties": { + "expand": { + "type": "string" + }, + "expires": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "ObjectListRequest": { + "type": "object", + "properties": { + "expand": { + "type": "string" + }, + "filter": { + "type": "array", + "items": { + "type": "string", + "example": "" + } + } + }, + "additionalProperties": false + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "description": "JWT Authorization header using the Bearer scheme.", + "scheme": "bearer", + "bearerFormat": "JWT", + "scopes": [] + }, + "basicAuth": { + "type": "http", + "description": "Basic Authorization header.", + "scheme": "basic" + } + } + } +} diff --git a/ci/phpunit/fixtures/openapi/config.spec.json b/ci/phpunit/fixtures/openapi/config.spec.json new file mode 100644 index 000000000..3f772f101 --- /dev/null +++ b/ci/phpunit/fixtures/openapi/config.spec.json @@ -0,0 +1,2962 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Hashtopolis API", + "version": "v2" + }, + "servers": [ + { + "url": "/" + } + ], + "paths": { + "/api/v2/ui/configs": { + "get": { + "tags": [ + "Configs" + ], + "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" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/ConfigListResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permConfigRead" + ] + ] + } + ], + "description": "GET many request to retrieve multiple objects.", + "parameters": [ + { + "name": "page[after]", + "in": "path", + "schema": { + "type": "string", + "format": "byte" + }, + "example": "eyJwcmltYXJ5Ijp7ImNvbmZpZ0lkIjogMTIzfX0=", + "description": "Pointer to paginate to retrieve the data after the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting.\n \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}`\n \n\nExample: `{\"primary\":{\"configId\": 123}}` -> `eyJwcmltYXJ5Ijp7ImNvbmZpZ0lkIjogMTIzfX0=`" + }, + { + "name": "page[before]", + "in": "path", + "schema": { + "type": "string", + "format": "byte" + }, + "example": "eyJwcmltYXJ5Ijp7ImNvbmZpZ0lkIjogMTIzfX0=", + "description": "Pointer to paginate to retrieve the data before the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting.\n \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}`\n \n\nExample: `{\"primary\":{\"configId\": 123}}` -> `eyJwcmltYXJ5Ijp7ImNvbmZpZ0lkIjogMTIzfX0=`" + }, + { + "name": "page[size]", + "in": "path", + "schema": { + "type": "integer", + "format": "int32" + }, + "example": 100, + "description": "Amout of data to retrieve inside a single page" + }, + { + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": "Filters results using a query. Every key is an attribute name optionally suffixed with a comparison operator, e.g. `filter[configId__gt]=200`.", + "example": { + "configId__gt": "200" + } + }, + { + "name": "include", + "in": "query", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "configSection" + ] + } + }, + "description": "Relationships to include in the response, comma seperated. Possible options: configSection", + "example": [ + "configSection" + ] + }, + { + "name": "aggregate", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "required": false, + "description": "Aggregated fields to include by type (comma separated values). Possible options: config: valueBoundaries", + "example": { + "aggregate[config]": "valueBoundaries" + } + } + ] + }, + "patch": { + "tags": [ + "Configs" + ], + "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 updated" + }, + "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": [ + [ + "permConfigUpdate" + ] + ] + } + ], + "description": "PATCH request to update attributes of a single object.", + "requestBody": { + "required": true, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/ConfigPatchMultiple" + } + } + } + }, + "parameters": [] + } + }, + "/api/v2/ui/configs/count": { + "get": { + "tags": [ + "Configs" + ], + "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" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/ConfigCountResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permConfigRead" + ] + ] + } + ], + "description": "GET many request to retrieve multiple objects.", + "parameters": [ + { + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": "Filters results using a query. Every key is an attribute name optionally suffixed with a comparison operator, e.g. `filter[configId__gt]=200`.", + "example": { + "configId__gt": "200" + } + }, + { + "name": "include_total", + "in": "query", + "schema": { + "type": "boolean" + }, + "example": true, + "description": "Also report the number of objects without any filter applied, as `meta.total_count`" + } + ] + } + }, + "/api/v2/ui/configs/{id:[0-9]+}/{relation:configSection}": { + "get": { + "tags": [ + "Configs" + ], + "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/ConfigRelationConfigSectionGetResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permConfigRead" + ] + ] + } + ], + "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/configs/{id:[0-9]+}/relationships/{relation:configSection}": { + "get": { + "tags": [ + "Configs" + ], + "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/ConfigResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permConfigRead" + ] + ] + } + ], + "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": [ + "Configs" + ], + "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": [ + [ + "permConfigUpdate" + ] + ] + } + ], + "description": "PATCH request to update a to one relationship.", + "requestBody": { + "required": true, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/ConfigRelationConfigSection" + } + } + } + }, + "parameters": [] + } + }, + "/api/v2/ui/configs/{id:[0-9]+}": { + "get": { + "tags": [ + "Configs" + ], + "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/ConfigResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permConfigRead" + ] + ] + } + ], + "description": "GET request to retrieve a single object.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "example": 10 + } + }, + { + "name": "include", + "in": "query", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "configSection" + ] + } + }, + "description": "Relationships to include in the response, comma seperated. Possible options: configSection", + "example": [ + "configSection" + ] + } + ] + }, + "patch": { + "tags": [ + "Configs" + ], + "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" + } + } + } + }, + "409": { + "description": "Resource already exists", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/ConfigPostPatchResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permConfigUpdate" + ] + ] + } + ], + "description": "PATCH request to update attributes of a single object.", + "requestBody": { + "required": true, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/ConfigPatch" + } + } + } + }, + "parameters": [] + } + }, + "/api/v2/ui/configsections": { + "get": { + "tags": [ + "ConfigSections" + ], + "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" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/ConfigSectionListResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permConfigSectionRead" + ] + ] + } + ], + "description": "GET many request to retrieve multiple objects.", + "parameters": [ + { + "name": "page[after]", + "in": "path", + "schema": { + "type": "string", + "format": "byte" + }, + "example": "eyJwcmltYXJ5Ijp7ImNvbmZpZ1NlY3Rpb25JZCI6IDEyM319", + "description": "Pointer to paginate to retrieve the data after the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting.\n \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}`\n \n\nExample: `{\"primary\":{\"configSectionId\": 123}}` -> `eyJwcmltYXJ5Ijp7ImNvbmZpZ1NlY3Rpb25JZCI6IDEyM319`" + }, + { + "name": "page[before]", + "in": "path", + "schema": { + "type": "string", + "format": "byte" + }, + "example": "eyJwcmltYXJ5Ijp7ImNvbmZpZ1NlY3Rpb25JZCI6IDEyM319", + "description": "Pointer to paginate to retrieve the data before the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting.\n \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}`\n \n\nExample: `{\"primary\":{\"configSectionId\": 123}}` -> `eyJwcmltYXJ5Ijp7ImNvbmZpZ1NlY3Rpb25JZCI6IDEyM319`" + }, + { + "name": "page[size]", + "in": "path", + "schema": { + "type": "integer", + "format": "int32" + }, + "example": 100, + "description": "Amout of data to retrieve inside a single page" + }, + { + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": "Filters results using a query. Every key is an attribute name optionally suffixed with a comparison operator, e.g. `filter[configSectionId__gt]=200`.", + "example": { + "configSectionId__gt": "200" + } + }, + { + "name": "include", + "in": "query", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Relationships to include in the response, comma seperated. Possible options: " + } + ] + } + }, + "/api/v2/ui/configsections/count": { + "get": { + "tags": [ + "ConfigSections" + ], + "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" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/ConfigSectionCountResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permConfigSectionRead" + ] + ] + } + ], + "description": "GET many request to retrieve multiple objects.", + "parameters": [ + { + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": "Filters results using a query. Every key is an attribute name optionally suffixed with a comparison operator, e.g. `filter[configSectionId__gt]=200`.", + "example": { + "configSectionId__gt": "200" + } + }, + { + "name": "include_total", + "in": "query", + "schema": { + "type": "boolean" + }, + "example": true, + "description": "Also report the number of objects without any filter applied, as `meta.total_count`" + } + ] + } + }, + "/api/v2/ui/configsections/{id:[0-9]+}": { + "get": { + "tags": [ + "ConfigSections" + ], + "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/ConfigSectionResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permConfigSectionRead" + ] + ] + } + ], + "description": "GET request to retrieve a single object.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "example": 10 + } + }, + { + "name": "include", + "in": "query", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Relationships to include in the response, comma seperated. Possible options: " + } + ] + } + }, + "/api/v2/auth/token": { + "post": { + "tags": [ + "Login" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Token" + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] + } + }, + "/api/v2/helper/importFile": { + "post": { + "parameters": [ + { + "name": "Upload-Metadata", + "in": "header", + "required": "true", + "schema": { + "type": "string", + "pattern": "^([a-zA-Z0-9]+ [A-Za-z0-9+/=]+)(,[a-zA-Z0-9]+ [A-Za-z0-9+/=]+)*$" + }, + "example": "filename ZXhhbXBsZS50eHQ=", + "description": " The Upload-Metadata header contains one or more comma-separated key-value pairs.\n Each pair is formatted as ` `, where:\n - `key` is a string without spaces.\n - `value` is base64-encoded" + }, + { + "name": "Upload-Length", + "in": "header", + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 10000, + "description": "The total size of the upload in bytes. Must be a positive integer.\n Required if `Upload-Defer-Length` is not set." + }, + { + "name": "Upload-Defer-Length", + "in": "header", + "schema": { + "type": "integer" + }, + "example": 1, + "description": "Indicates that the upload length is not known at creation time.\n Value must be `1`. If present, `Upload-Length` must be omitted." + } + ], + "responses": { + "201": { + "description": "Upload created", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Location": { + "description": "Location of the file where the user can push to.", + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}": { + "patch": { + "parameters": [ + { + "name": "Upload-Offset", + "in": "header", + "required": "true", + "schema": { + "type": "integer" + }, + "example": 512, + "description": " The Upload-Offset header's value MUST be equal to the current offset of the resource" + }, + { + "name": "Content-Type", + "in": "header", + "required": "true", + "schema": { + "type": "string", + "enum": [ + "application/offset+octet-stream" + ] + } + } + ], + "requestBody": [ + { + "required": "true", + "description": "The binary data to push to the file", + "content": { + "application/offset+octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + ], + "responses": { + "204": { + "description": "Chunk accepted", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Upload-Offset": { + "description": "The new offset after the chunk is accepted. Indicates how many bytes were received so far.", + "schema": { + "type": "integer" + } + } + } + } + } + }, + "head": { + "responses": { + "200": { + "description": "successful request", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Upload-Offset": { + "description": "Number of bytes already received", + "schema": { + "type": "integer" + } + }, + "Upload-Length": { + "description": "Total upload length (if known)", + "schema": { + "type": "integer" + } + }, + "Upload-Defer-Length": { + "description": "Indicates deferred upload length (if applicable)", + "schema": { + "type": "string" + } + }, + "Upload-Metadata": { + "description": "Original metadata sent during creation", + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ErrorResponse": { + "type": "object", + "required": [ + "status" + ], + "description": "RFC 7807 problem document", + "properties": { + "title": { + "type": "string", + "example": "No access to this object!" + }, + "type": { + "type": "string", + "example": "about:blank" + }, + "status": { + "type": "integer", + "example": 400 + } + } + }, + "ConfigCreate": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "type", + "attributes" + ], + "properties": { + "type": { + "type": "string", + "const": "config" + }, + "attributes": { + "type": "object", + "properties": { + "configSectionId": { + "type": "integer" + }, + "item": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "configSectionId", + "item", + "value" + ] + } + } + } + } + }, + "ConfigPatch": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "type", + "attributes" + ], + "properties": { + "type": { + "type": "string", + "const": "config" + }, + "attributes": { + "type": "object", + "properties": { + "item": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + } + } + }, + "ConfigPatchMultiple": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "config" + }, + "attributes": { + "type": "object", + "properties": { + "item": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + } + } + } + }, + "ConfigDeleteMultiple": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "config" + } + } + } + } + } + }, + "ConfigResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links", + "relationships" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "config" + }, + "attributes": { + "type": "object", + "required": [ + "configSectionId", + "item", + "value" + ], + "properties": { + "configSectionId": { + "type": "integer" + }, + "item": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/1" + } + } + }, + "relationships": { + "type": "object", + "required": [ + "configSection" + ], + "properties": { + "configSection": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/relationships/configSection" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/configs/configSection" + } + } + }, + "data": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "configSection" + }, + "id": { + "type": "integer", + "example": 1 + } + } + }, + { + "type": "null" + } + ] + } + } + } + } + } + } + }, + "included": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "required": [ + "sectionName" + ], + "properties": { + "sectionName": { + "type": "string" + } + } + } + } + } + } + } + }, + "ConfigSingleResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links", + "relationships" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "config" + }, + "attributes": { + "type": "object", + "required": [ + "configSectionId", + "item", + "value" + ], + "properties": { + "configSectionId": { + "type": "integer" + }, + "item": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/1" + } + } + }, + "relationships": { + "type": "object", + "required": [ + "configSection" + ], + "properties": { + "configSection": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/relationships/configSection" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/configs/configSection" + } + } + }, + "data": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "configSection" + }, + "id": { + "type": "integer", + "example": 1 + } + } + }, + { + "type": "null" + } + ] + } + } + } + } + } + } + }, + "included": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "required": [ + "sectionName" + ], + "properties": { + "sectionName": { + "type": "string" + } + } + } + } + } + } + } + }, + "ConfigPostPatchResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links", + "relationships" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "config" + }, + "attributes": { + "type": "object", + "required": [ + "configSectionId", + "item", + "value" + ], + "properties": { + "configSectionId": { + "type": "integer" + }, + "item": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/1" + } + } + }, + "relationships": { + "type": "object", + "required": [ + "configSection" + ], + "properties": { + "configSection": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/relationships/configSection" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/configs/configSection" + } + } + }, + "data": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "configSection" + }, + "id": { + "type": "integer", + "example": 1 + } + } + }, + { + "type": "null" + } + ] + } + } + } + } + } + } + }, + "included": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "required": [ + "sectionName" + ], + "properties": { + "sectionName": { + "type": "string" + } + } + } + } + } + } + } + }, + "ConfigListResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "meta", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self", + "first", + "last", + "next", + "prev" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs?page[size]=25" + }, + "first": { + "type": "string", + "default": "/api/v2/ui/configs?page[size]=25" + }, + "last": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/configs?page[size]=25&page[before]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + }, + "next": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/configs?page[size]=25&page[after]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + }, + "prev": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/configs?page[size]=25&page[before]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + } + } + }, + "meta": { + "type": "object", + "required": [ + "page" + ], + "properties": { + "page": { + "type": "object", + "required": [ + "total_elements" + ], + "properties": { + "total_elements": { + "type": "integer" + } + } + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links", + "relationships" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "config" + }, + "attributes": { + "type": "object", + "required": [ + "configSectionId", + "item", + "value" + ], + "properties": { + "configSectionId": { + "type": "integer" + }, + "item": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/1" + } + } + }, + "relationships": { + "type": "object", + "required": [ + "configSection" + ], + "properties": { + "configSection": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configs/relationships/configSection" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/configs/configSection" + } + } + }, + "data": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "configSection" + }, + "id": { + "type": "integer", + "example": 1 + } + } + }, + { + "type": "null" + } + ] + } + } + } + } + } + } + } + }, + "included": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "required": [ + "sectionName" + ], + "properties": { + "sectionName": { + "type": "string" + } + } + } + } + } + } + } + }, + "ConfigCountResponse": { + "type": "object", + "required": [ + "jsonapi", + "meta", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "meta": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "description": "Number of objects matching the given filters" + }, + "total_count": { + "type": "integer", + "description": "Number of objects without any filter applied, only present when `include_total=true` was requested" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object" + }, + "maxItems": 0, + "description": "Always empty: the count is reported under meta." + } + } + }, + "ConfigRelationConfigSection": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "configSection" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + }, + "ConfigRelationConfigSectionGetResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "configSection" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + }, + "ConfigSectionCreate": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "type", + "attributes" + ], + "properties": { + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "properties": { + "sectionName": { + "type": "string" + } + }, + "required": [ + "sectionName" + ] + } + } + } + } + }, + "ConfigSectionPatch": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "type", + "attributes" + ], + "properties": { + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "properties": { + "sectionName": { + "type": "string" + } + } + } + } + } + } + }, + "ConfigSectionPatchMultiple": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "properties": { + "sectionName": { + "type": "string" + } + } + } + } + } + } + } + }, + "ConfigSectionDeleteMultiple": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "configSection" + } + } + } + } + } + }, + "ConfigSectionResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configsections/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "required": [ + "sectionName" + ], + "properties": { + "sectionName": { + "type": "string" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configsections/1" + } + } + } + } + } + } + }, + "ConfigSectionSingleResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configsections/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "required": [ + "sectionName" + ], + "properties": { + "sectionName": { + "type": "string" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configsections/1" + } + } + } + } + } + } + }, + "ConfigSectionPostPatchResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configsections/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "required": [ + "sectionName" + ], + "properties": { + "sectionName": { + "type": "string" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configsections/1" + } + } + } + } + } + } + }, + "ConfigSectionListResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "meta", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self", + "first", + "last", + "next", + "prev" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configsections?page[size]=25" + }, + "first": { + "type": "string", + "default": "/api/v2/ui/configsections?page[size]=25" + }, + "last": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/configsections?page[size]=25&page[before]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + }, + "next": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/configsections?page[size]=25&page[after]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + }, + "prev": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/configsections?page[size]=25&page[before]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + } + } + }, + "meta": { + "type": "object", + "required": [ + "page" + ], + "properties": { + "page": { + "type": "object", + "required": [ + "total_elements" + ], + "properties": { + "total_elements": { + "type": "integer" + } + } + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "configSection" + }, + "attributes": { + "type": "object", + "required": [ + "sectionName" + ], + "properties": { + "sectionName": { + "type": "string" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/configsections/1" + } + } + } + } + } + } + } + }, + "ConfigSectionCountResponse": { + "type": "object", + "required": [ + "jsonapi", + "meta", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "meta": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "description": "Number of objects matching the given filters" + }, + "total_count": { + "type": "integer", + "description": "Number of objects without any filter applied, only present when `include_total=true` was requested" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object" + }, + "maxItems": 0, + "description": "Always empty: the count is reported under meta." + } + } + }, + "Token": { + "type": "object", + "required": [ + "token", + "expires" + ], + "properties": { + "token": { + "type": "string" + }, + "expires": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "TokenRequest": { + "type": "array", + "items": { + "type": "string", + "example": "role.all" + } + }, + "ObjectRequest": { + "type": "object", + "properties": { + "expand": { + "type": "string" + }, + "expires": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "ObjectListRequest": { + "type": "object", + "properties": { + "expand": { + "type": "string" + }, + "filter": { + "type": "array", + "items": { + "type": "string", + "example": "" + } + } + }, + "additionalProperties": false + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "description": "JWT Authorization header using the Bearer scheme.", + "scheme": "bearer", + "bearerFormat": "JWT", + "scopes": [ + "permConfigRead", + "permConfigSectionRead", + "permConfigUpdate" + ] + }, + "basicAuth": { + "type": "http", + "description": "Basic Authorization header.", + "scheme": "basic" + } + } + } +} diff --git a/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json b/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json new file mode 100644 index 000000000..62fca0036 --- /dev/null +++ b/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json @@ -0,0 +1,3642 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Hashtopolis API", + "version": "v2" + }, + "servers": [ + { + "url": "/" + } + ], + "paths": { + "/api/v2/ui/crackertypes": { + "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" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/CrackerBinaryTypeListResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permCrackerBinaryTypeRead" + ] + ] + } + ], + "description": "GET many request to retrieve multiple objects.", + "parameters": [ + { + "name": "page[after]", + "in": "path", + "schema": { + "type": "string", + "format": "byte" + }, + "example": "eyJwcmltYXJ5Ijp7ImNyYWNrZXJCaW5hcnlUeXBlSWQiOiAxMjN9fQ==", + "description": "Pointer to paginate to retrieve the data after the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting.\n \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}`\n \n\nExample: `{\"primary\":{\"crackerBinaryTypeId\": 123}}` -> `eyJwcmltYXJ5Ijp7ImNyYWNrZXJCaW5hcnlUeXBlSWQiOiAxMjN9fQ==`" + }, + { + "name": "page[before]", + "in": "path", + "schema": { + "type": "string", + "format": "byte" + }, + "example": "eyJwcmltYXJ5Ijp7ImNyYWNrZXJCaW5hcnlUeXBlSWQiOiAxMjN9fQ==", + "description": "Pointer to paginate to retrieve the data before the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting.\n \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}`\n \n\nExample: `{\"primary\":{\"crackerBinaryTypeId\": 123}}` -> `eyJwcmltYXJ5Ijp7ImNyYWNrZXJCaW5hcnlUeXBlSWQiOiAxMjN9fQ==`" + }, + { + "name": "page[size]", + "in": "path", + "schema": { + "type": "integer", + "format": "int32" + }, + "example": 100, + "description": "Amout of data to retrieve inside a single page" + }, + { + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": "Filters results using a query. Every key is an attribute name optionally suffixed with a comparison operator, e.g. `filter[crackerBinaryTypeId__gt]=200`.", + "example": { + "crackerBinaryTypeId__gt": "200" + } + }, + { + "name": "include", + "in": "query", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "crackerVersions", + "tasks" + ] + } + }, + "description": "Relationships to include in the response, comma seperated. Possible options: crackerVersions, tasks", + "example": [ + "crackerVersions", + "tasks" + ] + } + ] + }, + "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" + } + } + } + }, + "201": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/CrackerBinaryTypePostPatchResponse" + } + } + } + }, + "409": { + "description": "Resource already exists", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permCrackerBinaryTypeCreate" + ] + ] + } + ], + "description": "POST request to create a new object. The request must contain the resource record as data with the attributes of the new object.To add relationships, a relationships object can be added with the resource records of the relations that are part of this object.", + "requestBody": { + "required": true, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/CrackerBinaryTypeCreate" + } + } + } + }, + "parameters": [] + }, + "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": "successfully updated" + }, + "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 attributes of a single object.", + "requestBody": { + "required": true, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/CrackerBinaryTypePatchMultiple" + } + } + } + }, + "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/CrackerBinaryTypeDeleteMultiple" + } + } + } + }, + "parameters": [] + } + }, + "/api/v2/ui/crackertypes/count": { + "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" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/CrackerBinaryTypeCountResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permCrackerBinaryTypeRead" + ] + ] + } + ], + "description": "GET many request to retrieve multiple objects.", + "parameters": [ + { + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": "Filters results using a query. Every key is an attribute name optionally suffixed with a comparison operator, e.g. `filter[crackerBinaryTypeId__gt]=200`.", + "example": { + "crackerBinaryTypeId__gt": "200" + } + }, + { + "name": "include_total", + "in": "query", + "schema": { + "type": "boolean" + }, + "example": true, + "description": "Also report the number of objects without any filter applied, as `meta.total_count`" + } + ] + } + }, + "/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}": { + "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/CrackerBinaryTypeRelationTasksGetResponse" + } + } + } + } + }, + "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:tasks}": { + "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/CrackerBinaryTypeRelationTasks" + } + } + } + }, + "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/CrackerBinaryTypeRelationTasks" + } + } + } + }, + "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/CrackerBinaryTypeRelationTasks" + } + } + } + }, + "parameters": [] + } + }, + "/api/v2/ui/crackertypes/{id:[0-9]+}": { + "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 to retrieve a single object.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "example": 10 + } + }, + { + "name": "include", + "in": "query", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "crackerVersions", + "tasks" + ] + } + }, + "description": "Relationships to include in the response, comma seperated. Possible options: crackerVersions, tasks", + "example": [ + "crackerVersions", + "tasks" + ] + } + ] + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/CrackerBinaryTypePostPatchResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permCrackerBinaryTypeUpdate" + ] + ] + } + ], + "description": "PATCH request to update attributes of a single object.", + "requestBody": { + "required": true, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/CrackerBinaryTypePatch" + } + } + } + }, + "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" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "204": { + "description": "successfully deleted" + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permCrackerBinaryTypeDelete" + ] + ] + } + ], + "description": "", + "parameters": [] + } + }, + "/api/v2/auth/token": { + "post": { + "tags": [ + "Login" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Token" + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] + } + }, + "/api/v2/helper/importFile": { + "post": { + "parameters": [ + { + "name": "Upload-Metadata", + "in": "header", + "required": "true", + "schema": { + "type": "string", + "pattern": "^([a-zA-Z0-9]+ [A-Za-z0-9+/=]+)(,[a-zA-Z0-9]+ [A-Za-z0-9+/=]+)*$" + }, + "example": "filename ZXhhbXBsZS50eHQ=", + "description": " The Upload-Metadata header contains one or more comma-separated key-value pairs.\n Each pair is formatted as ` `, where:\n - `key` is a string without spaces.\n - `value` is base64-encoded" + }, + { + "name": "Upload-Length", + "in": "header", + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 10000, + "description": "The total size of the upload in bytes. Must be a positive integer.\n Required if `Upload-Defer-Length` is not set." + }, + { + "name": "Upload-Defer-Length", + "in": "header", + "schema": { + "type": "integer" + }, + "example": 1, + "description": "Indicates that the upload length is not known at creation time.\n Value must be `1`. If present, `Upload-Length` must be omitted." + } + ], + "responses": { + "201": { + "description": "Upload created", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Location": { + "description": "Location of the file where the user can push to.", + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}": { + "patch": { + "parameters": [ + { + "name": "Upload-Offset", + "in": "header", + "required": "true", + "schema": { + "type": "integer" + }, + "example": 512, + "description": " The Upload-Offset header's value MUST be equal to the current offset of the resource" + }, + { + "name": "Content-Type", + "in": "header", + "required": "true", + "schema": { + "type": "string", + "enum": [ + "application/offset+octet-stream" + ] + } + } + ], + "requestBody": [ + { + "required": "true", + "description": "The binary data to push to the file", + "content": { + "application/offset+octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + ], + "responses": { + "204": { + "description": "Chunk accepted", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Upload-Offset": { + "description": "The new offset after the chunk is accepted. Indicates how many bytes were received so far.", + "schema": { + "type": "integer" + } + } + } + } + } + }, + "head": { + "responses": { + "200": { + "description": "successful request", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Upload-Offset": { + "description": "Number of bytes already received", + "schema": { + "type": "integer" + } + }, + "Upload-Length": { + "description": "Total upload length (if known)", + "schema": { + "type": "integer" + } + }, + "Upload-Defer-Length": { + "description": "Indicates deferred upload length (if applicable)", + "schema": { + "type": "string" + } + }, + "Upload-Metadata": { + "description": "Original metadata sent during creation", + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ErrorResponse": { + "type": "object", + "required": [ + "status" + ], + "description": "RFC 7807 problem document", + "properties": { + "title": { + "type": "string", + "example": "No access to this object!" + }, + "type": { + "type": "string", + "example": "about:blank" + }, + "status": { + "type": "integer", + "example": 400 + } + } + }, + "CrackerBinaryTypeCreate": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "type", + "attributes" + ], + "properties": { + "type": { + "type": "string", + "const": "crackerBinaryType" + }, + "attributes": { + "type": "object", + "properties": { + "typeName": { + "type": "string" + } + }, + "required": [ + "typeName" + ] + } + } + } + } + }, + "CrackerBinaryTypePatch": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "type", + "attributes" + ], + "properties": { + "type": { + "type": "string", + "const": "crackerBinaryType" + }, + "attributes": { + "type": "object", + "properties": { + "isChunkingAvailable": { + "type": [ + "boolean", + "null" + ] + }, + "typeName": { + "type": "string" + } + } + } + } + } + } + }, + "CrackerBinaryTypePatchMultiple": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "crackerBinaryType" + }, + "attributes": { + "type": "object", + "properties": { + "isChunkingAvailable": { + "type": [ + "boolean", + "null" + ] + }, + "typeName": { + "type": "string" + } + } + } + } + } + } + } + }, + "CrackerBinaryTypeDeleteMultiple": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "crackerBinaryType" + } + } + } + } + } + }, + "CrackerBinaryTypeResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links", + "relationships" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "crackerBinaryType" + }, + "attributes": { + "type": "object", + "required": [ + "typeName", + "isChunkingAvailable" + ], + "properties": { + "typeName": { + "type": "string" + }, + "isChunkingAvailable": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/1" + } + } + }, + "relationships": { + "type": "object", + "required": [ + "crackerVersions", + "tasks" + ], + "properties": { + "crackerVersions": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/relationships/crackerVersions" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackertypes/crackerVersions" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "crackerBinary" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + }, + "tasks": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/relationships/tasks" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackertypes/tasks" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "task" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + } + } + } + } + }, + "included": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "crackerBinary" + }, + "attributes": { + "type": "object", + "required": [ + "crackerBinaryTypeId", + "version", + "downloadUrl", + "binaryName" + ], + "properties": { + "crackerBinaryTypeId": { + "type": "integer" + }, + "version": { + "type": "string" + }, + "downloadUrl": { + "type": "string" + }, + "binaryName": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "task" + }, + "attributes": { + "type": "object", + "required": [ + "taskName", + "attackCmd", + "chunkTime", + "statusTimer", + "keyspace", + "keyspaceProgress", + "priority", + "maxAgents", + "color", + "isSmall", + "isCpuTask", + "useNewBench", + "skipKeyspace", + "crackerBinaryId", + "crackerBinaryTypeId", + "taskWrapperId", + "isArchived", + "notes", + "staticChunks", + "chunkSize", + "forcePipe", + "preprocessorId", + "preprocessorCommand" + ], + "properties": { + "taskName": { + "type": "string" + }, + "attackCmd": { + "type": "string" + }, + "chunkTime": { + "type": "integer" + }, + "statusTimer": { + "type": "integer" + }, + "keyspace": { + "type": "integer", + "format": "int64" + }, + "keyspaceProgress": { + "type": "integer", + "format": "int64" + }, + "priority": { + "type": "integer" + }, + "maxAgents": { + "type": "integer" + }, + "color": { + "type": [ + "string", + "null" + ] + }, + "isSmall": { + "type": "boolean" + }, + "isCpuTask": { + "type": "boolean" + }, + "useNewBench": { + "type": "boolean" + }, + "skipKeyspace": { + "type": "integer", + "format": "int64" + }, + "crackerBinaryId": { + "type": "integer" + }, + "crackerBinaryTypeId": { + "type": [ + "integer", + "null" + ] + }, + "taskWrapperId": { + "type": "integer" + }, + "isArchived": { + "type": "boolean" + }, + "notes": { + "type": "string" + }, + "staticChunks": { + "type": "integer" + }, + "chunkSize": { + "type": "integer", + "format": "int64" + }, + "forcePipe": { + "type": "boolean" + }, + "preprocessorId": { + "type": "integer" + }, + "preprocessorCommand": { + "type": "string" + } + } + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + } + } + } + }, + "CrackerBinaryTypeSingleResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links", + "relationships" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "crackerBinaryType" + }, + "attributes": { + "type": "object", + "required": [ + "typeName", + "isChunkingAvailable" + ], + "properties": { + "typeName": { + "type": "string" + }, + "isChunkingAvailable": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/1" + } + } + }, + "relationships": { + "type": "object", + "required": [ + "crackerVersions", + "tasks" + ], + "properties": { + "crackerVersions": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/relationships/crackerVersions" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackertypes/crackerVersions" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "crackerBinary" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + }, + "tasks": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/relationships/tasks" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackertypes/tasks" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "task" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + } + } + } + } + }, + "included": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "crackerBinary" + }, + "attributes": { + "type": "object", + "required": [ + "crackerBinaryTypeId", + "version", + "downloadUrl", + "binaryName" + ], + "properties": { + "crackerBinaryTypeId": { + "type": "integer" + }, + "version": { + "type": "string" + }, + "downloadUrl": { + "type": "string" + }, + "binaryName": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "task" + }, + "attributes": { + "type": "object", + "required": [ + "taskName", + "attackCmd", + "chunkTime", + "statusTimer", + "keyspace", + "keyspaceProgress", + "priority", + "maxAgents", + "color", + "isSmall", + "isCpuTask", + "useNewBench", + "skipKeyspace", + "crackerBinaryId", + "crackerBinaryTypeId", + "taskWrapperId", + "isArchived", + "notes", + "staticChunks", + "chunkSize", + "forcePipe", + "preprocessorId", + "preprocessorCommand" + ], + "properties": { + "taskName": { + "type": "string" + }, + "attackCmd": { + "type": "string" + }, + "chunkTime": { + "type": "integer" + }, + "statusTimer": { + "type": "integer" + }, + "keyspace": { + "type": "integer", + "format": "int64" + }, + "keyspaceProgress": { + "type": "integer", + "format": "int64" + }, + "priority": { + "type": "integer" + }, + "maxAgents": { + "type": "integer" + }, + "color": { + "type": [ + "string", + "null" + ] + }, + "isSmall": { + "type": "boolean" + }, + "isCpuTask": { + "type": "boolean" + }, + "useNewBench": { + "type": "boolean" + }, + "skipKeyspace": { + "type": "integer", + "format": "int64" + }, + "crackerBinaryId": { + "type": "integer" + }, + "crackerBinaryTypeId": { + "type": [ + "integer", + "null" + ] + }, + "taskWrapperId": { + "type": "integer" + }, + "isArchived": { + "type": "boolean" + }, + "notes": { + "type": "string" + }, + "staticChunks": { + "type": "integer" + }, + "chunkSize": { + "type": "integer", + "format": "int64" + }, + "forcePipe": { + "type": "boolean" + }, + "preprocessorId": { + "type": "integer" + }, + "preprocessorCommand": { + "type": "string" + } + } + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + } + } + } + }, + "CrackerBinaryTypePostPatchResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links", + "relationships" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "crackerBinaryType" + }, + "attributes": { + "type": "object", + "required": [ + "typeName", + "isChunkingAvailable" + ], + "properties": { + "typeName": { + "type": "string" + }, + "isChunkingAvailable": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/1" + } + } + }, + "relationships": { + "type": "object", + "required": [ + "crackerVersions", + "tasks" + ], + "properties": { + "crackerVersions": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/relationships/crackerVersions" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackertypes/crackerVersions" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "crackerBinary" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + }, + "tasks": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/relationships/tasks" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackertypes/tasks" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "task" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + } + } + } + } + }, + "included": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "crackerBinary" + }, + "attributes": { + "type": "object", + "required": [ + "crackerBinaryTypeId", + "version", + "downloadUrl", + "binaryName" + ], + "properties": { + "crackerBinaryTypeId": { + "type": "integer" + }, + "version": { + "type": "string" + }, + "downloadUrl": { + "type": "string" + }, + "binaryName": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "task" + }, + "attributes": { + "type": "object", + "required": [ + "taskName", + "attackCmd", + "chunkTime", + "statusTimer", + "keyspace", + "keyspaceProgress", + "priority", + "maxAgents", + "color", + "isSmall", + "isCpuTask", + "useNewBench", + "skipKeyspace", + "crackerBinaryId", + "crackerBinaryTypeId", + "taskWrapperId", + "isArchived", + "notes", + "staticChunks", + "chunkSize", + "forcePipe", + "preprocessorId", + "preprocessorCommand" + ], + "properties": { + "taskName": { + "type": "string" + }, + "attackCmd": { + "type": "string" + }, + "chunkTime": { + "type": "integer" + }, + "statusTimer": { + "type": "integer" + }, + "keyspace": { + "type": "integer", + "format": "int64" + }, + "keyspaceProgress": { + "type": "integer", + "format": "int64" + }, + "priority": { + "type": "integer" + }, + "maxAgents": { + "type": "integer" + }, + "color": { + "type": [ + "string", + "null" + ] + }, + "isSmall": { + "type": "boolean" + }, + "isCpuTask": { + "type": "boolean" + }, + "useNewBench": { + "type": "boolean" + }, + "skipKeyspace": { + "type": "integer", + "format": "int64" + }, + "crackerBinaryId": { + "type": "integer" + }, + "crackerBinaryTypeId": { + "type": [ + "integer", + "null" + ] + }, + "taskWrapperId": { + "type": "integer" + }, + "isArchived": { + "type": "boolean" + }, + "notes": { + "type": "string" + }, + "staticChunks": { + "type": "integer" + }, + "chunkSize": { + "type": "integer", + "format": "int64" + }, + "forcePipe": { + "type": "boolean" + }, + "preprocessorId": { + "type": "integer" + }, + "preprocessorCommand": { + "type": "string" + } + } + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + } + } + } + }, + "CrackerBinaryTypeListResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "meta", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self", + "first", + "last", + "next", + "prev" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes?page[size]=25" + }, + "first": { + "type": "string", + "default": "/api/v2/ui/crackertypes?page[size]=25" + }, + "last": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/crackertypes?page[size]=25&page[before]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + }, + "next": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/crackertypes?page[size]=25&page[after]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + }, + "prev": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/crackertypes?page[size]=25&page[before]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + } + } + }, + "meta": { + "type": "object", + "required": [ + "page" + ], + "properties": { + "page": { + "type": "object", + "required": [ + "total_elements" + ], + "properties": { + "total_elements": { + "type": "integer" + } + } + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links", + "relationships" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "crackerBinaryType" + }, + "attributes": { + "type": "object", + "required": [ + "typeName", + "isChunkingAvailable" + ], + "properties": { + "typeName": { + "type": "string" + }, + "isChunkingAvailable": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/1" + } + } + }, + "relationships": { + "type": "object", + "required": [ + "crackerVersions", + "tasks" + ], + "properties": { + "crackerVersions": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/relationships/crackerVersions" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackertypes/crackerVersions" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "crackerBinary" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + }, + "tasks": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackertypes/relationships/tasks" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackertypes/tasks" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "task" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + } + } + } + } + } + }, + "included": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "crackerBinary" + }, + "attributes": { + "type": "object", + "required": [ + "crackerBinaryTypeId", + "version", + "downloadUrl", + "binaryName" + ], + "properties": { + "crackerBinaryTypeId": { + "type": "integer" + }, + "version": { + "type": "string" + }, + "downloadUrl": { + "type": "string" + }, + "binaryName": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "task" + }, + "attributes": { + "type": "object", + "required": [ + "taskName", + "attackCmd", + "chunkTime", + "statusTimer", + "keyspace", + "keyspaceProgress", + "priority", + "maxAgents", + "color", + "isSmall", + "isCpuTask", + "useNewBench", + "skipKeyspace", + "crackerBinaryId", + "crackerBinaryTypeId", + "taskWrapperId", + "isArchived", + "notes", + "staticChunks", + "chunkSize", + "forcePipe", + "preprocessorId", + "preprocessorCommand" + ], + "properties": { + "taskName": { + "type": "string" + }, + "attackCmd": { + "type": "string" + }, + "chunkTime": { + "type": "integer" + }, + "statusTimer": { + "type": "integer" + }, + "keyspace": { + "type": "integer", + "format": "int64" + }, + "keyspaceProgress": { + "type": "integer", + "format": "int64" + }, + "priority": { + "type": "integer" + }, + "maxAgents": { + "type": "integer" + }, + "color": { + "type": [ + "string", + "null" + ] + }, + "isSmall": { + "type": "boolean" + }, + "isCpuTask": { + "type": "boolean" + }, + "useNewBench": { + "type": "boolean" + }, + "skipKeyspace": { + "type": "integer", + "format": "int64" + }, + "crackerBinaryId": { + "type": "integer" + }, + "crackerBinaryTypeId": { + "type": [ + "integer", + "null" + ] + }, + "taskWrapperId": { + "type": "integer" + }, + "isArchived": { + "type": "boolean" + }, + "notes": { + "type": "string" + }, + "staticChunks": { + "type": "integer" + }, + "chunkSize": { + "type": "integer", + "format": "int64" + }, + "forcePipe": { + "type": "boolean" + }, + "preprocessorId": { + "type": "integer" + }, + "preprocessorCommand": { + "type": "string" + } + } + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + } + } + } + }, + "CrackerBinaryTypeCountResponse": { + "type": "object", + "required": [ + "jsonapi", + "meta", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "meta": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "description": "Number of objects matching the given filters" + }, + "total_count": { + "type": "integer", + "description": "Number of objects without any filter applied, only present when `include_total=true` was requested" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object" + }, + "maxItems": 0, + "description": "Always empty: the count is reported under meta." + } + } + }, + "CrackerBinaryTypeRelationCrackerVersions": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "crackerVersions" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + }, + "CrackerBinaryTypeRelationCrackerVersionsGetResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "crackerVersions" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + }, + "CrackerBinaryTypeRelationTasks": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "tasks" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + }, + "CrackerBinaryTypeRelationTasksGetResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "tasks" + }, + "id": { + "type": "integer", + "example": 1 + } + } + } + } + } + }, + "Token": { + "type": "object", + "required": [ + "token", + "expires" + ], + "properties": { + "token": { + "type": "string" + }, + "expires": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "TokenRequest": { + "type": "array", + "items": { + "type": "string", + "example": "role.all" + } + }, + "ObjectRequest": { + "type": "object", + "properties": { + "expand": { + "type": "string" + }, + "expires": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "ObjectListRequest": { + "type": "object", + "properties": { + "expand": { + "type": "string" + }, + "filter": { + "type": "array", + "items": { + "type": "string", + "example": "" + } + } + }, + "additionalProperties": false + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "description": "JWT Authorization header using the Bearer scheme.", + "scheme": "bearer", + "bearerFormat": "JWT", + "scopes": [ + "permCrackerBinaryTypeCreate", + "permCrackerBinaryTypeDelete", + "permCrackerBinaryTypeRead", + "permCrackerBinaryTypeUpdate" + ] + }, + "basicAuth": { + "type": "http", + "description": "Basic Authorization header.", + "scheme": "basic" + } + } + } +} diff --git a/ci/phpunit/fixtures/openapi/hashtype.spec.json b/ci/phpunit/fixtures/openapi/hashtype.spec.json new file mode 100644 index 000000000..83f876220 --- /dev/null +++ b/ci/phpunit/fixtures/openapi/hashtype.spec.json @@ -0,0 +1,1628 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Hashtopolis API", + "version": "v2" + }, + "servers": [ + { + "url": "/" + } + ], + "paths": { + "/api/v2/ui/hashtypes": { + "get": { + "tags": [ + "HashTypes" + ], + "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" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/HashTypeListResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permHashTypeRead" + ] + ] + } + ], + "description": "GET many request to retrieve multiple objects.", + "parameters": [ + { + "name": "page[after]", + "in": "path", + "schema": { + "type": "string", + "format": "byte" + }, + "example": "eyJwcmltYXJ5Ijp7Imhhc2hUeXBlSWQiOiAxMjN9fQ==", + "description": "Pointer to paginate to retrieve the data after the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting.\n \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}`\n \n\nExample: `{\"primary\":{\"hashTypeId\": 123}}` -> `eyJwcmltYXJ5Ijp7Imhhc2hUeXBlSWQiOiAxMjN9fQ==`" + }, + { + "name": "page[before]", + "in": "path", + "schema": { + "type": "string", + "format": "byte" + }, + "example": "eyJwcmltYXJ5Ijp7Imhhc2hUeXBlSWQiOiAxMjN9fQ==", + "description": "Pointer to paginate to retrieve the data before the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting.\n \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}`\n \n\nExample: `{\"primary\":{\"hashTypeId\": 123}}` -> `eyJwcmltYXJ5Ijp7Imhhc2hUeXBlSWQiOiAxMjN9fQ==`" + }, + { + "name": "page[size]", + "in": "path", + "schema": { + "type": "integer", + "format": "int32" + }, + "example": 100, + "description": "Amout of data to retrieve inside a single page" + }, + { + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": "Filters results using a query. Every key is an attribute name optionally suffixed with a comparison operator, e.g. `filter[hashTypeId__gt]=200`.", + "example": { + "hashTypeId__gt": "200" + } + }, + { + "name": "include", + "in": "query", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Relationships to include in the response, comma seperated. Possible options: " + } + ] + }, + "post": { + "tags": [ + "HashTypes" + ], + "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" + } + } + } + }, + "201": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/HashTypePostPatchResponse" + } + } + } + }, + "409": { + "description": "Resource already exists", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permHashTypeCreate" + ] + ] + } + ], + "description": "POST request to create a new object. The request must contain the resource record as data with the attributes of the new object.To add relationships, a relationships object can be added with the resource records of the relations that are part of this object.", + "requestBody": { + "required": true, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/HashTypeCreate" + } + } + } + }, + "parameters": [] + }, + "patch": { + "tags": [ + "HashTypes" + ], + "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 updated" + }, + "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": [ + [ + "permHashTypeUpdate" + ] + ] + } + ], + "description": "PATCH request to update attributes of a single object.", + "requestBody": { + "required": true, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/HashTypePatchMultiple" + } + } + } + }, + "parameters": [] + }, + "delete": { + "tags": [ + "HashTypes" + ], + "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": [ + [ + "permHashTypeDelete" + ] + ] + } + ], + "description": "", + "requestBody": { + "required": true, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/HashTypeDeleteMultiple" + } + } + } + }, + "parameters": [] + } + }, + "/api/v2/ui/hashtypes/count": { + "get": { + "tags": [ + "HashTypes" + ], + "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" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/HashTypeCountResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permHashTypeRead" + ] + ] + } + ], + "description": "GET many request to retrieve multiple objects.", + "parameters": [ + { + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": "Filters results using a query. Every key is an attribute name optionally suffixed with a comparison operator, e.g. `filter[hashTypeId__gt]=200`.", + "example": { + "hashTypeId__gt": "200" + } + }, + { + "name": "include_total", + "in": "query", + "schema": { + "type": "boolean" + }, + "example": true, + "description": "Also report the number of objects without any filter applied, as `meta.total_count`" + } + ] + } + }, + "/api/v2/ui/hashtypes/{id:[0-9]+}": { + "get": { + "tags": [ + "HashTypes" + ], + "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/HashTypeResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permHashTypeRead" + ] + ] + } + ], + "description": "GET request to retrieve a single object.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "example": 10 + } + }, + { + "name": "include", + "in": "query", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Relationships to include in the response, comma seperated. Possible options: " + } + ] + }, + "patch": { + "tags": [ + "HashTypes" + ], + "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" + } + } + } + }, + "409": { + "description": "Resource already exists", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "successful operation", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/HashTypePostPatchResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permHashTypeUpdate" + ] + ] + } + ], + "description": "PATCH request to update attributes of a single object.", + "requestBody": { + "required": true, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/HashTypePatch" + } + } + } + }, + "parameters": [] + }, + "delete": { + "tags": [ + "HashTypes" + ], + "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" + } + } + } + }, + "204": { + "description": "successfully deleted" + } + }, + "security": [ + { + "bearerAuth": [ + [ + "permHashTypeDelete" + ] + ] + } + ], + "description": "", + "parameters": [] + } + }, + "/api/v2/auth/token": { + "post": { + "tags": [ + "Login" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Token" + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] + } + }, + "/api/v2/helper/importFile": { + "post": { + "parameters": [ + { + "name": "Upload-Metadata", + "in": "header", + "required": "true", + "schema": { + "type": "string", + "pattern": "^([a-zA-Z0-9]+ [A-Za-z0-9+/=]+)(,[a-zA-Z0-9]+ [A-Za-z0-9+/=]+)*$" + }, + "example": "filename ZXhhbXBsZS50eHQ=", + "description": " The Upload-Metadata header contains one or more comma-separated key-value pairs.\n Each pair is formatted as ` `, where:\n - `key` is a string without spaces.\n - `value` is base64-encoded" + }, + { + "name": "Upload-Length", + "in": "header", + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 10000, + "description": "The total size of the upload in bytes. Must be a positive integer.\n Required if `Upload-Defer-Length` is not set." + }, + { + "name": "Upload-Defer-Length", + "in": "header", + "schema": { + "type": "integer" + }, + "example": 1, + "description": "Indicates that the upload length is not known at creation time.\n Value must be `1`. If present, `Upload-Length` must be omitted." + } + ], + "responses": { + "201": { + "description": "Upload created", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Location": { + "description": "Location of the file where the user can push to.", + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}": { + "patch": { + "parameters": [ + { + "name": "Upload-Offset", + "in": "header", + "required": "true", + "schema": { + "type": "integer" + }, + "example": 512, + "description": " The Upload-Offset header's value MUST be equal to the current offset of the resource" + }, + { + "name": "Content-Type", + "in": "header", + "required": "true", + "schema": { + "type": "string", + "enum": [ + "application/offset+octet-stream" + ] + } + } + ], + "requestBody": [ + { + "required": "true", + "description": "The binary data to push to the file", + "content": { + "application/offset+octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + ], + "responses": { + "204": { + "description": "Chunk accepted", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Upload-Offset": { + "description": "The new offset after the chunk is accepted. Indicates how many bytes were received so far.", + "schema": { + "type": "integer" + } + } + } + } + } + }, + "head": { + "responses": { + "200": { + "description": "successful request", + "headers": { + "Tus-Resumable": { + "description": "Indicates the TUS version the server supports.\n Must always be set to `1.0.0` in compliant servers.", + "schema": { + "type": "string", + "enum": "enum: ['1.0.0']" + } + }, + "Upload-Offset": { + "description": "Number of bytes already received", + "schema": { + "type": "integer" + } + }, + "Upload-Length": { + "description": "Total upload length (if known)", + "schema": { + "type": "integer" + } + }, + "Upload-Defer-Length": { + "description": "Indicates deferred upload length (if applicable)", + "schema": { + "type": "string" + } + }, + "Upload-Metadata": { + "description": "Original metadata sent during creation", + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ErrorResponse": { + "type": "object", + "required": [ + "status" + ], + "description": "RFC 7807 problem document", + "properties": { + "title": { + "type": "string", + "example": "No access to this object!" + }, + "type": { + "type": "string", + "example": "about:blank" + }, + "status": { + "type": "integer", + "example": 400 + } + } + }, + "HashTypeCreate": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "type", + "attributes" + ], + "properties": { + "type": { + "type": "string", + "const": "hashType" + }, + "attributes": { + "type": "object", + "properties": { + "hashTypeId": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "isSalted": { + "type": "boolean" + }, + "isSlowHash": { + "type": "boolean" + } + }, + "required": [ + "hashTypeId", + "description", + "isSalted", + "isSlowHash" + ] + } + } + } + } + }, + "HashTypePatch": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "type", + "attributes" + ], + "properties": { + "type": { + "type": "string", + "const": "hashType" + }, + "attributes": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "isSalted": { + "type": "boolean" + }, + "isSlowHash": { + "type": "boolean" + } + } + } + } + } + } + }, + "HashTypePatchMultiple": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "hashType" + }, + "attributes": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "isSalted": { + "type": "boolean" + }, + "isSlowHash": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "HashTypeDeleteMultiple": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "hashType" + } + } + } + } + } + }, + "HashTypeResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/hashtypes/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "hashType" + }, + "attributes": { + "type": "object", + "required": [ + "description", + "isSalted", + "isSlowHash" + ], + "properties": { + "description": { + "type": "string" + }, + "isSalted": { + "type": "boolean" + }, + "isSlowHash": { + "type": "boolean" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/hashtypes/1" + } + } + } + } + } + } + }, + "HashTypeSingleResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/hashtypes/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "hashType" + }, + "attributes": { + "type": "object", + "required": [ + "description", + "isSalted", + "isSlowHash" + ], + "properties": { + "description": { + "type": "string" + }, + "isSalted": { + "type": "boolean" + }, + "isSlowHash": { + "type": "boolean" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/hashtypes/1" + } + } + } + } + } + } + }, + "HashTypePostPatchResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/hashtypes/1" + } + } + }, + "data": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "hashType" + }, + "attributes": { + "type": "object", + "required": [ + "description", + "isSalted", + "isSlowHash" + ], + "properties": { + "description": { + "type": "string" + }, + "isSalted": { + "type": "boolean" + }, + "isSlowHash": { + "type": "boolean" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/hashtypes/1" + } + } + } + } + } + } + }, + "HashTypeListResponse": { + "type": "object", + "required": [ + "jsonapi", + "links", + "meta", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "links": { + "type": "object", + "required": [ + "self", + "first", + "last", + "next", + "prev" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/hashtypes?page[size]=25" + }, + "first": { + "type": "string", + "default": "/api/v2/ui/hashtypes?page[size]=25" + }, + "last": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/hashtypes?page[size]=25&page[before]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + }, + "next": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/hashtypes?page[size]=25&page[after]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + }, + "prev": { + "type": [ + "string", + "null" + ], + "default": "/api/v2/ui/hashtypes?page[size]=25&page[before]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" + } + } + }, + "meta": { + "type": "object", + "required": [ + "page" + ], + "properties": { + "page": { + "type": "object", + "required": [ + "total_elements" + ], + "properties": { + "total_elements": { + "type": "integer" + } + } + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "attributes", + "links" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "hashType" + }, + "attributes": { + "type": "object", + "required": [ + "description", + "isSalted", + "isSlowHash" + ], + "properties": { + "description": { + "type": "string" + }, + "isSalted": { + "type": "boolean" + }, + "isSlowHash": { + "type": "boolean" + } + } + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/hashtypes/1" + } + } + } + } + } + } + } + }, + "HashTypeCountResponse": { + "type": "object", + "required": [ + "jsonapi", + "meta", + "data" + ], + "properties": { + "jsonapi": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "default": "1.1" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + ] + } + } + }, + "meta": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "description": "Number of objects matching the given filters" + }, + "total_count": { + "type": "integer", + "description": "Number of objects without any filter applied, only present when `include_total=true` was requested" + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object" + }, + "maxItems": 0, + "description": "Always empty: the count is reported under meta." + } + } + }, + "Token": { + "type": "object", + "required": [ + "token", + "expires" + ], + "properties": { + "token": { + "type": "string" + }, + "expires": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "TokenRequest": { + "type": "array", + "items": { + "type": "string", + "example": "role.all" + } + }, + "ObjectRequest": { + "type": "object", + "properties": { + "expand": { + "type": "string" + }, + "expires": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "ObjectListRequest": { + "type": "object", + "properties": { + "expand": { + "type": "string" + }, + "filter": { + "type": "array", + "items": { + "type": "string", + "example": "" + } + } + }, + "additionalProperties": false + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "description": "JWT Authorization header using the Bearer scheme.", + "scheme": "bearer", + "bearerFormat": "JWT", + "scopes": [ + "permHashTypeCreate", + "permHashTypeDelete", + "permHashTypeRead", + "permHashTypeUpdate" + ] + }, + "basicAuth": { + "type": "http", + "description": "Basic Authorization header.", + "scheme": "basic" + } + } + } +} diff --git a/ci/phpunit/inc/apiv2/openapi/FeatureTypeMapperTest.php b/ci/phpunit/inc/apiv2/openapi/FeatureTypeMapperTest.php new file mode 100644 index 000000000..174071d79 --- /dev/null +++ b/ci/phpunit/inc/apiv2/openapi/FeatureTypeMapperTest.php @@ -0,0 +1,129 @@ +mapper = new FeatureTypeMapper(); + } + + public function testTypeLookupInt(): void { + $this->assertSame( + ["type" => "integer", "type_format" => null, "type_enum" => null, "type_enum_labels" => null, "subtype" => null], + $this->mapper->typeLookup(['type' => 'int', 'choices' => 'unset']) + ); + } + + public function testTypeLookupUint64(): void { + $this->assertSame("integer", $this->mapper->typeLookup(['type' => 'uint64', 'choices' => 'unset'])['type']); + } + + public function testTypeLookupInt64HasFormat(): void { + $result = $this->mapper->typeLookup(['type' => 'int64', 'choices' => 'unset']); + $this->assertSame("integer", $result['type']); + $this->assertSame("int64", $result['type_format']); + } + + public function testTypeLookupBool(): void { + $this->assertSame("boolean", $this->mapper->typeLookup(['type' => 'bool', 'choices' => 'unset'])['type']); + } + + public function testTypeLookupStr(): void { + $this->assertSame("string", $this->mapper->typeLookup(['type' => 'str', 'choices' => 'unset'])['type']); + $this->assertSame("string", $this->mapper->typeLookup(['type' => 'str(256)', 'choices' => 'unset'])['type']); + } + + public function testTypeLookupDictWithSubtype(): void { + $result = $this->mapper->typeLookup(['type' => 'dict', 'subtype' => 'str', 'choices' => 'unset']); + $this->assertSame("object", $result['type']); + $this->assertSame("string", $result['subtype']); + } + + public function testTypeLookupArray(): void { + $result = $this->mapper->typeLookup(['type' => 'array', 'choices' => 'unset']); + $this->assertSame("array", $result['type']); + $this->assertSame("integer", $result['subtype']); + } + + public function testTypeLookupChoices(): void { + $result = $this->mapper->typeLookup(['type' => 'int', 'choices' => [0 => 'Linux', 1 => 'Windows', 2 => 'macOS']]); + $this->assertSame([0, 1, 2], $result['type_enum']); + $this->assertSame(['Linux', 'Windows', 'macOS'], $result['type_enum_labels']); + } + + public function testTypeLookupUnknownTypeThrows(): void { + $this->expectException(HttpErrorException::class); + $this->mapper->typeLookup(['type' => 'blob', 'choices' => 'unset']); + } + + public function testMakePropertiesSimpleString(): void { + $features = [['type' => 'str', 'choices' => 'unset', 'null' => false, 'alias' => 'name', 'pk' => false]]; + $this->assertSame(['name' => ['type' => 'string']], $this->mapper->makeProperties($features)); + } + + public function testMakePropertiesNullableUsesTypeArray(): void { + $features = [['type' => 'int', 'choices' => 'unset', 'null' => true, 'alias' => 'userId', 'pk' => false]]; + $this->assertSame(['userId' => ['type' => ['integer', 'null']]], $this->mapper->makeProperties($features)); + } + + public function testMakePropertiesInt64Format(): void { + $features = [['type' => 'int64', 'choices' => 'unset', 'null' => false, 'alias' => 'lastTime', 'pk' => false]]; + $this->assertSame(['lastTime' => ['type' => 'integer', 'format' => 'int64']], $this->mapper->makeProperties($features)); + } + + public function testMakePropertiesChoicesBecomeOneOfWithConstAndTitle(): void { + $features = [['type' => 'int', 'choices' => [0 => 'Linux', 1 => 'Windows'], 'null' => false, 'alias' => 'os', 'pk' => false]]; + $this->assertSame([ + 'os' => ['oneOf' => [ + ['const' => 0, 'title' => 'Linux', 'type' => 'integer'], + ['const' => 1, 'title' => 'Windows', 'type' => 'integer'], + ]], + ], $this->mapper->makeProperties($features)); + } + + public function testMakePropertiesNullableChoicesAppendNullBranch(): void { + $features = [['type' => 'int', 'choices' => [0 => 'Off', 1 => 'On'], 'null' => true, 'alias' => 'state', 'pk' => false]]; + $properties = $this->mapper->makeProperties($features); + $this->assertSame(['type' => 'null'], end($properties['state']['oneOf'])); + } + + public function testMakePropertiesSkipsPrimaryKeyWhenRequested(): void { + $features = [ + ['type' => 'int', 'choices' => 'unset', 'null' => false, 'alias' => 'id', 'pk' => true], + ['type' => 'str', 'choices' => 'unset', 'null' => false, 'alias' => 'name', 'pk' => false], + ]; + $this->assertSame(['name'], array_keys($this->mapper->makeProperties($features, true))); + $this->assertSame(['id', 'name'], array_keys($this->mapper->makeProperties($features))); + } + + public function testMakePropertiesDictUsesAdditionalProperties(): void { + $features = [['type' => 'dict', 'subtype' => 'int', 'choices' => 'unset', 'null' => false, 'alias' => 'stats', 'pk' => false]]; + $this->assertSame( + ['stats' => ['type' => 'object', 'additionalProperties' => ['type' => 'integer']]], + $this->mapper->makeProperties($features) + ); + } + + public function testMakePropertiesArrayUsesItems(): void { + $features = [['type' => 'array', 'choices' => 'unset', 'null' => false, 'alias' => 'fileIds', 'pk' => false]]; + $this->assertSame( + ['fileIds' => ['type' => 'array', 'items' => ['type' => 'integer']]], + $this->mapper->makeProperties($features) + ); + } + + public function testMapToPropertiesInfersTypesFromSampleValues(): void { + $this->assertSame([ + 'file' => ['type' => 'string', 'default' => 'abc.txt'], + 'size' => ['type' => 'integer', 'default' => 123], + 'ratio' => ['type' => 'number', 'default' => 0.5], + 'ok' => ['type' => 'boolean', 'default' => true], + 'meta' => ['type' => 'object', 'default' => []], + ], $this->mapper->mapToProperties(['file' => 'abc.txt', 'size' => 123, 'ratio' => 0.5, 'ok' => true, 'meta' => []])); + } +} diff --git a/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php b/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php new file mode 100644 index 000000000..e9a089d8b --- /dev/null +++ b/ci/phpunit/inc/apiv2/openapi/FullSpecTest.php @@ -0,0 +1,227 @@ +buildForApiClasses(ApiRegistry::allApiClasses()); + // Round-trip through JSON so stdClass values become arrays and the walk + // below only has to deal with arrays. + self::$sanitized = json_decode( + json_encode((new SpecSanitizer())->sanitize(self::$raw), JSON_THROW_ON_ERROR), + true + ); + } + + public function testSpecsDeclareOpenApi310AndEncodeAsJson(): void { + $this->assertSame('3.1.0', self::$raw['openapi']); + $this->assertSame('3.1.0', self::$sanitized['openapi']); + $this->assertJson(json_encode(self::$raw, JSON_THROW_ON_ERROR)); + } + + public function testExpectedCoverage(): void { + $this->assertGreaterThanOrEqual(160, count(self::$sanitized['paths'])); + $this->assertGreaterThanOrEqual(200, count(self::$sanitized['components']['schemas'])); + } + + public function testAllRefsResolve(): void { + foreach (['raw' => self::$raw, 'sanitized' => self::$sanitized] as $variant => $spec) { + $refs = []; + $this->collectRefs($spec, $refs); + $this->assertNotEmpty($refs); + foreach ($refs as $ref) { + $this->assertStringStartsWith('#/components/schemas/', $ref, "Unexpected ref format in $variant spec: $ref"); + $name = substr($ref, strlen('#/components/schemas/')); + $this->assertArrayHasKey($name, $spec['components']['schemas'], "Dangling ref in $variant spec: $ref"); + } + } + } + + public function testSanitizedSchemasHaveNoBackslashNames(): void { + foreach (array_keys(self::$sanitized['components']['schemas']) as $name) { + $this->assertStringNotContainsString('\\', $name); + } + } + + public function testSanitizedOperationsAreComplete(): void { + $operationIds = []; + foreach (self::$sanitized['paths'] as $path => $pathItem) { + foreach ($pathItem as $method => $operation) { + $this->assertIsArray($operation, "$method $path"); + $this->assertArrayHasKey('operationId', $operation, "$method $path"); + $this->assertArrayHasKey('summary', $operation, "$method $path"); + $this->assertArrayHasKey('security', $operation, "$method $path"); + $this->assertNotEmpty($operation['tags'] ?? [], "$method $path"); + $operationIds[] = $operation['operationId']; + + $has2xx = false; + foreach (array_keys($operation['responses'] ?? []) as $code) { + if (str_starts_with((string)$code, '2')) { + $has2xx = true; + } + } + $this->assertTrue($has2xx, "No 2xx response on $method $path"); + } + } + $this->assertSame($operationIds, array_unique($operationIds), 'operationIds are not unique'); + } + + public function testGlobalTagsAreBuilt(): void { + $this->assertNotEmpty(self::$sanitized['tags']); + $tagNames = array_column(self::$sanitized['tags'], 'name'); + $this->assertContains('Helpers', $tagNames); + $this->assertContains('Login', $tagNames); + } + + public function testAuthTokenEndpointUsesBasicAuth(): void { + $post = self::$sanitized['paths']['/api/v2/auth/token']['post']; + $this->assertSame([['basicAuth' => []]], $post['security']); + /* token.routes.php answers 201 with a plain JSON body, it is not a JSON:API route */ + $this->assertSame('#/components/schemas/Token', $post['responses']['201']['content']['application/json']['schema']['$ref']); + $this->assertArrayNotHasKey('200', $post['responses']); + } + + /** + * Every JSON:API payload is served as application/vnd.api+json and every + * error as an RFC 7807 problem document, matching what the API sends. + */ + public function testMediaTypesMatchWhatTheApiSends(): void { + foreach (self::$sanitized['paths'] as $path => $pathItem) { + if (!str_starts_with($path, '/api/v2/ui/')) { + continue; + } + foreach ($pathItem as $method => $operation) { + foreach ($operation['responses'] ?? [] as $code => $response) { + $mediaTypes = array_keys($response['content'] ?? []); + if ($mediaTypes === []) { + continue; + } + $expected = ((int)$code >= 400) ? 'application/problem+json' : 'application/vnd.api+json'; + $this->assertSame([$expected], $mediaTypes, "$method $path response $code"); + } + if (isset($operation['requestBody']['content'])) { + $this->assertSame( + ['application/vnd.api+json'], + array_keys($operation['requestBody']['content']), + "$method $path request body" + ); + } + } + } + } + + /** + * obj2Resource puts the self link and the relationships inside the resource + * object, not next to it in the document. + */ + public function testResourceObjectsCarryLinksAndRelationships(): void { + $data = self::$sanitized['components']['schemas']['AccessGroupResponse']['properties']['data']; + $this->assertArrayHasKey('self', $data['properties']['links']['properties']); + $this->assertArrayHasKey('userMembers', $data['properties']['relationships']['properties']); + $this->assertArrayNotHasKey( + 'relationships', + self::$sanitized['components']['schemas']['AccessGroupResponse']['properties'], + 'relationships belong to the resource object, not to the document' + ); + } + + /** + * obj2Resource answers the primary key of the model as it comes from the + * database, so every id is described as an integer. Covers resource objects, + * relationship linkage, included resources and the write envelopes at once. + */ + public function testEveryResourceIdIsDeclaredAsTheRuntimeAnswersIt(): void { + $offenders = []; + $this->collectIdSchemas(self::$sanitized['components']['schemas'], '', $offenders); + $this->assertSame([], $offenders, 'Resource ids must be declared as integer'); + } + + private function collectIdSchemas(array $node, string $path, array &$offenders): void { + foreach ($node as $key => $value) { + if (!is_array($value)) { + continue; + } + /** + * An "id" sibling of a "type" holding a const is a resource identifier; + * that pairing is what distinguishes it from an "id" attribute of a model. + */ + if ($key === 'properties' && isset($value['id']['type'], $value['type']['const'])) { + if ($value['id']['type'] !== 'integer') { + $offenders[] = "$path.id declares {$value['id']['type']}"; + } + } + $this->collectIdSchemas($value, $path === '' ? (string)$key : "$path.$key", $offenders); + } + } + + public function testCountRouteReportsTheCountUnderMeta(): void { + $get = self::$sanitized['paths']['/api/v2/ui/accessgroups/count']['get']; + $this->assertSame( + '#/components/schemas/AccessGroupCountResponse', + $get['responses']['200']['content']['application/vnd.api+json']['schema']['$ref'] + ); + $countSchema = self::$sanitized['components']['schemas']['AccessGroupCountResponse']; + $this->assertArrayHasKey('count', $countSchema['properties']['meta']['properties']); + /* Counting takes filters, not pagination */ + $parameterNames = array_column($get['parameters'], 'name'); + $this->assertSame(['filter', 'include_total'], $parameterNames); + } + + public function testKnownShapeSpotChecks(): void { + $schemas = self::$sanitized['components']['schemas']; + + // Nullable integer field rendered as 3.1 type array + $agentAttributes = $schemas['AgentResponse']['properties']['data']['properties']['attributes']['properties']; + $this->assertSame(['integer', 'null'], $agentAttributes['userId']['type']); + + // Integer enum rendered as oneOf with const + title + $this->assertSame(0, $agentAttributes['ignoreErrors']['oneOf'][0]['const']); + $this->assertArrayHasKey('title', $agentAttributes['ignoreErrors']['oneOf'][0]); + + // Multi-expandable models get a discriminated union in "included" + $this->assertSame( + ['propertyName' => 'type'], + $schemas['AgentResponse']['properties']['included']['items']['discriminator'] + ); + + // Helper responses reference the schema of the model API they return + $this->assertSame( + '#/components/schemas/GlobalPermissionGroupSingleResponse', + self::$sanitized['paths']['/api/v2/helper/getUserPermission']['get']['responses']['200']['content']['application/vnd.api+json']['schema']['$ref'] + ); + } + + public function testNoSchemaIsNamedAfterAMissingRelation(): void { + foreach (['raw' => self::$raw, 'sanitized' => self::$sanitized] as $variant => $spec) { + foreach (array_keys($spec['components']['schemas']) as $name) { + $this->assertDoesNotMatchRegularExpression( + '/Relation(GetResponse)?$/', + $name, + "Schema without a relation name in $variant spec: $name" + ); + } + } + } + + private function collectRefs(array $data, array &$refs): void { + foreach ($data as $key => $value) { + if ($key === '$ref' && is_string($value)) { + $refs[] = $value; + } elseif (is_array($value)) { + $this->collectRefs($value, $refs); + } + } + } +} diff --git a/ci/phpunit/inc/apiv2/openapi/SpecBuilderHelperApiTest.php b/ci/phpunit/inc/apiv2/openapi/SpecBuilderHelperApiTest.php new file mode 100644 index 000000000..fa84dc66e --- /dev/null +++ b/ci/phpunit/inc/apiv2/openapi/SpecBuilderHelperApiTest.php @@ -0,0 +1,64 @@ + OpenAPI spec tests for helper API classes. + */ +final class SpecBuilderHelperApiTest extends TestCase { + use SpecFixtureTrait; + + public function testAbortChunkSpec(): void { + // POST helper: description from the actionPost PHPDoc, request body from + // getFormFields, response schema inferred from the getResponse() sample. + $spec = (new SpecBuilder())->buildForApiClasses([AbortChunkHelperAPI::class]); + + $this->assertMatchesJsonFixture($spec, 'abortchunk.spec.json'); + + $post = $spec['paths']['/api/v2/helper/abortChunk']['post']; + $this->assertStringStartsWith('Endpoint to stop a running chunk.', $post['description']); + + // Raw spec keys helper components by FQCN; the sanitizer renames them. + // A helper takes a flat body, so its request body stays application/json. + $this->assertSame( + ['$ref' => '#/components/schemas/' . AbortChunkHelperAPI::class], + $post['requestBody']['content']['application/json']['schema'] + ); + $this->assertSame( + ['$ref' => '#/components/schemas/' . AbortChunkHelperAPI::class . 'Response'], + $post['responses']['200']['content']['application/vnd.api+json']['schema'] + ); + + // getMetaResponse puts the sample map under meta and leaves data empty + $responseSchema = $spec['components']['schemas'][AbortChunkHelperAPI::class . 'Response']; + $this->assertSame(['jsonapi', 'meta', 'data'], $responseSchema['required']); + $this->assertSame( + ['Abort' => ['type' => 'string', 'default' => 'Success']], + $responseSchema['properties']['meta']['properties'] + ); + $this->assertSame(0, $responseSchema['properties']['data']['maxItems']); + + // Errors are RFC 7807 problem documents + $this->assertSame( + ['$ref' => '#/components/schemas/ErrorResponse'], + $post['responses']['403']['content']['application/problem+json']['schema'] + ); + } + + public function testGetCracksPerDaySpec(): void { + // GET helper with a custom register() (array callable to handleGet) and + // getResponse(): null, which yields a contentless 200 response. + $spec = (new SpecBuilder())->buildForApiClasses([GetCracksPerDayHelperAPI::class]); + + $get = $spec['paths']['/api/v2/helper/getCracksPerDay']['get']; + $this->assertStringStartsWith('Returns a map of date -> crack count', $get['description']); + $this->assertSame([], $get['parameters']); + $this->assertSame(['description' => 'successful operation'], $get['responses']['200']); + } +} diff --git a/ci/phpunit/inc/apiv2/openapi/SpecBuilderModelApiTest.php b/ci/phpunit/inc/apiv2/openapi/SpecBuilderModelApiTest.php new file mode 100644 index 000000000..8b425309c --- /dev/null +++ b/ci/phpunit/inc/apiv2/openapi/SpecBuilderModelApiTest.php @@ -0,0 +1,97 @@ + OpenAPI spec tests for model API classes. No database, no + * HTTP server: the spec is generated purely from class introspection. + */ +final class SpecBuilderModelApiTest extends TestCase { + use SpecFixtureTrait; + + public function testHashTypeSpec(): void { + // Simplest case: full CRUD model API without any relationships. + $spec = (new SpecBuilder())->buildForApiClasses([HashTypeAPI::class]); + + $this->assertMatchesJsonFixture($spec, 'hashtype.spec.json'); + + $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']); + + $response = $spec['components']['schemas']['HashTypeResponse']; + $this->assertSame(['jsonapi', 'links', 'data'], $response['required']); + $attributes = $response['properties']['data']['properties']['attributes']; + $this->assertSame(['description', 'isSalted', 'isSlowHash'], $attributes['required']); + $this->assertSame(['type' => 'boolean'], $attributes['properties']['isSalted']); + $this->assertSame( + ['type' => 'string', 'const' => 'hashType'], + $response['properties']['data']['properties']['type'] + ); + } + + public function testConfigSpecWithToOneRelationship(): void { + // Closed pair: Config has a toOne relationship to ConfigSection. + $spec = (new SpecBuilder())->buildForApiClasses([ConfigAPI::class, ConfigSectionAPI::class]); + + $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']); + + $response = $spec['components']['schemas']['ConfigResponse']; + // The attributes object is derived from the features of the model + $this->assertSame( + ['configSectionId', 'item', 'value'], + array_keys($response['properties']['data']['properties']['attributes']['properties']) + ); + + // toOne relationship linkage: resource identifier with const type, nullable + $configSection = $response['properties']['data']['properties']['relationships']['properties']['configSection']; + $this->assertSame( + ['type' => 'string', 'const' => 'configSection'], + $configSection['properties']['data']['oneOf'][0]['properties']['type'] + ); + $this->assertSame(['type' => 'null'], $configSection['properties']['data']['oneOf'][1]); + } + + public function testCrackerBinaryTypeSpecWithMapperOnlySeeding(): void { + // CrackerBinaryType has toMany relationships to CrackerBinary and Task. + // The related API classes are seeded on the class mapper only, so their + // own routes are not part of the spec but relationship resolution works. + $spec = (new SpecBuilder())->buildForApiClasses( + [CrackerBinaryTypeAPI::class], + [CrackerBinaryAPI::class, TaskAPI::class] + ); + + $this->assertMatchesJsonFixture($spec, 'crackerbinarytype.spec.json'); + + // 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']); + + $response = $spec['components']['schemas']['CrackerBinaryTypeResponse']; + // toMany relationship linkage is an array of resource identifiers + $this->assertSame('array', $response['properties']['data']['properties']['relationships']['properties']['tasks']['properties']['data']['type']); + + // Multiple expandables produce a discriminated oneOf union in "included" + $included = $response['properties']['included']['items']; + $this->assertSame(['propertyName' => 'type'], $included['discriminator']); + $this->assertSame( + ['crackerBinary', 'task'], + array_map(fn($branch) => $branch['properties']['type']['const'], $included['oneOf']) + ); + } +} diff --git a/ci/phpunit/inc/apiv2/openapi/SpecFixtureTrait.php b/ci/phpunit/inc/apiv2/openapi/SpecFixtureTrait.php new file mode 100644 index 000000000..060506a91 --- /dev/null +++ b/ci/phpunit/inc/apiv2/openapi/SpecFixtureTrait.php @@ -0,0 +1,33 @@ +fail("Fixture '$fixtureName' regenerated; rerun the tests without UPDATE_OPENAPI_FIXTURES."); + } + + $this->assertFileExists($fixtureFile, "Missing fixture '$fixtureName'; generate it by running the tests with UPDATE_OPENAPI_FIXTURES=1."); + $this->assertSame( + file_get_contents($fixtureFile), + $actualJson, + "Generated spec differs from fixture '$fixtureName'. If the change is intended, regenerate with UPDATE_OPENAPI_FIXTURES=1." + ); + } +} diff --git a/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php b/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php new file mode 100644 index 000000000..0d61c03ae --- /dev/null +++ b/ci/phpunit/inc/apiv2/openapi/SpecSanitizerTest.php @@ -0,0 +1,241 @@ +sanitize($spec); + } + + private function minimalSpec(array $paths = [], array $schemas = []): array { + return [ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => 'v2'], + 'paths' => $paths, + 'components' => ['schemas' => $schemas], + ]; + } + + 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( + ['/api/v2/helper/thing' => ['post' => [ + 'responses' => ['200' => [ + 'description' => 'ok', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/' . $fqcn]]], + ]], + ]]], + [$fqcn => ['type' => 'object']] + )); + + $this->assertArrayHasKey('ThingHelperAPI', $result['components']['schemas']); + $this->assertArrayNotHasKey($fqcn, $result['components']['schemas']); + $this->assertSame( + '#/components/schemas/ThingHelperAPI', + $result['paths']['/api/v2/helper/thing']['post']['responses']['200']['content']['application/json']['schema']['$ref'] + ); + } + + public function testRemovesBearerAuthScopesFromSecuritySchemes(): void { + $spec = $this->minimalSpec(); + $spec['components']['securitySchemes']['bearerAuth'] = ['type' => 'http', 'scheme' => 'bearer', 'scopes' => ['a']]; + $result = $this->sanitize($spec); + $this->assertArrayNotHasKey('scopes', $result['components']['securitySchemes']['bearerAuth']); + } + + public function testCleansPathTemplatesAndAddsMissingPathParams(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/ui/things/{id:[0-9]+}' => ['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']); + $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']); + $this->assertSame(['id'], array_column($operation['parameters'], 'name')); + } + + public function testMovesPaginationParamsToQueryAndFixesStyleCasing(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/ui/things' => ['get' => [ + 'parameters' => [ + ['name' => 'page[after]', 'in' => 'path', 'schema' => ['type' => 'integer']], + ['name' => 'filter', 'in' => 'path', 'style' => 'deepobject', 'schema' => ['type' => 'object']], + ], + 'responses' => ['200' => ['description' => 'ok']], + ]]] + )); + + $parameters = $result['paths']['/api/v2/ui/things']['get']['parameters']; + $this->assertSame('query', $parameters[0]['in']); + $this->assertSame('query', $parameters[1]['in']); + $this->assertSame('deepObject', $parameters[1]['style']); + } + + public function testUnwrapsIndexedRequestBodyAndFixesRequiredString(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/helper/upload' => ['patch' => [ + 'requestBody' => [[ + 'required' => 'true', + 'description' => 'binary data', + 'content' => ['application/offset+octet-stream' => ['schema' => ['type' => 'string']]], + ]], + 'responses' => ['204' => ['description' => 'accepted']], + ]]] + )); + + $requestBody = $result['paths']['/api/v2/helper/upload']['patch']['requestBody']; + $this->assertTrue($requestBody['required']); + $this->assertSame('binary data', $requestBody['description']); + } + + public function testFillsEmptyMediaTypeObjects(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/ui/things/{id:[0-9]+}' => ['delete' => [ + 'requestBody' => ['required' => true, 'content' => ['application/json' => []]], + 'responses' => ['200' => ['description' => 'ok', 'content' => ['application/json' => []]]], + ]]] + )); + + $operation = $result['paths']['/api/v2/ui/things/{id}']['delete']; + $this->assertSame(['schema' => ['type' => 'object']], $operation['requestBody']['content']['application/json']); + $this->assertSame(['schema' => ['type' => 'object']], $operation['responses']['200']['content']['application/json']); + } + + public function testWrapsSchemalessMediaTypeContent(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/helper/importFile' => ['post' => [ + 'responses' => ['201' => [ + 'description' => 'created', + 'content' => ['application/pdf' => ['type' => 'string', 'format' => 'binary']], + ]], + ]]] + )); + + $this->assertSame( + ['schema' => ['type' => 'string', 'format' => 'binary']], + $result['paths']['/api/v2/helper/importFile']['post']['responses']['201']['content']['application/pdf'] + ); + } + + public function testParsesEnumStringsIntoArrays(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/helper/importFile' => ['head' => [ + 'responses' => ['200' => [ + 'description' => 'ok', + 'headers' => ['Tus-Resumable' => ['schema' => ['type' => 'string', 'enum' => "enum: ['1.0.0']"]]], + ]], + ]]] + )); + + $this->assertSame( + ['1.0.0'], + $result['paths']['/api/v2/helper/importFile']['head']['responses']['200']['headers']['Tus-Resumable']['schema']['enum'] + ); + } + + public function testEmptiesBearerAuthScopesOnOperations(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/ui/things' => ['get' => [ + 'security' => [['bearerAuth' => [['permission1', 'permission2']]]], + 'responses' => ['200' => ['description' => 'ok']], + ]]] + )); + + $this->assertSame([['bearerAuth' => []]], $result['paths']['/api/v2/ui/things']['get']['security']); + } + + public function testSynthesizesTagsSummaryOperationIdAndSecurity(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/helper/abortChunk' => ['post' => [ + 'responses' => ['200' => ['description' => 'ok']], + ]]] + )); + + $operation = $result['paths']['/api/v2/helper/abortChunk']['post']; + $this->assertSame(['Helpers'], $operation['tags']); + $this->assertSame('Create Helpers', $operation['summary']); + $this->assertSame('postAbortChunk', $operation['operationId']); + $this->assertSame('Create Helpers', $operation['description']); + $this->assertSame([['bearerAuth' => []]], $operation['security']); + $this->assertSame([['name' => 'Helpers']], $result['tags']); + } + + public function testAddsMissing2xxResponse(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/ui/things' => ['get' => [ + 'responses' => ['400' => ['description' => 'bad']], + ]]] + )); + + $this->assertSame( + ['description' => 'successful operation'], + $result['paths']['/api/v2/ui/things']['get']['responses']['200'] + ); + } + + public function testPrunesUnreferencedSchemasIteratively(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/ui/things' => ['get' => [ + 'responses' => ['200' => [ + 'description' => 'ok', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/KeptRoot']]], + ]], + ]]], + [ + 'KeptRoot' => ['type' => 'object', 'properties' => ['child' => ['$ref' => '#/components/schemas/KeptChild']]], + 'KeptChild' => ['type' => 'string'], + 'OrphanParent' => ['type' => 'object', 'properties' => ['child' => ['$ref' => '#/components/schemas/OrphanChild']]], + 'OrphanChild' => ['type' => 'string'], + ] + )); + + $this->assertSame(['KeptRoot', 'KeptChild'], array_keys($result['components']['schemas'])); + } + + public function testEmptyPropertiesBecomeJsonObject(): void { + $result = $this->sanitize($this->minimalSpec( + ['/api/v2/ui/things' => ['get' => [ + 'responses' => ['200' => [ + 'description' => 'ok', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Empty']]], + ]], + ]]], + ['Empty' => ['type' => 'object', 'properties' => []]] + )); + + $properties = $result['components']['schemas']['Empty']['properties']; + $this->assertInstanceOf(\stdClass::class, $properties); + $this->assertSame('{}', json_encode($properties)); + } +} diff --git a/ci/tools/generate-openapi.php b/ci/tools/generate-openapi.php new file mode 100644 index 000000000..01ee87a23 --- /dev/null +++ b/ci/tools/generate-openapi.php @@ -0,0 +1,27 @@ +buildForApiClasses(ApiRegistry::allApiClasses()); +$spec = (new SpecSanitizer())->sanitize($spec); + +$flags = JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; +if (in_array('--pretty', $argv, true)) { + $flags |= JSON_PRETTY_PRINT; +} +echo json_encode($spec, $flags), PHP_EOL; diff --git a/composer.json b/composer.json index b2351dcfd..e5fdc9893 100644 --- a/composer.json +++ b/composer.json @@ -64,6 +64,7 @@ }, "scripts": { "start": "php -S localhost:8080 -t src", - "test": "phpunit" + "test": "phpunit", + "openapi": "php ci/tools/generate-openapi.php" } } diff --git a/src/api/v2/index.php b/src/api/v2/index.php index a5d5badba..055215965 100644 --- a/src/api/v2/index.php +++ b/src/api/v2/index.php @@ -20,71 +20,10 @@ use Hashtopolis\inc\apiv2\auth\JWTBeforeHandler; use Hashtopolis\inc\apiv2\common\ClassMapper; use Hashtopolis\inc\apiv2\error\ErrorHandler; -use Hashtopolis\inc\apiv2\helper\GetCompletedCountHelperAPI; -use Hashtopolis\inc\apiv2\helper\GetGlobalConfigHelperAPI; use Hashtopolis\inc\apiv2\util\CorsHackMiddleware; use Hashtopolis\inc\apiv2\util\JsonBodyParserMiddleware; use Hashtopolis\inc\apiv2\util\TokenAsParameterMiddleware; -use Hashtopolis\inc\apiv2\helper\AbortChunkHelperAPI; -use Hashtopolis\inc\apiv2\helper\AssignAgentHelperAPI; -use Hashtopolis\inc\apiv2\helper\BulkSupertaskBuilderHelperAPI; -use Hashtopolis\inc\apiv2\helper\ChangeOwnPasswordHelperAPI; -use Hashtopolis\inc\apiv2\helper\CreateSuperHashlistHelperAPI; -use Hashtopolis\inc\apiv2\helper\CreateSupertaskHelperAPI; -use Hashtopolis\inc\apiv2\helper\CurrentUserHelperAPI; -use Hashtopolis\inc\apiv2\helper\ExportCrackedHashesHelperAPI; -use Hashtopolis\inc\apiv2\helper\ExportLeftHashesHelperAPI; -use Hashtopolis\inc\apiv2\helper\ExportWordlistHelperAPI; -use Hashtopolis\inc\apiv2\helper\GetAccessGroupsHelperAPI; -use Hashtopolis\inc\apiv2\helper\GetAgentBinaryHelperAPI; -use Hashtopolis\inc\apiv2\helper\GetCracksOfTaskHelper; -use Hashtopolis\inc\apiv2\helper\GetCracksPerDayHelperAPI; -use Hashtopolis\inc\apiv2\helper\GetBestTasksAgent; -use Hashtopolis\inc\apiv2\helper\GetFileHelperAPI; -use Hashtopolis\inc\apiv2\helper\GetTaskProgressImageHelperAPI; -use Hashtopolis\inc\apiv2\helper\GetUserPermissionHelperAPI; -use Hashtopolis\inc\apiv2\helper\ImportCrackedHashesHelperAPI; -use Hashtopolis\inc\apiv2\helper\ImportFileHelperAPI; -use Hashtopolis\inc\apiv2\helper\MaskSupertaskBuilderHelperAPI; -use Hashtopolis\inc\apiv2\helper\PurgeTaskHelperAPI; -use Hashtopolis\inc\apiv2\helper\RebuildChunkCacheHelperAPI; -use Hashtopolis\inc\apiv2\helper\RecountFileLinesHelperAPI; -use Hashtopolis\inc\apiv2\helper\RescanGlobalFilesHelperAPI; -use Hashtopolis\inc\apiv2\helper\ResetChunkHelperAPI; -use Hashtopolis\inc\apiv2\helper\ResetUserPasswordHelperAPI; -use Hashtopolis\inc\apiv2\helper\SearchHashesHelperAPI; -use Hashtopolis\inc\apiv2\helper\SetUserPasswordHelperAPI; -use Hashtopolis\inc\apiv2\helper\UnassignAgentHelperAPI; -use Hashtopolis\inc\apiv2\model\AccessGroupAPI; -use Hashtopolis\inc\apiv2\model\AgentAPI; -use Hashtopolis\inc\apiv2\model\AgentAssignmentAPI; -use Hashtopolis\inc\apiv2\model\AgentBinaryAPI; -use Hashtopolis\inc\apiv2\model\AgentErrorAPI; -use Hashtopolis\inc\apiv2\model\AgentStatAPI; -use Hashtopolis\inc\apiv2\model\ApiTokenAPI; -use Hashtopolis\inc\apiv2\model\ChunkAPI; -use Hashtopolis\inc\apiv2\model\ConfigAPI; -use Hashtopolis\inc\apiv2\model\ConfigSectionAPI; -use Hashtopolis\inc\apiv2\model\CrackerBinaryAPI; -use Hashtopolis\inc\apiv2\model\CrackerBinaryTypeAPI; -use Hashtopolis\inc\apiv2\model\FileAPI; -use Hashtopolis\inc\apiv2\model\GlobalPermissionGroupAPI; -use Hashtopolis\inc\apiv2\model\HashAPI; -use Hashtopolis\inc\apiv2\model\HashlistAPI; -use Hashtopolis\inc\apiv2\model\HashTypeAPI; -use Hashtopolis\inc\apiv2\model\HealthCheckAgentAPI; -use Hashtopolis\inc\apiv2\model\HealthCheckAPI; -use Hashtopolis\inc\apiv2\model\LogEntryAPI; -use Hashtopolis\inc\apiv2\model\NotificationSettingAPI; -use Hashtopolis\inc\apiv2\model\PreprocessorAPI; -use Hashtopolis\inc\apiv2\model\PreTaskAPI; -use Hashtopolis\inc\apiv2\model\SpeedAPI; -use Hashtopolis\inc\apiv2\model\SupertaskAPI; -use Hashtopolis\inc\apiv2\model\TaskAPI; -use Hashtopolis\inc\apiv2\model\TaskWrapperAPI; -use Hashtopolis\inc\apiv2\model\TaskWrapperDisplayAPI; -use Hashtopolis\inc\apiv2\model\UserAPI; -use Hashtopolis\inc\apiv2\model\VoucherAPI; +use Hashtopolis\inc\apiv2\common\ApiRegistry; use DI\Container; use Hashtopolis\inc\StartupConfig; @@ -227,70 +166,10 @@ include(__DIR__ . "/../../inc/apiv2/common/openAPISchema.routes.php"); include(__DIR__ . "/../../inc/apiv2/auth/token.routes.php"); -// register model APIs -AccessGroupAPI::register($app); -AgentAPI::register($app); -AgentAssignmentAPI::register($app); -AgentBinaryAPI::register($app); -AgentErrorAPI::register($app); -AgentStatAPI::register($app); -ApiTokenAPI::register($app); -ChunkAPI::register($app); -ConfigAPI::register($app); -ConfigSectionAPI::register($app); -CrackerBinaryAPI::register($app); -CrackerBinaryTypeAPI::register($app); -FileAPI::register($app); -GlobalPermissionGroupAPI::register($app); -HashAPI::register($app); -HashlistAPI::register($app); -HashTypeAPI::register($app); -HealthCheckAgentAPI::register($app); -HealthCheckAPI::register($app); -LogEntryAPI::register($app); -NotificationSettingAPI::register($app); -PreprocessorAPI::register($app); -PreTaskAPI::register($app); -SpeedAPI::register($app); -SupertaskAPI::register($app); -TaskAPI::register($app); -TaskWrapperAPI::register($app); -TaskWrapperDisplayAPI::register($app); -UserAPI::register($app); -VoucherAPI::register($app); - -// register helpers -AbortChunkHelperAPI::register($app); -AssignAgentHelperAPI::register($app); -BulkSupertaskBuilderHelperAPI::register($app); -ChangeOwnPasswordHelperAPI::register($app); -CreateSuperHashlistHelperAPI::register($app); -CreateSupertaskHelperAPI::register($app); -CurrentUserHelperAPI::register($app); -ExportCrackedHashesHelperAPI::register($app); -ExportLeftHashesHelperAPI::register($app); -ExportWordlistHelperAPI::register($app); -GetAccessGroupsHelperAPI::register($app); -GetAgentBinaryHelperAPI::register($app); -GetBestTasksAgent::register($app); -GetCompletedCountHelperAPI::register($app); -GetCracksOfTaskHelper::register($app); -GetCracksPerDayHelperAPI::register($app); -GetFileHelperAPI::register($app); -GetGlobalConfigHelperAPI::register($app); -GetTaskProgressImageHelperAPI::register($app); -GetUserPermissionHelperAPI::register($app); -ImportCrackedHashesHelperAPI::register($app); -ImportFileHelperAPI::register($app); -MaskSupertaskBuilderHelperAPI::register($app); -PurgeTaskHelperAPI::register($app); -RebuildChunkCacheHelperAPI::register($app); -RecountFileLinesHelperAPI::register($app); -RescanGlobalFilesHelperAPI::register($app); -ResetChunkHelperAPI::register($app); -ResetUserPasswordHelperAPI::register($app); -SearchHashesHelperAPI::register($app); -SetUserPasswordHelperAPI::register($app); -UnassignAgentHelperAPI::register($app); +// register model and helper APIs (order matters: it defines route and +// OpenAPI spec ordering, see ApiRegistry) +foreach (ApiRegistry::allApiClasses() as $apiClass) { + $apiClass::register($app); +} $app->run(); diff --git a/src/inc/apiv2/common/ApiRegistry.php b/src/inc/apiv2/common/ApiRegistry.php new file mode 100644 index 000000000..d472352a0 --- /dev/null +++ b/src/inc/apiv2/common/ApiRegistry.php @@ -0,0 +1,150 @@ +> */ + public const MODEL_API_CLASSES = [ + AccessGroupAPI::class, + AgentAPI::class, + AgentAssignmentAPI::class, + AgentBinaryAPI::class, + AgentErrorAPI::class, + AgentStatAPI::class, + ApiTokenAPI::class, + ChunkAPI::class, + ConfigAPI::class, + ConfigSectionAPI::class, + CrackerBinaryAPI::class, + CrackerBinaryTypeAPI::class, + FileAPI::class, + GlobalPermissionGroupAPI::class, + HashAPI::class, + HashlistAPI::class, + HashTypeAPI::class, + HealthCheckAgentAPI::class, + HealthCheckAPI::class, + LogEntryAPI::class, + NotificationSettingAPI::class, + PreprocessorAPI::class, + PreTaskAPI::class, + SpeedAPI::class, + SupertaskAPI::class, + TaskAPI::class, + TaskWrapperAPI::class, + TaskWrapperDisplayAPI::class, + UserAPI::class, + VoucherAPI::class, + ]; + + /** @var list */ + public const HELPER_API_CLASSES = [ + AbortChunkHelperAPI::class, + AssignAgentHelperAPI::class, + BulkSupertaskBuilderHelperAPI::class, + ChangeOwnPasswordHelperAPI::class, + CreateSuperHashlistHelperAPI::class, + CreateSupertaskHelperAPI::class, + CurrentUserHelperAPI::class, + ExportCrackedHashesHelperAPI::class, + ExportLeftHashesHelperAPI::class, + ExportWordlistHelperAPI::class, + GetAccessGroupsHelperAPI::class, + GetAgentBinaryHelperAPI::class, + GetBestTasksAgent::class, + GetCompletedCountHelperAPI::class, + GetCracksOfTaskHelper::class, + GetCracksPerDayHelperAPI::class, + GetFileHelperAPI::class, + GetGlobalConfigHelperAPI::class, + GetTaskProgressImageHelperAPI::class, + GetUserPermissionHelperAPI::class, + ImportCrackedHashesHelperAPI::class, + ImportFileHelperAPI::class, + MaskSupertaskBuilderHelperAPI::class, + PurgeTaskHelperAPI::class, + RebuildChunkCacheHelperAPI::class, + RecountFileLinesHelperAPI::class, + RescanGlobalFilesHelperAPI::class, + ResetChunkHelperAPI::class, + ResetUserPasswordHelperAPI::class, + SearchHashesHelperAPI::class, + SetUserPasswordHelperAPI::class, + UnassignAgentHelperAPI::class, + ]; + + /** @return list */ + public static function allApiClasses(): array { + return [...self::MODEL_API_CLASSES, ...self::HELPER_API_CLASSES]; + } +} diff --git a/src/inc/apiv2/common/OpenAPISchemaUtils.php b/src/inc/apiv2/common/OpenAPISchemaUtils.php deleted file mode 100644 index 0d2ba8540..000000000 --- a/src/inc/apiv2/common/OpenAPISchemaUtils.php +++ /dev/null @@ -1,377 +0,0 @@ - $type, - "type_format" => $type_format, - "type_enum" => $type_enum, - "subtype" => $sub_type - ]; - } - - static function parsePhpDoc($doc): array|string { - $cleanedDoc = preg_replace([ - '/^\/\*\*/', // Remove opening /** - '/\*\/$/', // Remove closing */ - '/^\s*\*\s?/m' // Remove leading * on each line - ], '', $doc); - //markdown friendly line end - return str_replace("\n", "
", $cleanedDoc); - } - - // "jsonapi": { - // "version": "1.1", - // "ext": [ - // "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" - // ] - // }, - static function makeJsonApiHeader(): array { - return ["jsonapi" => [ - "type" => "object", - "properties" => [ - "version" => [ - "type" => "string", - "default" => "1.1" - ], - "ext" => [ - "type" => "string", - "default" => "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" - ] - ] - ] - ]; - } - - // "links": { - // "self": "/api/v2/ui/hashlists?page[size]=10000", - // "first": "/api/v2/ui/hashlists?page[size]=10000&page[after]=0", - // "last": "/api/v2/ui/hashlists?page[size]=10000&page[before]=345", - // "next": null, - // "prev": "/api/v2/ui/hashlists?page[size]=10000&page[before]=114" - // }, - static function makeLinks($uri): array { - $self = $uri . "?page[size]=25"; - return ["links" => [ - "type" => "object", - "properties" => [ - "self" => [ - "type" => "string", - "default" => $self - ], - "first" => [ - "type" => "string", - "default" => $self - ], - "last" => [ - "type" => "string", - "default" => $self . "&page[before]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0=" - ], - "next" => [ - "type" => "string", - "default" => $self . "&page[after]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0" - ], - "previous" => [ - "type" => "string", - "default" => $self . "&page[before]=eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0" - ] - ] - ] - ]; - } - - //TODO relationship array is unnecessarily indexed in the swagger UI - static function makeRelationships($relationshipsNames, $uri): array { - $properties = []; - sort($relationshipsNames); - foreach ($relationshipsNames as $relationshipName) { - $self = $uri . "/relationships/" . $relationshipName; - $related = $uri . "/" . $relationshipName; - $properties[$relationshipName] = [ - "type" => "object", - "properties" => [ - "links" => [ - "type" => "object", - "properties" => [ - "self" => [ - "type" => "string", - "default" => $self - ], - "related" => [ - "type" => "string", - "default" => $related - ] - ] - ] - ] - ]; - } - return $properties; - } - - static function getTUSHeader(): array { - return [ - "description" => "Indicates the TUS version the server supports. - Must always be set to `1.0.0` in compliant servers.", - "schema" => [ - "type" => "string", - "enum" => ['1.0.0'] - ] - ]; - } - - //TODO expandables array is unnecessarily indexed in the swagger UI - - /** - * @throws HttpErrorException - */ - static function makeExpandables($expandables, $container): array { - $properties = []; - foreach ($expandables as $expand => $expandVal) { - $expandClass = $expandVal["relationType"]; - $expandApiClass = new ($container->get('classMapper')->get($expandClass))($container); - $properties[$expand] = [ - "properties" => [ - "id" => [ - "type" => "integer" - ], - "type" => [ - "type" => "string", - "default" => $expand - ], - "attributes" => [ - "type" => "object", - "properties" => self::makeProperties($expandApiClass->getAliasedFeatures()) - ] - ] - ]; - } - return $properties; - } - - static function mapToProperties(mixed $value): array { - if (is_null($value)) { - return ["nullable" => true, "type" => "string"]; - } elseif (is_bool($value)) { - return ["type" => "boolean", "example" => $value]; - } elseif (is_int($value)) { - return ["type" => "integer", "example" => $value]; - } elseif (is_float($value)) { - return ["type" => "number", "example" => $value]; - } elseif (is_string($value)) { - return ["type" => "string", "example" => $value]; - } elseif (is_array($value)) { - if (empty($value)) { - return ["type" => "array"]; - } - if (array_is_list($value)) { - /* Merge properties from all items to capture the most complete schema */ - $mergedProperties = []; - foreach ($value as $item) { - $itemSchema = self::mapToProperties($item); - if (isset($itemSchema['properties'])) { - $mergedProperties = array_merge($mergedProperties, $itemSchema['properties']); - } - } - $itemSchema = self::mapToProperties($value[0]); - if (!empty($mergedProperties)) { - $itemSchema['properties'] = $mergedProperties; - } - return ["type" => "array", "items" => $itemSchema]; - } else { - $properties = array_map(function ($val) { - return self::mapToProperties($val); - }, $value); - return ["type" => "object", "properties" => $properties]; - } - } - return ["type" => "string"]; - } - - /** - * @throws HttpErrorException - */ - static function makeProperties($features, $skipPK = false): array { - $propertyVal = []; - foreach ($features as $feature) { - if ($skipPK && $feature['pk']) { - continue; - } - $ret = self::typeLookup($feature); - $propertyVal[$feature['alias']]["type"] = $ret["type"]; - if ($ret["type_format"] !== null) { - $propertyVal[$feature['alias']]["format"] = $ret["type_format"]; - } - if ($ret["type_enum"] !== null) { - $propertyVal[$feature['alias']]["enum"] = $ret["type_enum"]; - } - if ($ret["subtype"] !== null) { - $propertyVal[$feature['alias']]["items"]["type"] = $ret["subtype"]; - } - } - return $propertyVal; - } - - static function buildPatchPost($properties, $name, $id = null): array { - $result = ["data" => [ - "type" => "object", - "properties" => [ - "type" => [ - "type" => "string", - "default" => $name - ], - "attributes" => [ - "type" => "object", - "properties" => $properties - ] - ] - ] - ]; - - if ($id) { - $result["data"]["properties"]["id"] = [ - "type" => "integer", - ]; - } - return $result; - } - - /** - * This function builds the post/patch attributes for a relationship. When $isToMany is false, - * it would build the attributes for a to one relationship. If it is true it will build it for a too many relationship. - * */ - static function buildPostPatchRelation($name, $isToMany): array { - $resourceRecord = [ - "type" => "object", - "properties" => [ - "type" => [ - "type" => "string", - "default" => $name - ], - "id" => [ - "type" => "integer", - "default" => 1 - ] - ] - ]; - if ($isToMany) { - return ["data" => [ - "type" => "array", - "items" => $resourceRecord - ] - ]; - } - else { - return ["data" => $resourceRecord]; - } - } - - static function makeDescription($isRelation, $method, $singleObject): string { - $description = ""; - switch ($method) { - case "get": - if ($isRelation) { - if ($singleObject) { - $description = "GET request for for a to-one relationship link. Returns the resource record of the object that is part of the specified relation."; - } - else { - $description = "GET request for a to-many relationship link. Returns a list of resource records of objects that are part of the specified relation."; - } - } - else { - if ($singleObject) { - $description = "GET request to retrieve a single object."; - } - else { - $description = "GET many request to retrieve multiple objects."; - } - } - break; - case "post": - if ($isRelation) { - if ($singleObject) { - $description = "POST request to create a to-one relationship link."; - } - else { - $description = "POST request to create a to-many relationship link."; - } - } - else { - $description = "POST request to create a new object. The request must contain the resource record as data with the attributes of the new object." - . "To add relationships, a relationships object can be added with the resource records of the relations that are part of this object."; - } - break; - case "patch": - if ($isRelation) { - if ($singleObject) { - $description = "PATCH request to update a to one relationship."; - } - else { - $description = "PATCH request to update a to-many relationship link."; - } - } - else { - $description = "PATCH request to update attributes of a single object."; - } - break; - case "delete": - if ($isRelation) { - if ($singleObject) { - $description = "DELETE request to update a to one relationship."; - } - else { - $description = "DELETE request to update a to-many relationship link."; - } - } - else { - $description = "DELETE request to update attributes of a single object."; - } - } - return $description; - } -} \ No newline at end of file diff --git a/src/inc/apiv2/common/openAPISchema.routes.php b/src/inc/apiv2/common/openAPISchema.routes.php index 9d4ec5e23..7f1d41b49 100644 --- a/src/inc/apiv2/common/openAPISchema.routes.php +++ b/src/inc/apiv2/common/openAPISchema.routes.php @@ -2,15 +2,14 @@ namespace Hashtopolis\inc\apiv2\common; +use Hashtopolis\inc\apiv2\openapi\SpecBuilder; +use Hashtopolis\inc\apiv2\openapi\SpecSanitizer; + use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use ReflectionMethod; -use ReflectionObject; use Slim\Routing\RouteCollectorProxy; -use Middlewares\Utils\HttpErrorException; - use Slim\App; /** @var App $app */ $app->group("/api/v2/openapi.json", function (RouteCollectorProxy $group) use ($app) { @@ -18,1045 +17,14 @@ $group->options('', function (Request $request, Response $response): Response { return $response; }); - - $group->get('', function (Request $request, Response $response) use ($app): Response { - /* Hold collection of all scopes discovered */ - $all_scopes = []; - - $paths = []; - $components["ListResponse"] = [ - "type" => "object", - "properties" => [ - "expand" => [ - "type" => "string", - "example" => "hashlist", - ], - "page[after]" => [ - "type" => "integer", - "example" => 0 - ], - "page[before]" => [ - "type" => "integer", - "example" => 0 - ], - "page[size]" => [ - "type" => "integer", - "example" => 100 - ] - ] - ]; - $components["ErrorResponse"] = [ - "type" => "object", - "properties" => [ - "title" => [ - "type" => "string", - "example" => "about=>blank" - ], - "type" => [ - "type" => "string", - "example" => "Error details here" - ], - "status" => [ - "type" => "integer", - "example" => 400 - ] - ] - ]; - $components["NotFoundResponse"] = [ - "type" => "object", - "properties" => [ - "message" => [ - "type" => "string", - "example" => "404 Not Found" - ], - "exception" => [ - "type" => "object", - "properties" => [ - "type" => [ - "type" => "string", - "example" => "Slim\\Exception\\HttpNotFoundException" - ], - "code" => [ - "type" => "integer", - "example" => 404 - ], - "message" => [ - "type" => "string", - "example" => "Not Found" - ], - "file" => [ - "type" => "string", - "example" => "../hashtopolis/server/vendor/slim/slim/Slim/Middleware/RoutingMiddleware.php" - ], - "line" => [ - "type" => "integer", - "example" => 91 - ] - ] - ] - ] - ]; - - /* Iterate over routes */ - $routes = $app->getRouteCollector()->getRoutes(); - foreach ($routes as $route) { - /* Quirk to receive className, since it is hidden in a protected variable */ - $reflectionOfRoute = new ReflectionObject($route); - $protectedCallable = $reflectionOfRoute->getProperty('callable'); - $reflectionCallable = ($protectedCallable->getValue($route)); - - /* 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(); - $method = strtolower($route->getMethods()[0]); - - if (is_string($reflectionCallable)) { - $explodedCallable = explode(':', $reflectionCallable); - if (count($explodedCallable) !== 2) { - continue; - } - [$apiClassName, $apiMethod] = $explodedCallable; - } elseif (is_array($reflectionCallable) - && isset($reflectionCallable[0], $reflectionCallable[1]) - && is_string($reflectionCallable[0]) && is_string($reflectionCallable[1])) { - [$apiClassName, $apiMethod] = $reflectionCallable; - } else { - /* OPTIONS (CORS) have an function callable, ignore for now */ - continue; - } - $class = new $apiClassName($app->getContainer()); - - - $path = preg_replace('/\{([^:}]+):(.+)}/', '{$1}', $path); - if (!($class instanceof AbstractModelAPI)) { - $name_parts = explode('\\', $class::class); - $name = end($name_parts); - $apiMethod = ($apiMethod == "processPost" && $name != "ImportFileHelperAPI") ? "actionPost" : $apiMethod; - $reflectionApiMethod = new ReflectionMethod($class::class, $apiMethod); - $paths[$path][$method]["description"] = OpenAPISchemaUtils::parsePhpDoc($reflectionApiMethod->getDocComment()); - $paths[$path][$method]["summary"] = OpenAPISchemaUtils::parsePhpDoc($reflectionApiMethod->getDocComment()); - $parameters = $class->getCreateValidFeatures(); - $properties = OpenAPISchemaUtils::makeProperties($parameters); - $amountProperties = count($properties); - if ($amountProperties > 0) { - $components[$name] = - [ - "type" => "object", - "properties" => $properties, - ]; - } - if ($method == "post" && $amountProperties > 0) { - $reflectionMethodFormFields = new ReflectionMethod($class::class, "getFormFields"); - $bodyDescription = OpenAPISchemaUtils::parsePhpDoc($reflectionMethodFormFields->getDocComment()); - $paths[$path][$method]["requestBody"] = [ - "description" => $bodyDescription, - "required" => true, - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/" . $name - ], - ] - ] - ]; - } - elseif ($method == "get") { - $paths[$path][$method]["parameters"] = $class->getParamsSwagger(); - } - $request_response = $class->getResponse(); - $ref = null; - if (is_array($request_response)) { - $responseProperties = OpenAPISchemaUtils::mapToProperties($request_response); - $components[$name . "Response"] = $responseProperties; - $ref = "#/components/schemas/" . $name . "Response"; - } - else if (is_string($request_response)) { - $ref = "#/components/schemas/" . $request_response . "SingleResponse"; - } - if (isset($ref)) { - $paths[$path][$method]["responses"]["200"] = [ - "description" => "successful operation", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => $ref - ] - ] - ] - ]; - } - else { - $paths[$path][$method]["responses"]["200"] = [ - "description" => "successful operation", - ]; - } - $required_scopes = $class->getRequiredPermissions($method); - $paths[$path][$method]["security"] = [ - [ - "bearerAuth" => $required_scopes - ] - ]; - continue; - } - - /* Quick to find out if single parameter object is used */ - $singleObject = ((strstr($path, '/{id}')) !== false); - $name_parts = explode('\\', $class->getDBAClass()); - $name = end($name_parts); - $uri = $class->getBaseUri(); - - $isRelation = (strstr($path, "/relationships/")) !== false; - if (str_contains($path, "relation:")) { - $relation = rtrim(explode("relation:", $path)[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."); - } else { - $availableMethods = $class->getAvailableMethods(); - $method_to_check = strtoupper($method); - if ($method_to_check != "GET" && !in_array($method_to_check, $availableMethods)) { - continue; - } - $isToMany = $isToOne = false; - $relation = null; - } - - $expandables = implode(",", $class->getExpandables()); - /** - * Create component objects - */ - if (!array_key_exists($name, $components)) { - $properties_return_post_patch = [ - "data" => [ - "type" => "array", - "items" => [ - "type" => "object", - "properties" => [ - "id" => [ - "type" => "integer", - ], - "type" => [ - "type" => "string", - "default" => $name - ], - "attributes" => [ - "type" => "object", - "properties" => OpenAPISchemaUtils::makeProperties($class->getFeaturesWithoutFormfields(), true) - ], - ] - ] - ] - ]; - - $relationshipsNames = array_merge(array_keys($class->getToOneRelationships()), array_keys($class->getToManyRelationships())); - $relationships = []; - if (count($relationshipsNames) > 0) { - $relationships = ["relationships" => [ - "type" => "object", - "properties" => OpenAPISchemaUtils::makeRelationships($relationshipsNames, $uri) - ] - ]; - } - $expandables_array = array_merge($class->getToOneRelationships(), $class->getToManyRelationships()); - $included = []; - if (count($expandables_array) > 0) { - $included = ["included" => [ - "type" => "array", - "items" => [ - "type" => "object", - "properties" => OpenAPISchemaUtils::makeExpandables($expandables_array, $app->getContainer()) - ], - ] - ]; - } - - $properties_get_single = array_merge($properties_return_post_patch, $relationships, $included); - - $json_api_header = OpenAPISchemaUtils::makeJsonApiHeader(); - $links = OpenAPISchemaUtils::makeLinks($uri); - $properties_return_post_patch = array_merge($json_api_header, $properties_return_post_patch); - $postProperties = OpenAPISchemaUtils::makeProperties($class->getAllPostParameters($class->getCreateValidFeatures())); - $properties_get = array_merge($json_api_header, $links, $properties_get_single, $included); - $patch_properties = OpenAPISchemaUtils::makeProperties($class->getPatchValidFeatures()); - - if (count($postProperties) > 0) { - $properties_create = OpenAPISchemaUtils::buildPatchPost(OpenAPISchemaUtils::makeProperties($class->getAllPostParameters($class->getCreateValidFeatures())), $name); - $components[$name . "Create"] = - [ - "type" => "object", - "properties" => $properties_create, - ]; - } - - if (count($patch_properties) > 0) { - $properties_patch = OpenAPISchemaUtils::buildPatchPost($patch_properties, $name); - $components[$name . "Patch"] = - [ - "type" => "object", - "properties" => $properties_patch, - ]; - } - - $components[$name . "Response"] = - [ - "type" => "object", - "properties" => $properties_get, - ]; - - if ($relation) { - $properties_patch_post_relation = OpenAPISchemaUtils::buildPostPatchRelation($relation, ($isToMany && !$isToOne)); - $responseGetRelation = $properties_patch_post_relation; - $components[$name . "Relation" . ucfirst($relation)] = - [ - "type" => "object", - "properties" => $properties_patch_post_relation, - ]; - $components[$name . "Relation" . ucfirst($relation) . "GetResponse"] = - [ - "type" => "object", - "properties" => $responseGetRelation - ]; - } - - $components[$name . "SingleResponse"] = - [ - "type" => "object", - "properties" => $properties_get_single - ]; - - $components[$name . "PostPatchResponse"] = - [ - "type" => "object", - "properties" => $properties_return_post_patch - ]; - - $components[$name . "ListResponse"] = - [ - "allOf" => [ - [ - '$ref' => "#/components/schemas/ListResponse" - ], - [ - "type" => "object", - "properties" => [ - "values" => [ - "type" => "array", - "items" => [ - '$ref' => "#/components/schemas/" . $name . "Response" - ] - ] - ] - ] - ] - ]; - } - - /** - * Create path objects - */ - - /* Determine the scopes required for the call */ - $required_scopes = $class->getRequiredPermissions($method); - array_push($all_scopes, ...$required_scopes); - - $paths[$path][$method] = [ - "tags" => [ - $name . 's' - ], - "responses" => [ - - "400" => [ - "description" => "Invalid request", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/ErrorResponse" - ] - ] - ] - ], - "401" => [ - "description" => "Authentication failed", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/ErrorResponse" - ] - ] - ] - ] - ], - "security" => [ - [ - "bearerAuth" => $required_scopes - ] - ] - ]; - - $paths[$path][$method]["description"] = OpenAPISchemaUtils::makeDescription($isRelation, $method, $singleObject); - $paths[$path][$method]["summary"] = OpenAPISchemaUtils::makeDescription($isRelation, $method, $singleObject); - - if ($isRelation && in_array($method, ["post", "patch", "delete"], true)) { - $paths[$path][$method]["responses"]["204"] = - [ - "description" => "Successfull operation" - ]; - } - if ($singleObject) { - /* Single objects could not exists */ - $paths[$path][$method]["responses"]["404"] = - [ - "description" => "Not Found", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/NotFoundResponse" - ] - ] - ] - ]; - - /* Method specific responses and requests for single objects */ - if ($method == 'get') { - if (!$isRelation && str_contains($path, "relation:")) { - $paths[$path][$method]["responses"]["200"] = [ - "description" => "successful operation", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/" . $name . "Relation" . ucfirst($relation) . "GetResponse" - - ] - ] - ] - ]; - } - else { - $paths[$path][$method]["responses"]["200"] = [ - "description" => "successful operation", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/" . $name . "Response" - ] - ] - ] - ]; - } - - /* Supported by client, not by browser, disabled for APIdocs */ - // /* JSON object required */ - // $paths[$path][$method]["requestBody"] = [ - // "required" => true, - // "content" => [ - // "application/json" => [ - // "schema" => [ - // '$ref' => "#/components/schemas/ObjectRequest" - // ], - // ], - // ]]; - - } - elseif ($method == 'patch') { - if ($isRelation) { - $paths[$path][$method]["requestBody"] = [ - "required" => true, - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/" . $name . "Relation" . ucfirst($relation) - ], - ], - ] - ]; - } - else { - $paths[$path][$method]["requestBody"] = [ - "required" => true, - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/" . $name . "Patch" - ], - ], - ] - ]; - - $paths[$path][$method]["responses"]["200"] = [ - "description" => "successful operation", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/" . $name . "PostPatchResponse" - ] - ] - ] - ]; - } - } - elseif ($method == 'delete') { - $paths[$path][$method]["responses"]["204"] = [ - "description" => "successfully deleted", - ]; - - if ($isRelation) { - $paths[$path][$method]["requestBody"] = [ - "required" => true, - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/" . $name . "Relation" . ucfirst($relation) - ], - ], - ] - ]; - } - else { - /* Empty JSON object required */ - // $paths[$path][$method]["requestBody"] = [ - // "required" => false, - // "content" => [ - // "application/json" => [], - // ] - // ]; - } - } - elseif ($method == 'post') { - $paths[$path][$method]["responses"]["204"] = [ - "description" => "successfully created", - ]; - } - else { - throw new HttpErrorException("Method '$method' not implemented"); - } - } - else { - /* Model API entry point */ - if ($method == 'get') { - $paths[$path][$method]["responses"]["200"] = [ - "description" => "successful operation", - "content" => [ - "application/json" => [ - "schema" => [ - "type" => "array", - "items" => [ - '$ref' => "#/components/schemas/" . $name . "Response" - ] - ] - ] - ] - ]; - - /* Supported by client, not by browser, disabled for APIdocs */ - // $paths[$path][$method]["requestBody"] = [ - // "content" => [ - // "application/json" => [ - // "schema" => [ - // '$ref' => "#/components/schemas/ObjectListRequest" - // ], - // ] - // ]]; - - - } - elseif ($method == 'post') { - $paths[$path][$method]["responses"]["201"] = [ - "description" => "successful operation", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/" . $name . "PostPatchResponse" - ] - ] - ] - ]; - - if ($isRelation) { - $paths[$path][$method]["requestBody"] = [ - "required" => true, - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/" . $name . "Relation" . ucfirst($relation) - ], - ], - ] - ]; - } - else { - $paths[$path][$method]["requestBody"] = [ - "required" => true, - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/" . $name . "Create" - ], - ] - ] - ]; - } - - } - elseif ($method == 'patch') { - $paths[$path][$method]["responses"]["204"] = [ - "description" => "successfully patched", - ]; - } - elseif ($method == 'delete') { - $paths[$path][$method]["responses"]["200"] = [ - "description" => "successfully deleted", - ]; - } - else { - throw new HttpErrorException("Method '$method' not implemented"); - } - } - - if ($singleObject) { - $parameters = [ - [ - "name" => "id", - "in" => "path", - "required" => true, - "schema" => [ - "type" => "integer", - "format" => "int32", - "example" => 10, - ] - ] - ]; - - if (!str_contains($path, "relation:")) { - $parameters[] = [ - "name" => "include", - "in" => "query", - "schema" => [ - "type" => "string" - ], - "description" => "Items to include. Comma seperated" - ]; - } - } - else { - if ($method == 'get') { - $primaryKey = array_find(call_user_func($class->getDBAclass() . '::getFeatures'), function (array $feature) { return $feature["pk"] === true; }); - $exampleCursor = "{\"primary\":{\"".($primaryKey['alias'] ?? "id")."\": 123}}"; - $parameters = [ - [ - "name" => "page[after]", - "in" => "query", - "schema" => [ - "type" => "string", - "format" => "byte", - ], - "example" => base64_encode($exampleCursor), - "required" => false, - "description" => "Pointer to paginate to retrieve the data after the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting. - \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}` - \n\nExample: `$exampleCursor` -> `".(base64_encode($exampleCursor))."`" - ], - [ - "name" => "page[before]", - "in" => "query", - "schema" => [ - "type" => "string", - "format" => "byte", - ], - "example" => base64_encode($exampleCursor), - "required" => false, - "description" => "Pointer to paginate to retrieve the data before the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting. - \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}` - \n\nExample: `$exampleCursor` -> `".(base64_encode($exampleCursor))."`" - ], - [ - "name" => "page[size]", - "in" => "query", - "schema" => [ - "type" => "integer", - "format" => "int32" - ], - "required" => false, - "example" => 100, - "description" => "Amout of data to retrieve inside a single page" - ], - [ - "name" => "filter", - "in" => "query", - "style" => "deepObject", - "explode" => true, - "schema" => [ - "type" => "string", - ], - "description" => "Filters results using a query", - "example" => '"filter[hashlistId__gt]": 200' - ], - [ - "name" => "include", - "in" => "query", - "schema" => [ - "type" => "string" - ], - "required" => false, - "description" => "Items to include, comma seperated. Possible options: " . $expandables - ] - ]; - - $aggregateFieldsets = $class->getAggregateFieldsets(); - if (!empty($aggregateFieldsets)) { - $aggregateExamples = []; - $aggregateDescriptionParts = []; - foreach ($aggregateFieldsets as $fieldset => $options) { - if (empty($options)) { - continue; - } - $aggregateExamples["aggregate[" . $fieldset . "]"] = implode(",", array_keys($options)); - $aggregateDescriptionParts[] = $fieldset . ": " . implode(", ", array_keys($options)); - } + $group->get('', function (Request $request, Response $response) use ($app): Response { + $result = (new SpecBuilder())->buildFromApp($app); + $result = (new SpecSanitizer())->sanitize($result); - if (!empty($aggregateExamples)) { - $parameters[] = [ - "name" => "aggregate", - "in" => "query", - "style" => "deepObject", - "explode" => true, - "schema" => [ - "type" => "object", - "additionalProperties" => [ - "type" => "string" - ] - ], - "required" => false, - "description" => "Aggregated fields to include by type (comma separated values). Possible options: " . implode(" | ", $aggregateDescriptionParts), - "example" => $aggregateExamples - ]; - } - } - } - else { - $parameters = []; - } - } - $paths[$path][$method]["parameters"] = $parameters; - } - - /** - * Build static entries - */ - $paths["/api/v2/auth/token"] = [ - "post" => [ - "tags" => [ - "Login" - ], - "summary" => "Obtain an authentication token", - "requestBody" => [ - "required" => true, - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/TokenRequest" - ] - ] - ] - ], - "responses" => [ - "200" => [ - "description" => "Success", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/Token" - ] - ] - ] - ], - "401" => [ - "description" => "Authentication failed", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/ErrorResponse" - ] - ] - ] - ], - "404" => [ - "description" => "Not Found", - "content" => [ - "application/json" => [ - "schema" => [ - '$ref' => "#/components/schemas/NotFoundResponse" - ] - ] - ] - ] - ], - "security" => [ - [ - "basicAuth" => [] - ] - ] - ] - ]; - - $components["Token"] = [ - "type" => "object", - "properties" => [ - "token" => [ - "type" => "string" - ], - "expires" => [ - "type" => "integer" - ] - ], - "additionalProperties" => false - ]; - $components["TokenRequest"] = [ - "type" => "array", - "items" => [ - "type" => "string", - "example" => "role.all" - ] - ]; - - $components["ObjectRequest"] = [ - "type" => "object", - "properties" => [ - "expand" => [ - "type" => "string", - ], - "expires" => [ - "type" => "integer" - ] - ], - "additionalProperties" => false - ]; - - $components["ObjectListRequest"] = [ - "type" => "object", - "properties" => [ - "expand" => [ - "type" => "string", - ], - "filter" => [ - "type" => "array", - "items" => [ - "type" => "string", - "example" => "", - ] - ] - ], - "additionalProperties" => false - ]; - //Hard coded headers for the importfile endpoints. - $paths["/api/v2/helper/importFile"]["post"]["parameters"] = [ - [ - "name" => "Upload-Metadata", - "in" => "header", - "required" => true, - "schema" => [ - "type" => "string", - "pattern" => '^([a-zA-Z0-9]+ [A-Za-z0-9+/=]+)(,[a-zA-Z0-9]+ [A-Za-z0-9+/=]+)*$' - ], - "example" => "filename ZXhhbXBsZS50eHQ=", - "description" => " The Upload-Metadata header contains one or more comma-separated key-value pairs. - Each pair is formatted as ` `, where: - - `key` is a string without spaces. - - `value` is base64-encoded" - ], - [ - "name" => "Upload-Length", - "in" => "header", - "schema" => [ - "type" => "integer", - "minimum" => 1 - ], - "example" => 10000, - "description" => "The total size of the upload in bytes. Must be a positive integer. - Required if `Upload-Defer-Length` is not set." - ], - [ - "name" => "Upload-Defer-Length", - "in" => "header", - "schema" => [ - "type" => "integer", - ], - "example" => 1, - "description" => "Indicates that the upload length is not known at creation time. - Value must be `1`. If present, `Upload-Length` must be omitted." - ] - ]; - - $paths["/api/v2/helper/importFile/{id}"]["head"]["parameters"] = [ - [ - "name" => "id", - "in" => "path", - "required" => true, - "schema" => [ - "type"=> "string", - "pattern"=> "^[0-9]{14}-[0-9a-f]{32}$" - ] - ] - ]; - $paths["/api/v2/helper/importFile/{id}"]["delete"]["parameters"] = [ - [ - "name" => "id", - "in" => "path", - "required" => true, - "schema" => [ - "type"=> "string", - "pattern"=> "^[0-9]{14}-[0-9a-f]{32}$" - ] - ] - ]; - $paths["/api/v2/helper/importFile/{id}"]["patch"]["parameters"] = [ - [ - "name" => "Upload-Offset", - "in" => "header", - "required" => true, - "schema" => [ - "type" => "integer", - ], - "example" => 512, - "description" => " The Upload-Offset header’s value MUST be equal to the current offset of the resource" - ], - [ - "name" => "id", - "in" => "path", - "required" => true, - "schema" => [ - "type"=> "string", - "pattern"=> "^[0-9]{14}-[0-9a-f]{32}$" - ] - ], - [ - "name" => "Content-Type", - "in" => "header", - "required" => true, - "schema" => [ - "type" => "string", - "enum" => ["application/offset+octet-stream"] - ], - ], - ]; - $paths["/api/v2/helper/importFile/{id}"]["patch"]["requestBody"] = [ - "required" => true, - "description" => "The binary data to push to the file", - "content" => [ - "application/offset+octet-stream" => [ - "schema" => [ - "type" => "string", - "format" => "binary" - ] - ] - ] - ]; - - $paths["/api/v2/helper/importFile/{id}"]["head"]["responses"]["200"] = [ - "description" => "successful request", - "headers" => [ - "Tus-Resumable" => OpenAPISchemaUtils::getTUSHeader(), - "Upload-Offset" => [ - "description" => "Number of bytes already received", - "schema" => [ - "type" => "integer" - ] - ], - "Upload-Length" => [ - "description" => "Total upload length (if known)", - "schema" => [ - "type" => "integer" - ], - ], - "Upload-Defer-Length" => [ - "description" => "Indicates deferred upload length (if applicable)", - "schema" => [ - "type" => "string" - ], - ], - "Upload-Metadata" => [ - "description" => "Original metadata sent during creation", - "schema" => [ - "type" => "string" - ] - ] - ] - ]; - $paths["/api/v2/helper/importFile/{id}"]["delete"]["responses"]["204"] = [ - "description" => "successful operation" - ]; - - $paths["/api/v2/helper/importFile"]["post"]["responses"]["201"] = [ - "description" => "successful operation", - "headers" => [ - "Tus-Resumable" => OpenAPISchemaUtils::getTUSHeader(), - "Location" => [ - "description" => "Location of the file where the user can push to.", - "schema" => [ - "type" => "string" - ] - ] - ], - "content" => [ - "application/pdf" => [ - "schema" => [ - "type" => "string", - "format" => "binary" - ] - ] - ] - ]; - $paths["/api/v2/helper/importFile/{id}"]["patch"]["responses"]["204"] = [ - "description" => "Chunk accepted", - "headers" => [ - "Tus-Resumable" => OpenAPISchemaUtils::getTUSHeader(), - "Upload-Offset" => [ - "description" => "The new offset after the chunk is accepted. Indicates how many bytes were received so far.", - "schema" => [ - "type" => "integer" - ] - ] - ] - ]; - /** - * Build final result - */ - $unique_all_scopes = array_unique($all_scopes); - asort($unique_all_scopes); - $result = [ - "openapi" => "3.0.1", - "info" => [ - "title" => "Hashtopolis API", - "version" => "v2" - ], - "servers" => [ - [ - "url" => "/" - ], - ], - "paths" => $paths, - "components" => [ - "schemas" => $components, - "securitySchemes" => [ - "bearerAuth" => [ - "type" => "http", - "description" => "JWT Authorization header using the Bearer scheme. Allowing the following scopes: " . implode(",
", $unique_all_scopes), - "scheme" => "bearer", - "bearerFormat" => "JWT", - ], - "basicAuth" => [ - "type" => "http", - "description" => "Basic Authorization header.", - "scheme" => "basic" - ] - ] - ], - ]; - $body = $response->getBody(); $body->write(json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); - + return $response->withStatus(200) ->withHeader("Content-Type", "application/json"); }); diff --git a/src/inc/apiv2/helper/GetUserPermissionHelperAPI.php b/src/inc/apiv2/helper/GetUserPermissionHelperAPI.php index 373b8c805..e8991aede 100644 --- a/src/inc/apiv2/helper/GetUserPermissionHelperAPI.php +++ b/src/inc/apiv2/helper/GetUserPermissionHelperAPI.php @@ -80,9 +80,12 @@ static public function register($app): void { /** * getAccessGroups is different because it returns via another function + * + * Must match the schema prefix derived from the model API class name + * (GlobalPermissionGroupAPI), not the DBA model name (RightGroup). */ public static function getResponse(): string { - return "RightGroup"; + return "GlobalPermissionGroup"; } } diff --git a/src/inc/apiv2/model/AgentAPI.php b/src/inc/apiv2/model/AgentAPI.php index 0e83690f1..4c10df252 100644 --- a/src/inc/apiv2/model/AgentAPI.php +++ b/src/inc/apiv2/model/AgentAPI.php @@ -27,21 +27,24 @@ use Hashtopolis\inc\HTException; use Hashtopolis\inc\SConfig; use Hashtopolis\inc\Util; -use Psr\Container\ContainerInterface; /** * @extends AbstractModelAPI */ class AgentAPI extends AbstractModelAPI { - private bool $hideIpInfo; + private ?bool $hideIpInfo = null; /** - * @throws Exception + * Read once and cached, but only when data is actually filtered: the OpenAPI + * generator instantiates this class purely to introspect it and has no + * database connection. */ - public function __construct(ContainerInterface $container) { - parent::__construct($container); - $this->hideIpInfo = SConfig::getInstance()->getVal(DConfig::HIDE_IP_INFO) === "1"; + private function hideIpInfo(): bool { + if ($this->hideIpInfo === null) { + $this->hideIpInfo = SConfig::getInstance()->getVal(DConfig::HIDE_IP_INFO) === "1"; + } + return $this->hideIpInfo; } public static function getBaseUri(): string { @@ -105,7 +108,7 @@ function aggregateData(AbstractModel $object, array &$includedData = [], ?array } protected function filterData(array $object): array { - if ($this->hideIpInfo && isset($object[Agent::LAST_IP])) { + if ($this->hideIpInfo() && isset($object[Agent::LAST_IP])) { $object[Agent::LAST_IP] = "Hidden"; } return $object; diff --git a/src/inc/apiv2/openapi/FeatureTypeMapper.php b/src/inc/apiv2/openapi/FeatureTypeMapper.php new file mode 100644 index 000000000..44a6a5914 --- /dev/null +++ b/src/inc/apiv2/openapi/FeatureTypeMapper.php @@ -0,0 +1,133 @@ +typeLookup(['type' => $feature['subtype'], 'choices' => 'unset'])['type']; + } + } + elseif ($feature['type'] == 'array') { + $type = "array"; + $sub_type = "integer"; //TODO: subtype is hardcoded because we only have int arrays + } + elseif ($feature['type'] == 'bool') { + $type = "boolean"; + } + elseif (str_starts_with($feature['type'], 'str(')) { + $type = "string"; + } + elseif ($feature['type'] == 'str') { + $type = "string"; + } + else { + throw new HttpErrorException("Cast for type '" . $feature['type'] . "' not implemented"); + } + + if (is_array($feature['choices'])) { + $type_enum = array_keys($feature['choices']); + } + + return [ + "type" => $type, + "type_format" => $type_format, + "type_enum" => $type_enum, + "type_enum_labels" => $type_enum !== null ? array_values($feature['choices']) : null, + "subtype" => $sub_type + ]; + } + + /** + * Turns a map of sample values (the getResponse() of a helper) into the + * schema properties describing it. + */ + public function mapToProperties($map): array { + return array_map(function ($value) { + if (is_int($value)) { + $type = "integer"; + } elseif (is_float($value)) { + $type = "number"; + } elseif (is_bool($value)) { + $type = "boolean"; + } elseif (is_array($value) || is_object($value)) { + $type = "object"; + } else { + $type = "string"; + } + return [ + "type" => $type, + "default" => $value, + ]; + }, $map); + } + + /** + * @throws HttpErrorException + */ + public function makeProperties($features, $skipPK = false): array { + $propertyVal = []; + foreach ($features as $feature) { + if ($skipPK && $feature['pk']) { + continue; + } + $ret = $this->typeLookup($feature); + $isNullable = $feature['null'] ?? false; + if ($ret["type_enum"] !== null && $ret["type_enum_labels"] !== null) { + $oneOfItems = []; + foreach ($ret["type_enum"] as $i => $val) { + $item = ["const" => $val, "title" => $ret["type_enum_labels"][$i], "type" => $ret["type"]]; + if ($ret["type_format"] !== null) { + $item["format"] = $ret["type_format"]; + } + $oneOfItems[] = $item; + } + if ($isNullable) { + $oneOfItems[] = ["type" => "null"]; + } + $propertyVal[$feature['alias']] = ["oneOf" => $oneOfItems]; + } else { + $propertyVal[$feature['alias']]["type"] = $isNullable ? [$ret["type"], "null"] : $ret["type"]; + if ($ret["type_format"] !== null) { + $propertyVal[$feature['alias']]["format"] = $ret["type_format"]; + } + if ($ret["type_enum"] !== null) { + $propertyVal[$feature['alias']]["enum"] = $ret["type_enum"]; + } + } + if ($ret["subtype"] !== null) { + if ($ret["type"] === "object") { + $propertyVal[$feature['alias']]["additionalProperties"]["type"] = $ret["subtype"]; + } else { + $propertyVal[$feature['alias']]["items"]["type"] = $ret["subtype"]; + } + } + } + return $propertyVal; + } +} diff --git a/src/inc/apiv2/openapi/HelperApiPathBuilder.php b/src/inc/apiv2/openapi/HelperApiPathBuilder.php new file mode 100644 index 000000000..e033d649a --- /dev/null +++ b/src/inc/apiv2/openapi/HelperApiPathBuilder.php @@ -0,0 +1,106 @@ +pattern; + $method = $target->httpMethod; + $apiMethod = $target->methodName; + $class = $api; + + $name = $class::class; + $apiMethod = ($apiMethod == "processPost" && $name != "ImportFileHelperAPI") ? "actionPost" : $apiMethod; + $reflectionApiMethod = new ReflectionMethod($name, $apiMethod); + $paths[$path][$method]["description"] = $this->parsePhpDoc($reflectionApiMethod->getDocComment()); + $parameters = $class->getCreateValidFeatures(); + $properties = $this->typeMapper->makeProperties($parameters); + $components[$name] = + [ + "type" => "object", + "properties" => $properties, + ]; + /** + * Helpers run behind the same authentication and permission checks as the + * model routes and resolve the objects they act on, so they answer the same + * errors. + */ + $paths[$path][$method]["responses"] = $this->jsonApiFragments->commonErrorResponses(); + $paths[$path][$method]["responses"]["404"] = $this->jsonApiFragments->problemResponse("Not Found"); + + if ($method == "post") { + $reflectionMethodFormFields = new ReflectionMethod($name, "getFormFields"); + $bodyDescription = $this->parsePhpDoc($reflectionMethodFormFields->getDocComment()); + /** + * A helper takes a flat map of its form fields, not a JSON:API document, + * so its request body is plain application/json. + */ + $paths[$path][$method]["requestBody"] = [ + "description" => $bodyDescription, + "required" => true, + "content" => [ + "application/json" => [ + "schema" => [ + '$ref' => "#/components/schemas/" . $name + ], + ] + ] + ]; + } + elseif ($method == "get") { + $paths[$path][$method]["parameters"] = $class->getParamsSwagger(); + } + $request_response = $class->getResponse(); + $ref = null; + if (is_array($request_response)) { + $components[$name . "Response"] = $this->jsonApiFragments->buildMetaResponse( + $this->typeMapper->mapToProperties($request_response) + ); + $ref = "#/components/schemas/" . $name . "Response"; + } + else if (is_string($request_response)) { + $ref = "#/components/schemas/" . $request_response . "SingleResponse"; + } + else if ($name == "ImportFileHelperAPI") { + //ImportFileHelperAPI is hardcoded, because its different than other helpers. + return; + } + if (isset($ref)) { + $paths[$path][$method]["responses"]["200"] = $this->jsonApiFragments->jsonApiResponse("successful operation", $ref); + } + else { + $paths[$path][$method]["responses"]["200"] = [ + "description" => "successful operation", + ]; + } + } + + private function parsePhpDoc($doc): string { + $cleanedDoc = preg_replace([ + '/^\/\*\*/', // Remove opening /** + '/\*\/$/', // Remove closing */ + '/^\s*\*\s?/m' // Remove leading * on each line + ], '', $doc); + //annotation lines (@param, @return, @throws, ...) document the PHP signature, not the endpoint + $prose = array_filter( + array_map('rtrim', explode("\n", $cleanedDoc)), + fn($line) => !str_starts_with(ltrim($line), '@') + ); + //markdown friendly line end + return str_replace("\n", "
", trim(implode("\n", $prose))); + } +} diff --git a/src/inc/apiv2/openapi/JsonApiFragments.php b/src/inc/apiv2/openapi/JsonApiFragments.php new file mode 100644 index 000000000..9cbe7fdfa --- /dev/null +++ b/src/inc/apiv2/openapi/JsonApiFragments.php @@ -0,0 +1,403 @@ + "integer", + "example" => 1 + ]; + } + + /** + * A response carrying the JSON:API document described by $schemaRef. + */ + public function jsonApiResponse(string $description, string $schemaRef): array { + return [ + "description" => $description, + "content" => [ + self::MEDIA_TYPE => [ + "schema" => ['$ref' => $schemaRef] + ] + ] + ]; + } + + /** + * A required request body carrying the JSON:API document described by $schemaRef. + */ + public function jsonApiRequestBody(string $schemaRef): array { + return [ + "required" => true, + "content" => [ + self::MEDIA_TYPE => [ + "schema" => ['$ref' => $schemaRef] + ] + ] + ]; + } + + /** + * A single RFC 7807 problem response. + */ + public function problemResponse(string $description): array { + return [ + "description" => $description, + "content" => [ + self::PROBLEM_MEDIA_TYPE => [ + "schema" => ['$ref' => "#/components/schemas/ErrorResponse"] + ] + ] + ]; + } + + /** + * The errors every authenticated APIv2 route can answer with: 400 on a + * malformed request, 401 without a usable token and 403 when the token lacks + * the permission the route requires. + */ + public function commonErrorResponses(): array { + return [ + "400" => $this->problemResponse("Invalid request"), + "401" => $this->problemResponse("Authentication failed"), + "403" => $this->problemResponse("Permission denied") + ]; + } + + // "jsonapi": { + // "version": "1.1", + // "ext": [ + // "https://jsonapi.org/profiles/ethanresnick/cursor-pagination" + // ] + // }, + public function makeJsonApiHeader(): array { + return ["jsonapi" => [ + "type" => "object", + "required" => ["version"], + "properties" => [ + "version" => [ + "type" => "string", + "default" => "1.1" + ], + "ext" => [ + "type" => "array", + "items" => ["type" => "string"], + "default" => ["https://jsonapi.org/profiles/ethanresnick/cursor-pagination"] + ] + ] + ] + ]; + } + + // "links": { + // "self": "/api/v2/ui/hashlists?page[size]=10000", + // "first": "/api/v2/ui/hashlists?page[size]=10000&page[after]=0", + // "last": "/api/v2/ui/hashlists?page[size]=10000&page[before]=345", + // "next": null, + // "prev": "/api/v2/ui/hashlists?page[size]=10000&page[before]=114" + // }, + public function makeLinks($uri): array { + $self = $uri . "?page[size]=25"; + $cursor = "eyJwcmltYXJ5Ijp7InNvbWVVbnFpdWVGaWVsZCI6MTIzfSwic2Vjb25kYXJ5Ijp7InNvbWVPdGhlck9wdGlvbmFsRmllbGQiOiJGb28ifX0="; + return ["links" => [ + "type" => "object", + "required" => ["self", "first", "last", "next", "prev"], + "properties" => [ + "self" => [ + "type" => "string", + "default" => $self + ], + "first" => [ + "type" => "string", + "default" => $self + ], + "last" => [ + "type" => ["string", "null"], + "default" => $self . "&page[before]=" . $cursor + ], + "next" => [ + "type" => ["string", "null"], + "default" => $self . "&page[after]=" . $cursor + ], + "prev" => [ + "type" => ["string", "null"], + "default" => $self . "&page[before]=" . $cursor + ] + ] + ] + ]; + } + + /** + * The links member of a single resource document, which carries the self link + * of the request and nothing else (see AbstractBaseAPI::getOneResource). + */ + public function makeSelfLink(string $uri): array { + return ["links" => [ + "type" => "object", + "required" => ["self"], + "properties" => [ + "self" => [ + "type" => "string", + "default" => $uri . "/1" + ] + ] + ] + ]; + } + + /** + * The meta member of a collection document: the total number of elements the + * filtered collection holds. + */ + public function makeListMeta(): array { + return ["meta" => [ + "type" => "object", + "required" => ["page"], + "properties" => [ + "page" => [ + "type" => "object", + "required" => ["total_elements"], + "properties" => [ + "total_elements" => [ + "type" => "integer" + ] + ] + ] + ] + ] + ]; + } + + /** + * The document the /count route answers with: the number of matching objects + * under meta, with an empty data member (see AbstractModelAPI::count). + */ + public function buildCountResponse(): array { + return [ + "type" => "object", + "required" => ["jsonapi", "meta", "data"], + "properties" => array_merge( + $this->makeJsonApiHeader(), + [ + "meta" => [ + "type" => "object", + "required" => ["count"], + "properties" => [ + "count" => [ + "type" => "integer", + "description" => "Number of objects matching the given filters" + ], + "total_count" => [ + "type" => "integer", + "description" => "Number of objects without any filter applied, only present when `include_total=true` was requested" + ] + ] + ], + "data" => [ + "type" => "array", + "items" => [ + "type" => "object" + ], + "maxItems" => 0, + "description" => "Always empty: the count is reported under meta." + ] + ] + ) + ]; + } + + public function buildPatchPost($properties, $name, $id = null, $requiredAttributes = null): array { + $required = ["type", "attributes"]; + if ($id) { + $required[] = "id"; + } + $attributesSchema = [ + "type" => "object", + "properties" => $properties + ]; + if ($requiredAttributes !== null && count($requiredAttributes) > 0) { + $attributesSchema["required"] = $requiredAttributes; + } + $result = ["data" => [ + "type" => "object", + "required" => $required, + "properties" => [ + "type" => [ + "type" => "string", + "const" => $name + ], + "attributes" => $attributesSchema + ] + ] + ]; + + if ($id) { + $result["data"]["properties"]["id"] = $this->resourceIdSchema(); + } + return $result; + } + + /** + * The document a helper answers with when its action returns a map instead of + * an object: AbstractBaseAPI::getMetaResponse puts that map under "meta" and + * leaves data empty. + */ + public function buildMetaResponse(array $metaProperties): array { + return [ + "type" => "object", + "required" => ["jsonapi", "meta", "data"], + "properties" => array_merge( + $this->makeJsonApiHeader(), + [ + "meta" => [ + "type" => "object", + "properties" => $metaProperties + ], + "data" => [ + "type" => "array", + "items" => [ + "type" => "object" + ], + "maxItems" => 0, + "description" => "Always empty: a helper answers with meta only." + ] + ] + ) + ]; + } + + /** + * The write envelope of the collection level patch and delete routes. Unlike + * the single object routes these carry a list of resource records as data, + * each identified by its own id. Attributes are only part of a patch, a + * delete identifies the objects to remove and nothing else. + */ + public function buildMultipleWriteEnvelope(string $name, ?array $properties = null): array { + $required = ["id", "type"]; + $recordProperties = [ + "id" => $this->resourceIdSchema(), + "type" => [ + "type" => "string", + "const" => $name + ] + ]; + if ($properties !== null) { + $required[] = "attributes"; + $recordProperties["attributes"] = [ + "type" => "object", + "properties" => $properties + ]; + } + + return ["data" => [ + "type" => "array", + "items" => [ + "type" => "object", + "required" => $required, + "properties" => $recordProperties + ] + ] + ]; + } + + /** + * This function builds the post/patch attributes for a relationship. When $istomany is false, + * it would build the attributes for a to one relationship. If it is true it will build it for a too many relationship. + * */ + public function buildPostPatchRelation($name, $isToMany): array { + $resourceRecord = [ + "type" => "object", + "required" => ["type", "id"], + "properties" => [ + "type" => [ + "type" => "string", + "const" => $name + ], + "id" => $this->resourceIdSchema() + ] + ]; + if ($isToMany) { + return ["data" => [ + "type" => "array", + "items" => $resourceRecord + ] + ]; + } + else { + return ["data" => $resourceRecord]; + } + } + + public function makeDescription($isRelation, $method, $singleObject): string { + $description = ""; + switch ($method) { + case "get": + if ($isRelation) { + if ($singleObject) { + $description = "GET request for for a to-one relationship link. Returns the resource record of the object that is part of the specified relation."; + } + else { + $description = "GET request for a to-many relationship link. Returns a list of resource records of objects that are part of the specified relation."; + } + } + else { + if ($singleObject) { + $description = "GET request to retrieve a single object."; + } + else { + $description = "GET many request to retrieve multiple objects."; + } + } + break; + case "post": + if ($isRelation) { + if ($singleObject) { + $description = "POST request to create a to-one relationship link."; + } + else { + $description = "POST request to create a to-many relationship link."; + } + } + else { + $description = "POST request to create a new object. The request must contain the resource record as data with the attributes of the new object." + . "To add relationships, a relationships object can be added with the resource records of the relations that are part of this object."; + } + break; + case "patch": + if ($isRelation) { + if ($singleObject) { + $description = "PATCH request to update a to one relationship."; + } + else { + $description = "PATCH request to update a to-many relationship link."; + } + } + else { + $description = "PATCH request to update attributes of a single object."; + } + } + return $description; + } +} diff --git a/src/inc/apiv2/openapi/ModelApiPathBuilder.php b/src/inc/apiv2/openapi/ModelApiPathBuilder.php new file mode 100644 index 000000000..16264e619 --- /dev/null +++ b/src/inc/apiv2/openapi/ModelApiPathBuilder.php @@ -0,0 +1,731 @@ +pattern; + $method = $target->httpMethod; + $class = $api; + + /* Quick to find out if single parameter object is used */ + $singleObject = ((strstr($path, '/{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 + $typeName = lcfirst($name); + $uri = $class->getBaseUri(); + + $isRelation = (strstr($path, "/relationships/")) !== false; + if (str_contains($path, "relation:")) { + $relation = rtrim(explode("relation:", $path)[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."); + } else { + $isToMany = $isToOne = false; + $relation = null; + } + + $expandables = implode(",", $class->getExpandables()); + /** + * Create component objects + */ + if (!array_key_exists($name, $components)) { + $responseFeatures = array_filter($class->getFeaturesWithoutFormfields(), fn($f) => !$f['private']); + $responseAttributeProperties = $this->typeMapper->makeProperties($responseFeatures, true); + $attributesSchema = [ + "type" => "object", + "required" => array_values(array_map( + fn($f) => $f['alias'], + array_filter($responseFeatures, fn($f) => !$f['pk']) + )), + "properties" => $responseAttributeProperties + ]; + /** + * The resource object as AbstractBaseAPI::obj2Resource builds it: the + * primary key becomes the id, the remaining features the attributes, and + * both the self link and the relationships are part of the resource + * object itself rather than of the document around it. + */ + $resourceObjectRequired = ["id", "type", "attributes", "links"]; + $resourceObjectProperties = [ + "id" => $this->jsonApiFragments->resourceIdSchema(), + "type" => [ + "type" => "string", + "const" => $typeName + ], + "attributes" => $attributesSchema, + "links" => [ + "type" => "object", + "required" => ["self"], + "properties" => [ + "self" => [ + "type" => "string", + "default" => $uri . "/1" + ] + ] + ] + ]; + + $relationshipProperties = $this->makeRelationships($class, $uri, $container); + if (count($relationshipProperties) > 0) { + $resourceObjectRequired[] = "relationships"; + $resourceObjectProperties["relationships"] = [ + "type" => "object", + "required" => array_keys($relationshipProperties), + "properties" => $relationshipProperties + ]; + } + + $resourceObject = [ + "type" => "object", + "required" => $resourceObjectRequired, + "properties" => $resourceObjectProperties + ]; + + $expandables = $this->makeExpandables($class, $container); + /** + * A model without relationships has nothing to include, so it must not + * carry an "included" member: an empty oneOf is not a valid schema. + */ + $included = []; + if (count($expandables) > 0) { + $includedItems = count($expandables) === 1 + ? array_merge(["type" => "object"], $expandables[0]) + : [ + "oneOf" => array_map( + fn($e) => array_merge(["type" => "object"], $e), + $expandables + ), + "discriminator" => ["propertyName" => "type"] + ]; + $included = ["included" => [ + "type" => "array", + "items" => $includedItems, + ] + ]; + } + + $json_api_header = $this->jsonApiFragments->makeJsonApiHeader(); + + /** + * A single resource document carries only the self link, a collection + * document the full set of cursor pagination links plus the element count + * (see AbstractBaseAPI::getOneResource and AbstractModelAPI::get). + */ + $properties_get_single = array_merge( + $json_api_header, + $this->jsonApiFragments->makeSelfLink($uri), + ["data" => $resourceObject], + $included + ); + $properties_get_list = array_merge( + $json_api_header, + $this->jsonApiFragments->makeLinks($uri), + $this->jsonApiFragments->makeListMeta(), + ["data" => [ + "type" => "array", + "items" => $resourceObject + ] + ], + $included + ); + + $createFeatures = $class->getAllPostParameters($class->getCreateValidFeatures()); + $requiredCreateAttributes = array_values(array_map( + fn($f) => $f['alias'], + array_filter($createFeatures, fn($f) => !$f['null']) + )); + $properties_create = $this->jsonApiFragments->buildPatchPost($this->typeMapper->makeProperties($createFeatures), $typeName, null, $requiredCreateAttributes); + $properties_patch = $this->jsonApiFragments->buildPatchPost($this->typeMapper->makeProperties($class->getPatchValidFeatures(), true), $typeName); + + $components[$name . "Create"] = + [ + "type" => "object", + "required" => ["data"], + "properties" => $properties_create, + ]; + + $components[$name . "Patch"] = + [ + "type" => "object", + "required" => ["data"], + "properties" => $properties_patch, + ]; + + $components[$name . "PatchMultiple"] = + [ + "type" => "object", + "required" => ["data"], + "properties" => $this->jsonApiFragments->buildMultipleWriteEnvelope( + $typeName, + $this->typeMapper->makeProperties($class->getPatchValidFeatures(), true) + ), + ]; + + $components[$name . "DeleteMultiple"] = + [ + "type" => "object", + "required" => ["data"], + "properties" => $this->jsonApiFragments->buildMultipleWriteEnvelope($typeName), + ]; + + /** + * Reading one object, creating one and updating one all answer with the + * same single resource document (AbstractBaseAPI::getOneResource), so all + * three schemas share one shape. + */ + $singleDocument = [ + "type" => "object", + "required" => ["jsonapi", "links", "data"], + "properties" => $properties_get_single + ]; + + $components[$name . "Response"] = $singleDocument; + + $this->addRelationComponents($name, $relation, ($isToMany && !$isToOne), $components); + + $components[$name . "SingleResponse"] = $singleDocument; + + $components[$name . "PostPatchResponse"] = $singleDocument; + + $components[$name . "ListResponse"] = + [ + "type" => "object", + "required" => ["jsonapi", "links", "meta", "data"], + "properties" => $properties_get_list, + ]; + + $components[$name . "CountResponse"] = $this->jsonApiFragments->buildCountResponse(); + } + + /** + * Create path objects + */ + + /* Determine the scopes required for the call */ + $required_scopes = $class->getRequiredPermissions($method); + array_push($all_scopes, ...$required_scopes); + + $paths[$path][$method] = [ + "tags" => [ + $name . 's' + ], + "responses" => $this->jsonApiFragments->commonErrorResponses(), + "security" => [ + [ + "bearerAuth" => [ + $required_scopes + ] + ] + ] + ]; + + $paths[$path][$method]["description"] = $this->jsonApiFragments->makeDescription($isRelation, $method, $singleObject); + + if ($isRelation && in_array($method, ["post", "patch", "delete"], true)) { + $paths[$path][$method]["responses"]["204"] = + [ + "description" => "Successfull operation" + ]; + } + if ($singleObject) { + /* Single objects could not exists */ + $paths[$path][$method]["responses"]["404"] = $this->jsonApiFragments->problemResponse("Not Found"); + + /* Method specific responses and requests for single objects */ + if ($method == 'get') { + if (!$isRelation && str_contains($path, "relation:")) { + $paths[$path][$method]["responses"]["200"] = $this->jsonApiFragments->jsonApiResponse( + "successful operation", + "#/components/schemas/" . $name . "Relation" . ucfirst($relation) . "GetResponse" + ); + } + else { + $paths[$path][$method]["responses"]["200"] = $this->jsonApiFragments->jsonApiResponse( + "successful operation", + "#/components/schemas/" . $name . "Response" + ); + } + + /* Supported by client, not by browser, disabled for APIdocs */ + // /* JSON object required */ + // $paths[$path][$method]["requestBody"] = [ + // "required" => true, + // "content" => [ + // "application/json" => [ + // "schema" => [ + // '$ref' => "#/components/schemas/ObjectRequest" + // ], + // ], + // ]]; + + } + elseif ($method == 'patch') { + /* A rename can collide with an existing object */ + $paths[$path][$method]["responses"]["409"] = $this->jsonApiFragments->problemResponse("Resource already exists"); + + if ($isRelation) { + $paths[$path][$method]["requestBody"] = $this->jsonApiFragments->jsonApiRequestBody( + "#/components/schemas/" . $name . "Relation" . ucfirst($relation) + ); + } + else { + $paths[$path][$method]["requestBody"] = $this->jsonApiFragments->jsonApiRequestBody( + "#/components/schemas/" . $name . "Patch" + ); + + $paths[$path][$method]["responses"]["200"] = $this->jsonApiFragments->jsonApiResponse( + "successful operation", + "#/components/schemas/" . $name . "PostPatchResponse" + ); + } + } + elseif ($method == 'delete') { + $paths[$path][$method]["responses"]["204"] = [ + "description" => "successfully deleted", + ]; + + if ($isRelation) { + $paths[$path][$method]["requestBody"] = $this->jsonApiFragments->jsonApiRequestBody( + "#/components/schemas/" . $name . "Relation" . ucfirst($relation) + ); + } + /* deleteOne identifies the object by its path id and reads no body */ + } + elseif ($method == 'post') { + $paths[$path][$method]["responses"]["204"] = [ + "description" => "successfully created", + ]; + /* Linking a relation that already exists is a conflict */ + $paths[$path][$method]["responses"]["409"] = $this->jsonApiFragments->problemResponse("Resource already exists"); + + /* The resource identifiers to link are sent as data */ + $paths[$path][$method]["requestBody"] = $this->jsonApiFragments->jsonApiRequestBody( + "#/components/schemas/" . $name . "Relation" . ucfirst($relation) + ); + } + else { + throw new HttpErrorException("Method '$method' not implemented"); + } + } + else { + /* Model API entry point */ + if ($method == 'get') { + /* The /count route reports the number of matches under meta, it returns no objects */ + $paths[$path][$method]["responses"]["200"] = $isCount + ? $this->jsonApiFragments->jsonApiResponse( + "successful operation", + "#/components/schemas/" . $name . "CountResponse" + ) + : $this->jsonApiFragments->jsonApiResponse( + "successful operation", + "#/components/schemas/" . $name . "ListResponse" + ); + + /* Supported by client, not by browser, disabled for APIdocs */ + // $paths[$path][$method]["requestBody"] = [ + // "content" => [ + // "application/json" => [ + // "schema" => [ + // '$ref' => "#/components/schemas/ObjectListRequest" + // ], + // ] + // ]]; + + + } + elseif ($method == 'post') { + $paths[$path][$method]["responses"]["201"] = $this->jsonApiFragments->jsonApiResponse( + "successful operation", + "#/components/schemas/" . $name . "PostPatchResponse" + ); + /* Creating an object whose unique attributes are taken is a conflict */ + $paths[$path][$method]["responses"]["409"] = $this->jsonApiFragments->problemResponse("Resource already exists"); + + if ($isRelation) { + $paths[$path][$method]["requestBody"] = $this->jsonApiFragments->jsonApiRequestBody( + "#/components/schemas/" . $name . "Relation" . ucfirst($relation) + ); + } + else { + $paths[$path][$method]["requestBody"] = $this->jsonApiFragments->jsonApiRequestBody( + "#/components/schemas/" . $name . "Create" + ); + } + + } + elseif ($method == 'patch') { + /** + * patchMultiple: the resource records to update are sent as data, the + * updated objects are not returned (see AbstractModelAPI::patchMultiple). + */ + $paths[$path][$method]["responses"]["204"] = [ + "description" => "successfully updated", + ]; + $paths[$path][$method]["responses"]["404"] = $this->jsonApiFragments->problemResponse("Not Found"); + $paths[$path][$method]["responses"]["409"] = $this->jsonApiFragments->problemResponse("Resource already exists"); + $paths[$path][$method]["requestBody"] = $this->jsonApiFragments->jsonApiRequestBody( + "#/components/schemas/" . $name . "PatchMultiple" + ); + } + elseif ($method == 'delete') { + /** + * deleteMultiple: the resource identifiers to delete are sent as data + * (see AbstractModelAPI::deleteMultiple). + */ + $paths[$path][$method]["responses"]["204"] = [ + "description" => "successfully deleted", + ]; + $paths[$path][$method]["responses"]["404"] = $this->jsonApiFragments->problemResponse("Not Found"); + $paths[$path][$method]["requestBody"] = $this->jsonApiFragments->jsonApiRequestBody( + "#/components/schemas/" . $name . "DeleteMultiple" + ); + } + else { + throw new HttpErrorException("Method '$method' not implemented"); + } + } + + if ($singleObject && $method == 'get') { + $parameters = [ + [ + "name" => "id", + "in" => "path", + "required" => true, + "schema" => [ + "type" => "integer", + "format" => "int32", + "example" => 10, + ] + ] + ]; + + if (!str_contains($path, "relation:")) { + $parameters[] = $this->makeIncludeParameter($class); + }; + } + else { + if ($method == 'get') { + $primaryKey = array_find(call_user_func($class->getDBAclass() . '::getFeatures'), function (array $feature) { return $feature["pk"] === true; }); + $exampleCursor = "{\"primary\":{\"" . ($primaryKey['alias'] ?? "id") . "\": 123}}"; + /** + * The /count route counts the objects matching the filters, so it takes + * the filters but neither pagination nor include (AbstractModelAPI::count). + */ + if ($isCount) { + $paths[$path][$method]["parameters"] = [ + $this->makeFilterParameter($primaryKey), + [ + "name" => "include_total", + "in" => "query", + "schema" => [ + "type" => "boolean" + ], + "example" => true, + "description" => "Also report the number of objects without any filter applied, as `meta.total_count`" + ] + ]; + return; + } + $parameters = [ + [ + "name" => "page[after]", + "in" => "path", + "schema" => [ + "type" => "string", + "format" => "byte", + ], + "example" => base64_encode($exampleCursor), + "description" => "Pointer to paginate to retrieve the data after the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting. + \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}` + \n\nExample: `$exampleCursor` -> `" . (base64_encode($exampleCursor)) . "`" + ], + [ + "name" => "page[before]", + "in" => "path", + "schema" => [ + "type" => "string", + "format" => "byte", + ], + "example" => base64_encode($exampleCursor), + "description" => "Pointer to paginate to retrieve the data before the object provided. Specify the `base64` encoded JSON string in a **uniquely identifiable** manner (e.g. object IDs), i.e. by using one (primary) or two (primary and secondary) fields that allow for **stable** sorting. + \n\nFormat: `{\"primary\":{\"someField\": 123},\"secondary\":{\"someOtherOptionalField\": \"Foo\"}}` + \n\nExample: `$exampleCursor` -> `" . (base64_encode($exampleCursor)) . "`" + ], + [ + "name" => "page[size]", + "in" => "path", + "schema" => [ + "type" => "integer", + "format" => "int32" + ], + "example" => 100, + "description" => "Amout of data to retrieve inside a single page" + ], + $this->makeFilterParameter($primaryKey), + $this->makeIncludeParameter($class) + ]; + + $aggregateFieldsets = $class->getAggregateFieldsets(); + if (!empty($aggregateFieldsets)) { + $aggregateExamples = []; + $aggregateDescriptionParts = []; + foreach ($aggregateFieldsets as $fieldset => $options) { + if (empty($options)) { + continue; + } + $aggregateExamples["aggregate[" . $fieldset . "]"] = implode(",", array_keys($options)); + $aggregateDescriptionParts[] = $fieldset . ": " . implode(", ", array_keys($options)); + } + + if (!empty($aggregateExamples)) { + $parameters[] = [ + "name" => "aggregate", + "in" => "query", + "style" => "deepObject", + "explode" => true, + "schema" => [ + "type" => "object", + "additionalProperties" => [ + "type" => "string" + ] + ], + "required" => false, + "description" => "Aggregated fields to include by type (comma separated values). Possible options: " . implode(" | ", $aggregateDescriptionParts), + "example" => $aggregateExamples + ]; + } + } + } + else { + $parameters = []; + } + } + $paths[$path][$method]["parameters"] = $parameters; + } + + /** + * The "filter" query parameter, a deep object whose keys are attribute names + * optionally suffixed with a comparison operator. + */ + private function makeFilterParameter(?array $primaryKey): array { + $exampleKey = ($primaryKey['alias'] ?? "id") . "__gt"; + return [ + "name" => "filter", + "in" => "query", + "style" => "deepObject", + "explode" => true, + "schema" => [ + "type" => "object", + "additionalProperties" => [ + "type" => "string" + ] + ], + "description" => "Filters results using a query. Every key is an attribute name optionally suffixed with a comparison operator, e.g. `filter[" . $exampleKey . "]=200`.", + "example" => [$exampleKey => "200"] + ]; + } + + /** + * The JSON:API "include" query parameter. The API reads it as a single comma + * separated value, which is exactly how "style: form, explode: false" + * serializes a string array: `?include=a,b`. + */ + private function makeIncludeParameter($class): array { + $expandables = $class->getExpandables(); + $parameter = [ + "name" => "include", + "in" => "query", + "style" => "form", + "explode" => false, + "schema" => [ + "type" => "array", + "items" => [ + "type" => "string" + ] + ], + "description" => "Relationships to include in the response, comma seperated. Possible options: " . implode(", ", $expandables) + ]; + if (count($expandables) > 0) { + $parameter["schema"]["items"]["enum"] = array_values($expandables); + $parameter["example"] = array_slice(array_values($expandables), 0, 2); + } + return $parameter; + } + + /** + * Relation schemas only exist for relationship routes. Other routes of the + * same model carry no relation name, so nothing must be emitted for them: + * that produced a nameless "Relation" schema with a null type const. + */ + private function addRelationComponents(string $name, ?string $relation, bool $isToMany, array &$components): void { + if ($relation === null) { + return; + } + $properties = $this->jsonApiFragments->buildPostPatchRelation($relation, $isToMany); + + $components[$name . "Relation" . ucfirst($relation)] = + [ + "type" => "object", + "required" => ["data"], + "properties" => $properties, + ]; + + $components[$name . "Relation" . ucfirst($relation) . "GetResponse"] = + [ + "type" => "object", + "required" => ["data"], + "properties" => $properties + ]; + } + + private function makeRelationships($class, $uri, $container = null): array { + $toOneRelationships = $class->getToOneRelationships(); + $toManyRelationships = $class->getToManyRelationships(); + + // Legacy behavior when no container is provided + if ($container === null) { + $properties = []; + $relationshipsNames = array_merge(array_keys($toOneRelationships), array_keys($toManyRelationships)); + sort($relationshipsNames); + foreach ($relationshipsNames as $relationshipName) { + $self = $uri . "/relationships/" . $relationshipName; + $related = $uri . "/" . $relationshipName; + $properties[] = [ + "properties" => [ + $relationshipName => [ + "type" => "object", + "properties" => [ + "links" => [ + "type" => "object", + "properties" => [ + "self" => [ + "type" => "string", + "default" => $self + ], + "related" => [ + "type" => "string", + "default" => $related + ] + ] + ] + ] + ] + ] + ]; + } + return $properties; + } + + // New behavior with container: resolve relationship types + $properties = []; + $classMapper = $container->get('classMapper'); + + $allRelationships = array_merge($toOneRelationships, $toManyRelationships); + ksort($allRelationships); + + foreach ($allRelationships as $relationshipName => $relationshipConfig) { + $self = $uri . "/relationships/" . $relationshipName; + $related = $uri . "/" . $relationshipName; + $isToMany = array_key_exists($relationshipName, $toManyRelationships); + + $relationType = $relationshipConfig['relationType']; + $apiClassName = $classMapper->get($relationType); + $nameParts = explode('\\', $apiClassName); + $typeName = lcfirst(substr(end($nameParts), 0, -3)); + + $resourceIdentifier = [ + "type" => "object", + "required" => ["type", "id"], + "properties" => [ + "type" => [ + "type" => "string", + "const" => $typeName + ], + "id" => $this->jsonApiFragments->resourceIdSchema() + ] + ]; + + $dataSchema = $isToMany + ? ["type" => "array", "items" => $resourceIdentifier] + : ["oneOf" => [$resourceIdentifier, ["type" => "null"]]]; + + $properties[$relationshipName] = [ + "type" => "object", + "required" => ["links"], + "properties" => [ + "links" => [ + "type" => "object", + "required" => ["self", "related"], + "properties" => [ + "self" => [ + "type" => "string", + "default" => $self + ], + "related" => [ + "type" => "string", + "default" => $related + ] + ] + ], + "data" => $dataSchema + ] + ]; + } + return $properties; + } + + //TODO expandables array is unnecessarily indexed in the swagger UI + private function makeExpandables($class, $container): array { + $properties = []; + $expandables = array_merge($class->getToOneRelationships(), $class->getToManyRelationships()); + foreach ($expandables as $expand => $expandVal) { + $expandClass = $expandVal["relationType"]; + $expandApiClass = new ($container->get('classMapper')->get($expandClass))($container); + $nameParts = explode('\\', get_class($expandApiClass)); + $typeName = lcfirst(substr(end($nameParts), 0, -3)); + $features = array_filter($expandApiClass->getFeaturesWithoutFormfields(), fn($f) => !$f['private']); + $attrProperties = $this->typeMapper->makeProperties($features, true); + $requiredAttributes = array_values(array_map( + fn($f) => $f['alias'], + array_filter($features, fn($f) => !$f['pk']) + )); + $properties[$typeName] = [ + "required" => ["id", "type", "attributes"], + "properties" => [ + "id" => $this->jsonApiFragments->resourceIdSchema(), + "type" => [ + "type" => "string", + "const" => $typeName + ], + "attributes" => [ + "type" => "object", + "required" => $requiredAttributes, + "properties" => $attrProperties + ] + ] + ]; + }; + return array_values($properties); + } +} diff --git a/src/inc/apiv2/openapi/RouteIntrospector.php b/src/inc/apiv2/openapi/RouteIntrospector.php new file mode 100644 index 000000000..1a7290842 --- /dev/null +++ b/src/inc/apiv2/openapi/RouteIntrospector.php @@ -0,0 +1,47 @@ + + */ + public function introspect(App $app): array { + $targets = []; + $routes = $app->getRouteCollector()->getRoutes(); + foreach ($routes as $route) { + /* Quirk to receive className, since it is hidden in a protected variable */ + $reflectionOfRoute = new \ReflectionObject($route); + $protectedCallable = $reflectionOfRoute->getProperty('callable'); + $reflectionCallable = ($protectedCallable->getValue($route)); + + /* 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(); + $method = strtolower($route->getMethods()[0]); + + /* Retrieve parameters. Model API routes register array callables + [Class::class, 'method']; helper API routes register string + callables "Class:method". OPTIONS (CORS) use Closures (ignored). */ + if (is_array($reflectionCallable)) { + $apiClassName = is_object($reflectionCallable[0]) ? get_class($reflectionCallable[0]) : $reflectionCallable[0]; + $apiMethod = $reflectionCallable[1]; + } elseif (is_string($reflectionCallable)) { + $explodedCallable = explode(':', $reflectionCallable); + $apiClassName = $explodedCallable[0]; + $apiMethod = $explodedCallable[1]; + } else { + continue; + } + $targets[] = new RouteTarget($path, $method, $apiClassName, $apiMethod); + } + return $targets; + } +} diff --git a/src/inc/apiv2/openapi/RouteTarget.php b/src/inc/apiv2/openapi/RouteTarget.php new file mode 100644 index 000000000..b222922aa --- /dev/null +++ b/src/inc/apiv2/openapi/RouteTarget.php @@ -0,0 +1,16 @@ +routeIntrospector = new RouteIntrospector(); + $this->helperApiPathBuilder = new HelperApiPathBuilder($typeMapper, $jsonApiFragments); + $this->modelApiPathBuilder = new ModelApiPathBuilder($typeMapper, $jsonApiFragments); + $this->staticFragments = new StaticFragments(); + } + + /** + * Build the OpenAPI spec from the application routes. + */ + public function buildFromApp(App $app): array { + /* Hold collection of all scopes discovered */ + $all_scopes = []; + + $paths = []; + $components = $this->staticFragments->errorComponents(); + + /* Iterate over routes */ + foreach ($this->routeIntrospector->introspect($app) as $target) { + $apiClassName = $target->className; + $class = new $apiClassName($app->getContainer()); + + if (!($class instanceof AbstractModelAPI)) { + $this->helperApiPathBuilder->addRoute($target, $class, $paths, $components); + continue; + } + + $this->modelApiPathBuilder->addRoute($target, $class, $app->getContainer(), $paths, $components, $all_scopes); + } + + /** + * Build static entries + */ + $paths["/api/v2/auth/token"] = $this->staticFragments->authTokenPath(); + + foreach ($this->staticFragments->tokenComponents() as $key => $schema) { + $components[$key] = $schema; + } + + $this->staticFragments->applyImportFileTusPaths($paths); + + /** + * Build final result + */ + $unique_all_scopes = array_unique($all_scopes); + asort($unique_all_scopes); + $result = [ + "openapi" => "3.1.0", + "info" => [ + "title" => "Hashtopolis API", + "version" => "v2" + ], + "servers" => [ + [ + "url" => "/" + ], + ], + "paths" => $paths, + "components" => [ + "schemas" => $components, + "securitySchemes" => [ + "bearerAuth" => [ + "type" => "http", + "description" => "JWT Authorization header using the Bearer scheme.", + "scheme" => "bearer", + "bearerFormat" => "JWT", + "scopes" => array_values($unique_all_scopes), + ], + "basicAuth" => [ + "type" => "http", + "description" => "Basic Authorization header.", + "scheme" => "basic" + ] + ] + ], + ]; + + return $result; + } + + /** + * Build the OpenAPI spec for an explicit list of API classes, without a + * pre-built Slim app. A bare app and DI container are assembled internally, + * the given classes are registered on it and the spec is generated from the + * resulting routes. + * + * The input must be closed under relationships: every relationship target + * of a registered model API class must be resolvable through the class + * mapper, otherwise relationship resolution fails. Related API classes + * whose routes should not be part of the spec can be passed via + * $classMapperOnlyClasses; they are then only registered on the class + * mapper (no routes). + * + * @param list $apiClasses classes whose routes are registered + * @param list> $classMapperOnlyClasses + * model API classes registered on the class mapper only + */ + public function buildForApiClasses(array $apiClasses, array $classMapperOnlyClasses = []): array { + $container = new Container(); + $classMapper = new ClassMapper(); + $container->set('classMapper', $classMapper); + $app = AppFactory::create(null, $container); + + foreach ($classMapperOnlyClasses as $apiClass) { + $classMapper->add($apiClass::getDBAclass(), $apiClass); + } + foreach ($apiClasses as $apiClass) { + $apiClass::register($app); + } + + return $this->buildFromApp($app); + } +} diff --git a/src/inc/apiv2/openapi/SpecSanitizer.php b/src/inc/apiv2/openapi/SpecSanitizer.php new file mode 100644 index 000000000..0cc9d1409 --- /dev/null +++ b/src/inc/apiv2/openapi/SpecSanitizer.php @@ -0,0 +1,390 @@ + 'Hashtopolis', + 'url' => 'https://github.com/hashtopolis/server' + ]; + } + + // Phase 1: Build rename map for schema names containing backslashes + $renameMap = []; + if (isset($spec['components']['schemas'])) { + $usedShortNames = []; + // Collect existing non-backslash names to avoid collisions + foreach (array_keys($spec['components']['schemas']) as $key) { + if (!str_contains($key, '\\')) { + $usedShortNames[$key] = true; + } + } + // Build rename map: extract short class name (last segment after \) + foreach (array_keys($spec['components']['schemas']) as $key) { + if (str_contains($key, '\\')) { + $shortName = substr($key, strrpos($key, '\\') + 1); + if (!isset($usedShortNames[$shortName])) { + $renameMap[$key] = $shortName; + $usedShortNames[$shortName] = true; + } + } + } + // Apply rename to component schema keys + $newSchemas = []; + foreach ($spec['components']['schemas'] as $key => $value) { + $newKey = $renameMap[$key] ?? $key; + $newSchemas[$newKey] = $value; + } + $spec['components']['schemas'] = $newSchemas; + } + + // Phase 2: Remove scopes from bearerAuth (only valid on OAuth2) + if (isset($spec['components']['securitySchemes']['bearerAuth']['scopes'])) { + 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 + foreach ($spec['paths'] as $path => &$pathItem) { + foreach ($pathItem as $method => &$operation) { + if (!is_array($operation)) continue; + + // Fix: Security requirement - bearerAuth should have empty scopes array for HTTP bearer + if (isset($operation['security'])) { + foreach ($operation['security'] as &$secReq) { + if (isset($secReq['bearerAuth'])) { + $secReq['bearerAuth'] = []; + } + } + unset($secReq); + } + + // Fix: Query params incorrectly marked as path params + style casing + if (isset($operation['parameters'])) { + $queryParamNames = ['page[after]', 'page[before]', 'page[size]', 'filter', 'include']; + foreach ($operation['parameters'] as &$param) { + if (isset($param['in']) && $param['in'] === 'path' && in_array($param['name'], $queryParamNames)) { + $param['in'] = 'query'; + } + // Fix: style casing (deepobject -> deepObject) + if (isset($param['style']) && $param['style'] === 'deepobject') { + $param['style'] = 'deepObject'; + } + } + unset($param); + } + + // Fix: requestBody as indexed array -- unwrap to first element + if (isset($operation['requestBody'][0]) && is_array($operation['requestBody'][0])) { + $operation['requestBody'] = $operation['requestBody'][0]; + } + + // Fix: Walk response content + if (isset($operation['responses'])) { + foreach ($operation['responses'] as &$responseObj) { + if (!is_array($responseObj) || !isset($responseObj['content'])) continue; + foreach ($responseObj['content'] as $mediaType => &$mediaObj) { + // Fix: Empty media type object + if (is_array($mediaObj) && empty($mediaObj)) { + $mediaObj = ["schema" => ["type" => "object"]]; + } + // Fix: Missing schema wrapper (has 'type' but no 'schema') + elseif (is_array($mediaObj) && isset($mediaObj['type']) && !isset($mediaObj['schema'])) { + $mediaObj = ["schema" => $mediaObj]; + } + } + unset($mediaObj); + } + unset($responseObj); + } + + // Fix: Also for requestBody content + if (isset($operation['requestBody']['content'])) { + foreach ($operation['requestBody']['content'] as $mediaType => &$mediaObj) { + if (is_array($mediaObj) && empty($mediaObj)) { + $mediaObj = ["schema" => ["type" => "object"]]; + } + } + unset($mediaObj); + } + + // Fix: Clean backslash-prefixed tag names + if (isset($operation['tags'])) { + $operation['tags'] = array_map(function($tag) { + return str_contains($tag, '\\') ? substr($tag, strrpos($tag, '\\') + 1) : $tag; + }, $operation['tags']); + } + // Fix: Add missing tags for helper/auth operations + if (!isset($operation['tags']) || empty($operation['tags'])) { + if (str_starts_with($path, '/api/v2/helper/')) { + $operation['tags'] = ['Helpers']; + } elseif (str_starts_with($path, '/api/v2/auth/')) { + $operation['tags'] = ['Authentication']; + } + } + + // Fix: Add missing path parameter definitions + preg_match_all('/\{(\w+)\}/', $path, $pathParamMatches); + $expectedPathParams = $pathParamMatches[1]; + if (!empty($expectedPathParams)) { + $definedPathParams = []; + if (isset($operation['parameters'])) { + foreach ($operation['parameters'] as $existingParam) { + if (isset($existingParam['in']) && $existingParam['in'] === 'path') { + $definedPathParams[] = $existingParam['name']; + } + } + } + foreach ($expectedPathParams as $paramName) { + if (!in_array($paramName, $definedPathParams)) { + if (!isset($operation['parameters'])) { + $operation['parameters'] = []; + } + $operation['parameters'][] = [ + "name" => $paramName, + "in" => "path", + "required" => true, + "schema" => [ + "type" => $paramName === 'id' ? "integer" : "string", + ] + ]; + } + } + } + + // Fix: Add missing operation summary + if (!isset($operation['summary'])) { + $tag = $operation['tags'][0] ?? ''; + $hasId = str_contains($path, '{id}'); + $isRelation = str_contains($path, '/relationships/'); + $isCount = str_ends_with($path, '/count'); + $summary = match($method) { + 'get' => $isCount ? "Count $tag" : ($hasId ? "Get $tag" : "List $tag"), + 'post' => $isRelation ? "Add $tag relationship" : "Create $tag", + 'patch' => "Update $tag", + 'delete' => $isRelation ? "Remove $tag relationship" : "Delete $tag", + 'head' => "Head $tag", + default => ucfirst($method) . " $tag" + }; + $operation['summary'] = $summary; + } + + // Fix: Generate unique operationId + if (!isset($operation['operationId'])) { + $stripped = preg_replace('#^/api/v2/(ui|helper|auth)/#', '', $path); + $parts = []; + foreach (explode('/', $stripped) as $seg) { + if ($seg === '') continue; + if (str_starts_with($seg, '{')) { + $parts[] = 'By' . ucfirst(trim($seg, '{}')); + } else { + $parts[] = ucfirst($seg); + } + } + $operation['operationId'] = $method . implode('', $parts); + } + + // Fix: Fill empty descriptions + if (!isset($operation['description']) || $operation['description'] === '') { + $operation['description'] = $operation['summary'] ?? ''; + } + + // Fix: Ensure operation has security defined + if (!isset($operation['security'])) { + $operation['security'] = [["bearerAuth" => []]]; + } + + // Fix: Ensure at least one 2xx response exists + if (isset($operation['responses'])) { + $has2xx = false; + foreach (array_keys($operation['responses']) as $code) { + if (str_starts_with((string)$code, '2')) { + $has2xx = true; + break; + } + } + if (!$has2xx) { + $operation['responses']['200'] = [ + "description" => "successful operation" + ]; + } + } + } + unset($operation); + } + unset($pathItem); + + // Phase 5: Recursive walk for $ref renaming, enum, required, description fixes + $spec = $this->recursiveFixValues($spec, $renameMap); + + // Phase 6: Build global tags array from all operations + $allTags = []; + foreach ($spec['paths'] as $pathItem) { + foreach ($pathItem as $op) { + if (is_array($op) && isset($op['tags'])) { + foreach ($op['tags'] as $tag) { $allTags[$tag] = true; } + } + } + } + ksort($allTags); + $spec['tags'] = array_map(fn($name) => ['name' => $name], array_keys($allTags)); + + // Phase 7: Remove unreferenced component schemas (iterative until stable) + if (isset($spec['components']['schemas'])) { + $changed = true; + while ($changed) { + $changed = false; + $refs = []; + $this->collectSchemaRefs($spec['paths'], $refs); + $this->collectSchemaRefs($spec['components']['schemas'], $refs); + foreach (array_keys($spec['components']['schemas']) as $name) { + if (!isset($refs[$name])) { + unset($spec['components']['schemas'][$name]); + $changed = true; + } + } + } + } + + 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; + foreach ($data as $key => $value) { + if ($key === '$ref' && is_string($value) && str_starts_with($value, '#/components/schemas/')) { + $refs[substr($value, strlen('#/components/schemas/'))] = true; + } elseif (is_array($value) || is_object($value)) { + $this->collectSchemaRefs($value, $refs); + } + } + } + + private function recursiveFixValues(array $data, array $renameMap): array { + foreach ($data as $key => &$value) { + // Fix: description as array -> string (skip schema objects named "description") + if ($key === 'description' && is_array($value) && !isset($value['type'])) { + $value = implode("\n", $value); + continue; + } + + // Fix: enum as string -> proper array + if ($key === 'enum' && is_string($value)) { + if (preg_match_all("/'([^']+)'/", $value, $matches)) { + $value = $matches[1]; + } + continue; + } + + // Fix: properties must be a JSON object, not array + if ($key === 'properties' && is_array($value)) { + if (empty($value)) { + // Empty array -> stdClass so json_encode outputs {} not [] + $value = new \stdClass(); + continue; + } + if (isset($value[0])) { + // Indexed array -- try merging elements that have 'properties' sub-keys + $canMerge = true; + foreach ($value as $item) { + if (!is_array($item) || !isset($item['properties'])) { + $canMerge = false; + break; + } + } + if ($canMerge) { + $merged = []; + foreach ($value as $item) { + $merged = array_merge($merged, $item['properties']); + } + $value = $merged; + } else { + // Non-mergeable indexed array -> force to object + $value = (object)$value; + continue; + } + } + } + + if (is_array($value)) { + $value = $this->recursiveFixValues($value, $renameMap); + } else { + // Fix: $ref renaming (update references to renamed schemas) + if ($key === '$ref' && is_string($value) && str_starts_with($value, '#/components/schemas/')) { + $schemaName = substr($value, strlen('#/components/schemas/')); + if (isset($renameMap[$schemaName])) { + $value = '#/components/schemas/' . $renameMap[$schemaName]; + } + } + // Fix: required as string "true" -> boolean true + if ($key === 'required' && $value === "true") { + $value = true; + } + } + } + unset($value); + return $data; + } +} diff --git a/src/inc/apiv2/openapi/StaticFragments.php b/src/inc/apiv2/openapi/StaticFragments.php new file mode 100644 index 000000000..4e4d4fc96 --- /dev/null +++ b/src/inc/apiv2/openapi/StaticFragments.php @@ -0,0 +1,299 @@ + "object", + "required" => ["status"], + "description" => "RFC 7807 problem document", + "properties" => [ + "title" => [ + "type" => "string", + "example" => "No access to this object!" + ], + "type" => [ + "type" => "string", + "example" => "about:blank" + ], + "status" => [ + "type" => "integer", + "example" => 400 + ] + ] + ]; + return $components; + } + + /** + * The token endpoint exchanges basic auth credentials for a JWT. It is not a + * JSON:API resource endpoint: it answers a plain application/json body with + * status 201 (see token.routes.php). + */ + public function authTokenPath(): array { + return [ + "post" => [ + "tags" => [ + "Login" + ], + "requestBody" => [ + "required" => true, + "content" => [ + "application/json" => [ + "schema" => [ + '$ref' => "#/components/schemas/TokenRequest" + ] + ] + ] + ], + "responses" => [ + "201" => [ + "description" => "Success", + "content" => [ + "application/json" => [ + "schema" => [ + '$ref' => "#/components/schemas/Token" + ] + ] + ] + ], + "400" => $this->problemResponse("Invalid request"), + "401" => $this->problemResponse("Authentication failed") + ], + "security" => [ + [ + "basicAuth" => [] + ] + ] + ] + ]; + } + + /** + * Errors are rendered as RFC 7807 problem documents by + * ErrorHandler::errorResponse, on every APIv2 route. + */ + private function problemResponse(string $description): array { + return [ + "description" => $description, + "content" => [ + JsonApiFragments::PROBLEM_MEDIA_TYPE => [ + "schema" => [ + '$ref' => "#/components/schemas/ErrorResponse" + ] + ] + ] + ]; + } + + public function tokenComponents(): array { + $components = []; + $components["Token"] = [ + "type" => "object", + "required" => ["token", "expires"], + "properties" => [ + "token" => [ + "type" => "string" + ], + "expires" => [ + "type" => "integer" + ] + ], + "additionalProperties" => false + ]; + $components["TokenRequest"] = [ + "type" => "array", + "items" => [ + "type" => "string", + "example" => "role.all" + ] + ]; + + $components["ObjectRequest"] = [ + "type" => "object", + "properties" => [ + "expand" => [ + "type" => "string", + ], + "expires" => [ + "type" => "integer" + ] + ], + "additionalProperties" => false + ]; + + $components["ObjectListRequest"] = [ + "type" => "object", + "properties" => [ + "expand" => [ + "type" => "string", + ], + "filter" => [ + "type" => "array", + "items" => [ + "type" => "string", + "example" => "", + ] + ] + ], + "additionalProperties" => false + ]; + return $components; + } + + public function tusHeader(): array { + return [ + "description" => "Indicates the TUS version the server supports. + Must always be set to `1.0.0` in compliant servers.", + "schema" => [ + "type" => "string", + "enum" => "enum: ['1.0.0']" + ] + ]; + } + + public function applyImportFileTusPaths(array &$paths): void { + //Hard coded headers for the importfile endpoints. + $paths["/api/v2/helper/importFile"]["post"]["parameters"] = [ + [ + "name" => "Upload-Metadata", + "in" => "header", + "required" => "true", + "schema" => [ + "type" => "string", + "pattern" => '^([a-zA-Z0-9]+ [A-Za-z0-9+/=]+)(,[a-zA-Z0-9]+ [A-Za-z0-9+/=]+)*$' + ], + "example" => "filename ZXhhbXBsZS50eHQ=", + "description" => " The Upload-Metadata header contains one or more comma-separated key-value pairs. + Each pair is formatted as ` `, where: + - `key` is a string without spaces. + - `value` is base64-encoded" + ], + [ + "name" => "Upload-Length", + "in" => "header", + "schema" => [ + "type" => "integer", + "minimum" => 1 + ], + "example" => 10000, + "description" => "The total size of the upload in bytes. Must be a positive integer. + Required if `Upload-Defer-Length` is not set." + ], + [ + "name" => "Upload-Defer-Length", + "in" => "header", + "schema" => [ + "type" => "integer", + ], + "example" => 1, + "description" => "Indicates that the upload length is not known at creation time. + Value must be `1`. If present, `Upload-Length` must be omitted." + ] + ]; + + $paths["/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}"]["patch"]["parameters"] = [ + [ + "name" => "Upload-Offset", + "in" => "header", + "required" => "true", + "schema" => [ + "type" => "integer", + ], + "example" => 512, + "description" => " The Upload-Offset header's value MUST be equal to the current offset of the resource" + ], + [ + "name" => "Content-Type", + "in" => "header", + "required" => "true", + "schema" => [ + "type" => "string", + "enum" => ["application/offset+octet-stream"] + ], + ], + ]; + $paths["/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}"]["patch"]["requestBody"] = [ + [ + "required" => "true", + "description" => "The binary data to push to the file", + "content" => [ + "application/offset+octet-stream" => [ + "schema" => [ + "type" => "string", + "format" => "binary" + ] + ] + ] + ] + ]; + + $paths["/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}"]["head"]["responses"]["200"] = [ + "description" => "successful request", + "headers" => [ + "Tus-Resumable" => $this->tusHeader(), + "Upload-Offset" => [ + "description" => "Number of bytes already received", + "schema" => [ + "type" => "integer" + ] + ], + "Upload-Length" => [ + "description" => "Total upload length (if known)", + "schema" => [ + "type" => "integer" + ], + ], + "Upload-Defer-Length" => [ + "description" => "Indicates deferred upload length (if applicable)", + "schema" => [ + "type" => "string" + ], + ], + "Upload-Metadata" => [ + "description" => "Original metadata sent during creation", + "schema" => [ + "type" => "string" + ] + ] + ] + ]; + + /* TUS creation answers with headers only, the upload itself follows as PATCH */ + $paths["/api/v2/helper/importFile"]["post"]["responses"]["201"] = [ + "description" => "Upload created", + "headers" => [ + "Tus-Resumable" => $this->tusHeader(), + "Location" => [ + "description" => "Location of the file where the user can push to.", + "schema" => [ + "type" => "string" + ] + ] + ] + ]; + $paths["/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}"]["patch"]["responses"]["204"] = [ + "description" => "Chunk accepted", + "headers" => [ + "Tus-Resumable" => $this->tusHeader(), + "Upload-Offset" => [ + "description" => "The new offset after the chunk is accepted. Indicates how many bytes were received so far.", + "schema" => [ + "type" => "integer" + ] + ] + ] + ]; + } +} From 73ecc7beb1aedc3ccc503f2c0ac3fb23a2d4cddf Mon Sep 17 00:00:00 2001 From: correct-horse-battery-bench Date: Mon, 17 Aug 2026 13:37:06 +0200 Subject: [PATCH 2/3] Lint the generated spec against the JSON:API ruleset The lint job was green without checking anything: the spec master serves declares openapi 3.0.1, while the upstream JSON:API ruleset declares formats: [oas3.1], so Spectral skipped every rule. The 3.1.0 output of the generator activates the whole ruleset at once. spectral-hashtopolis.yml extends the upstream ruleset, which stays unmodified so it can be updated from upstream, and records every point where the APIv2 departs from JSON:API, naming the code that decides the behaviour. A rule is re-scoped wherever part of it still holds: - resource-object-property-types and relationship-data-schema keep their type and meta clauses. Their id clause cannot hold while obj2Resource answers the integer primary key of the model. - content-type, the response code rules and the single object rules hold for the resource routes under /api/v2/ui/. The routes under /api/v2/helper/ are RPC style actions that take a flat body and answer a meta document. Three rules are switched off, because the behaviour they describe is absent altogether rather than narrower than upstream assumes: 406-response-code and 415-response-code (no content negotiation middleware exists) and patch-requests-single-object (a collection PATCH carries a list of objects). Each of the three names what has to change for it to come back. Spectral does not inherit the aliases of an extended ruleset, so the aliases the re-scoped rules use are copied into this file. --- .github/openapi/spectral-hashtopolis.yml | 267 +++++++++++++++++++++++ .github/workflows/openapi-lint.yml | 2 +- 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 .github/openapi/spectral-hashtopolis.yml diff --git a/.github/openapi/spectral-hashtopolis.yml b/.github/openapi/spectral-hashtopolis.yml new file mode 100644 index 000000000..b7cb9f4ed --- /dev/null +++ b/.github/openapi/spectral-hashtopolis.yml @@ -0,0 +1,267 @@ +description: > + Lint ruleset for the generated Hashtopolis APIv2 OpenAPI spec. + + It extends the upstream JSON:API styleguide (spectral-jsonapi.yml, kept + unmodified so it can be updated from upstream) and records the points where + the APIv2 knowingly departs from JSON:API. Those rules are relaxed here + instead of in the generator, because the generated spec has to keep describing + what the server actually answers. Every entry below names the code that + decides the behaviour, so the rule can be restored once the behaviour changes. + + A rule is re-scoped wherever part of it still holds, and only switched off + where the behaviour it describes is absent altogether. + +extends: + - ./spectral-jsonapi.yml + +# Spectral does not inherit the aliases of an extended ruleset, so the aliases +# used by the re-scoped rules below are copied verbatim from spectral-jsonapi.yml. +aliases: + AllContentSchemas: + - "$.paths..content['application/vnd.api+json'].schema" + + ResourceObjects: + - "$.paths..responses..content[application/vnd.api+json].schema.properties.data.properties" + - "$.paths..responses..content[application/vnd.api+json].schema.properties.data.allOf[*].properties" + - "$.paths..responses..content[application/vnd.api+json].schema.properties.data.items.properties" + - "$.paths..responses..content[application/vnd.api+json].schema.properties.data.items.allOf[*].properties" + - "$.paths..content[application/vnd.api+json].schema.properties.included.items.properties" + - "$.paths..content[application/vnd.api+json].schema.properties.included.items.allOf[*].properties" + - "$.paths..patch.requestBody.content[application/vnd.api+json].schema.properties.data.properties" + - "$.paths..patch.requestBody.content[application/vnd.api+json].schema.properties.data.allOf[*].properties" + + POSTResourceObjects: + - "$.paths..post.requestBody.content[application/vnd.api+json].schema.properties.data.properties" + - "$.paths..post.requestBody.content[application/vnd.api+json].schema.properties.data.allOf[*].properties" + + Relationships: + - "#AllContentSchemas..properties[relationships]" + + RelationshipData: + - "#Relationships..data" + +rules: + # The APIv2 never inspects the Accept header, so it cannot answer 406. There is + # no content negotiation middleware in src/inc/apiv2 at all, so there is no + # narrower scope the rule could hold in. Restored once a middleware answers it. + 406-response-code: off + + # Only the TUS upload endpoint validates Content-Type and answers 415 + # (ImportFileHelperAPI::processPatch). No other route does, so requiring a + # documented 415 on every POST and PATCH would document a reply that never + # comes. Restored once a middleware answers it for every route. + 415-response-code: off + + # AbstractBaseAPI::obj2Resource answers the primary key of the model as the + # integer it is in the database, so the spec describes an id as an integer and + # the "id" half of the rule cannot hold. The "type" half is kept by dropping + # #ResourceObjects.id from the given. Restore the upstream given once the + # runtime serializes an id as the string JSON:API asks for. + resource-object-property-types: + description: "`type` MUST be of type `string`. The `id` is excluded: obj2Resource answers the + integer primary key of the model." + documentationUrl: "https://jsonapi.org/format/1.0/#document-resource-object-identification" + message: "'type' MUST be of type 'string'" + severity: error + given: + - "#ResourceObjects.type" + - "#POSTResourceObjects.type" + then: + field: type + function: enumeration + functionOptions: + values: + - string + + # Same deviation in the resource linkage of a relationship, which carries the + # id of the related object (AbstractBaseAPI::obj2Resource). The schema below + # is the upstream one without its "id" clause, so the type and meta members + # are still checked. + relationship-data-schema: + description: "relationship data items MUST follow schema. The `id` is excluded: obj2Resource + answers the integer primary key of the related model." + documentationUrl: "https://jsonapi.org/format/1.0/#document-resource-identifier-objects" + message: "relationship data items MUST follow schema" + severity: error + given: + - "#RelationshipData.properties" + - "#RelationshipData.allOf[0].properties" + - "#RelationshipData.items.properties" + - "#RelationshipData.items.allOf[0].properties" + then: + function: schema + functionOptions: + dialect: "draft2020-12" + schema: + type: object + required: ["id", "type"] + properties: + type: + type: object + properties: + type: + type: string + enum: + - string + meta: + type: object + properties: + type: + type: string + enum: + - object + + # Two deviations are folded into this rule: + # - Errors are RFC 7807 problem documents served as application/problem+json + # by ErrorHandler::errorResponse, not JSON:API error documents, so only + # success payloads are checked. + # - Helper request bodies are flat maps of form fields read by + # AbstractHelperAPI::processPost, not JSON:API documents, so request bodies + # are only checked under /api/v2/ui/. + content-type: + description: "JSON:API payloads MUST use the `application/vnd.api+json` media type. Error + responses are excluded: the APIv2 answers those as RFC 7807 problem documents." + documentationUrl: "https://jsonapi.org/format/1.0/#content-negotiation" + message: "content MUST be 'application/vnd.api+json'" + severity: error + given: + - "$.paths[?(@property.match(/^\\/api\\/v2\\/ui\\//))]..requestBody.content" + - "$.paths..responses[?(@property >= '200' && @property < '300')].content" + then: + field: "@key" + function: enumeration + functionOptions: + values: + - application/vnd.api+json + + # The rules below only hold for the JSON:API resource routes under + # /api/v2/ui/. The routes under /api/v2/helper/ are RPC style actions and file + # transfers: they take a flat body, answer 200 with a meta document + # (AbstractBaseAPI::getMetaResponse) and have no conflict semantics. + post-2xx-response-codes: + description: "`POST` requests MUST support one of the following 2xx codes: 201, 202 or 204." + documentationUrl: "https://jsonapi.org/format/1.0/#crud-creating-responses" + message: "POST requests MUST support one Of the following 2xx codes: 201, 202 or 204" + severity: error + given: "$.paths[?(@property.match(/^\\/api\\/v2\\/ui\\//))][post].responses" + then: + function: schema + functionOptions: + dialect: "draft2020-12" + schema: + type: object + anyOf: + - required: ["201"] + - required: ["202"] + - required: ["204"] + + post-409-response-code: + description: "`POST` requests MUST document and support response code 409." + documentationUrl: "https://jsonapi.org/format/1.0/#crud-creating-responses" + message: "POST paths must support response codes: 409" + severity: error + given: "$.paths[?(@property.match(/^\\/api\\/v2\\/ui\\//))][post].responses" + then: + field: "409" + function: truthy + + patch-409-response-code: + description: "`PATCH` requests MUST document and support response code 409." + documentationUrl: "https://jsonapi.org/format/1.0/#crud-updating-responses" + message: "PATCH requests MUST support response codes: 409" + severity: error + given: "$.paths[?(@property.match(/^\\/api\\/v2\\/ui\\//))][patch].responses" + then: + field: "409" + function: truthy + + patch-404-response-code: + description: "`PATCH` requests MUST support response code 404." + documentationUrl: "https://jsonapi.org/format/1.0/#crud-updating-responses" + message: "PATCH requests MUST support response code 404" + severity: error + given: "$.paths[?(@property.match(/^\\/api\\/v2\\/ui\\//))][patch].responses" + then: + field: "404" + function: truthy + + delete-404-response-code: + description: "`DELETE` requests MUST support response code 404." + documentationUrl: "https://jsonapi.org/format/1.0/#crud-deleting-responses" + message: "DELETE requests MUST support response code 404" + severity: error + given: "$.paths[?(@property.match(/^\\/api\\/v2\\/ui\\//))][delete].responses" + then: + field: "404" + function: truthy + + # A collection level PATCH updates many objects in one request + # (AbstractModelAPI::patchMultiple), which JSON:API 1.0 does not describe. The + # rule assumes every PATCH body holds exactly one resource object, so it cannot + # hold for a route whose body is a list. Restored once several objects are + # updated through an operations document instead. + patch-requests-single-object: off + + # Upstream applies this to every POST body, but a POST to a to-many + # relationship URL MUST carry an array of resource identifiers, so the rule + # only holds for the resource creation routes. + post-requests-single-object: + description: "A `POST` that creates a resource MUST carry a single resource object. + Relationship routes are excluded: posting to a to-many relationship carries an array + of resource identifier objects." + documentationUrl: "https://jsonapi.org/format/1.0/#crud-creating" + message: "POST requests MAY only contain a single resource object" + severity: error + given: "$.paths[?(!@property.match(/\\/relationships\\//))].post.requestBody.content[application/vnd.api+json].schema.properties.data[?(@property==='type' && @ === 'array')]" + then: + function: falsy + + # Helper request bodies are flat maps of form fields and helper responses are + # meta documents, so the top level document rules only apply under + # /api/v2/ui/. + top-level-json-properties: + description: "The root of a JSON:API document MUST follow the JSON:API document schema." + documentationUrl: "https://jsonapi.org/format/1.0/#document-top-level" + message: "Root JSON object MUST follow the jsonapi schema" + severity: error + given: "$.paths[?(@property.match(/^\\/api\\/v2\\/ui\\//))]..content['application/vnd.api+json'].schema" + then: + field: "properties" + function: schema + functionOptions: + dialect: "draft2020-12" + schema: + type: object + anyOf: + - required: ["data"] + - required: ["errors"] + - required: ["meta"] + not: + anyOf: + - required: ["data", "errors"] + dependentRequired: + included: ["data"] + + top-level-json-object: + description: "A JSON:API request or response body MUST be a JSON object." + documentationUrl: "https://jsonapi.org/format/1.0/#document-top-level" + message: "Request/response body must be wrapped in root level JSON object" + severity: error + given: "$.paths[?(@property.match(/^\\/api\\/v2\\/ui\\//))]..content['application/vnd.api+json'].schema" + then: + field: type + function: enumeration + functionOptions: + values: + - object + +overrides: + # /api/v2/auth/token exchanges basic auth credentials for a JWT. It is not a + # JSON:API resource endpoint: it takes a scope list and answers a plain + # application/json body (see token.routes.php). + - files: + - "**#/paths/~1api~1v2~1auth~1token" + rules: + content-type: off + 400-response-code: off + 403-response-code: off diff --git a/.github/workflows/openapi-lint.yml b/.github/workflows/openapi-lint.yml index 02c46a469..7b217f6b7 100644 --- a/.github/workflows/openapi-lint.yml +++ b/.github/workflows/openapi-lint.yml @@ -37,4 +37,4 @@ jobs: - name: Lint OpenAPI schema with Redocly run: redocly lint openapi.json - name: Lint OpenAPI schema with Spectral - run: spectral lint openapi.json --ruleset .github/openapi/spectral-jsonapi.yml -D + run: spectral lint openapi.json --ruleset .github/openapi/spectral-hashtopolis.yml -D From da3b5df7ba976e60591bd5e9645763b991044a43 Mon Sep 17 00:00:00 2001 From: correct-horse-battery-bench Date: Tue, 18 Aug 2026 16:16:01 +0200 Subject: [PATCH 3/3] Name the base class of the helper API class list MODEL_API_CLASSES declares list>, while HELPER_API_CLASSES declared the bare list, so nothing said the entries are helper APIs. Every class in the list extends AbstractHelperAPI, and the consumers of the constant call the methods that base class declares. --- src/inc/apiv2/common/ApiRegistry.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/inc/apiv2/common/ApiRegistry.php b/src/inc/apiv2/common/ApiRegistry.php index d472352a0..1611028e0 100644 --- a/src/inc/apiv2/common/ApiRegistry.php +++ b/src/inc/apiv2/common/ApiRegistry.php @@ -107,7 +107,7 @@ final class ApiRegistry { VoucherAPI::class, ]; - /** @var list */ + /** @var list> */ public const HELPER_API_CLASSES = [ AbortChunkHelperAPI::class, AssignAgentHelperAPI::class,