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/.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 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..1611028e0 --- /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" + ] + ] + ] + ]; + } +}