diff --git a/.github/workflows/openapi-lint.yml b/.github/workflows/openapi-lint.yml new file mode 100644 index 000000000..eb2e36bb1 --- /dev/null +++ b/.github/workflows/openapi-lint.yml @@ -0,0 +1,28 @@ +name: OpenAPI Lint + +on: + push: + branches: + - master + - dev + pull_request: + branches: + - master + - dev + +jobs: + openapi-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Start Hashtopolis server + uses: ./.github/actions/start-hashtopolis + with: + db_system: mysql + - name: Install Spectral + run: npm install -g @stoplight/spectral-cli + - name: Download JSON:API ruleset + run: curl -fsSL https://raw.githubusercontent.com/jmlue42/spectral-jsonapi-ruleset/main/.spectral.yml -o .spectral.yml + - name: Lint OpenAPI schema + run: spectral lint http://localhost:8080/api/v2/openapi.json --ruleset .spectral.yml -D diff --git a/src/inc/apiv2/common/AbstractBaseAPI.php b/src/inc/apiv2/common/AbstractBaseAPI.php index 52baa3d13..8d294f2a9 100644 --- a/src/inc/apiv2/common/AbstractBaseAPI.php +++ b/src/inc/apiv2/common/AbstractBaseAPI.php @@ -186,6 +186,18 @@ protected function getUpdateHandlers($id, $current_user): array { public function aggregateData(object $object, array &$includedData = [], ?array $aggregateFieldsets = null): array { return []; } + + /** + * Return supported aggregate fieldsets/options for this endpoint. + * + * Format: + * [ + * 'resourceKey' => ['option1', 'option2'] + * ] + */ + public function getAggregateFieldsets(): array { + return []; + } /** * Take all the dba features and converts them to a list. diff --git a/src/inc/apiv2/common/OpenAPISchemaUtils.php b/src/inc/apiv2/common/OpenAPISchemaUtils.php index ae6ac950e..322a7e774 100644 --- a/src/inc/apiv2/common/OpenAPISchemaUtils.php +++ b/src/inc/apiv2/common/OpenAPISchemaUtils.php @@ -126,16 +126,13 @@ static function makeLinks($uri): array { } //TODO relationship array is unnecessarily indexed in the swagger UI - static function makeRelationships($class, $uri): array { + static function makeRelationships($relationshipsNames, $uri): array { $properties = []; - $relationshipsNames = array_merge(array_keys($class->getToOneRelationships()), array_keys($class->getToManyRelationships())); sort($relationshipsNames); foreach ($relationshipsNames as $relationshipName) { $self = $uri . "/relationships/" . $relationshipName; $related = $uri . "/" . $relationshipName; - $properties[] = [ - "properties" => [ - $relationshipName => [ + $properties[$relationshipName] = [ "type" => "object", "properties" => [ "links" => [ @@ -152,9 +149,6 @@ static function makeRelationships($class, $uri): array { ] ] ] - ] - - ] ]; } return $properties; @@ -166,19 +160,18 @@ static function getTUSHeader(): array { Must always be set to `1.0.0` in compliant servers.", "schema" => [ "type" => "string", - "enum" => "enum: ['1.0.0']" + "enum" => ['1.0.0'] ] ]; } //TODO expandables array is unnecessarily indexed in the swagger UI - static function makeExpandables($class, $container): array { + static function makeExpandables($expandables, $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); - $properties[] = [ + $properties[$expand] = [ "properties" => [ "id" => [ "type" => "integer" @@ -197,20 +190,44 @@ static function makeExpandables($class, $container): array { return $properties; } - static function mapToProperties($map): array { - $properties = array_map(function ($value) { - return [ - "type" => "string", - "default" => $value, - ]; - }, $map); - return [ - "type" => "array", - "items" => [ - "type" => "object", - "properties" => $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 = []; + foreach ($value as $key => $val) { + $properties[$key] = self::mapToProperties($val); + } + return ["type" => "object", "properties" => $properties]; + } + } + return ["type" => "string"]; } /** diff --git a/src/inc/apiv2/common/openAPISchema.routes.php b/src/inc/apiv2/common/openAPISchema.routes.php index 0ad869c81..dc38f880f 100644 --- a/src/inc/apiv2/common/openAPISchema.routes.php +++ b/src/inc/apiv2/common/openAPISchema.routes.php @@ -120,7 +120,9 @@ $apiClassName = $explodedCallable[0]; $apiMethod = $explodedCallable[1]; $class = new $apiClassName($app->getContainer()); + + $path = preg_replace('/\{([^:}]+):(.+)\}/', '{$1}', $path); if (!($class instanceof AbstractModelAPI)) { $name_parts = explode('\\', $class::class); $name = end($name_parts); @@ -129,12 +131,15 @@ $paths[$path][$method]["description"] = OpenAPISchemaUtils::parsePhpDoc($reflectionApiMethod->getDocComment()); $parameters = $class->getCreateValidFeatures(); $properties = OpenAPISchemaUtils::makeProperties($parameters); - $components[$name] = - [ - "type" => "object", - "properties" => $properties, - ]; - if ($method == "post") { + $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"] = [ @@ -162,10 +167,6 @@ 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. - continue; - } if (isset($ref)) { $paths[$path][$method]["responses"]["200"] = [ "description" => "successful operation", @@ -187,7 +188,7 @@ }; /* Quick to find out if single parameter object is used */ - $singleObject = ((strstr($path, '/{id:')) !== false); + $singleObject = ((strstr($path, '/{id}')) !== false); $name_parts = explode('\\', $class->getDBAClass()); $name = end($name_parts); $uri = $class->getBaseUri(); @@ -199,6 +200,11 @@ $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; } @@ -230,42 +236,54 @@ ] ]; - $relationships = ["relationships" => [ - "type" => "object", - "properties" => OpenAPISchemaUtils::makeRelationships($class, $uri) - ] - ]; - $included = ["included" => [ - "type" => "array", - "items" => [ + $relationshipsNames = array_merge(array_keys($class->getToOneRelationships()), array_keys($class->getToManyRelationships())); + $relationships = []; + if (count($relationshipsNames) > 0) { + $relationships = ["relationships" => [ "type" => "object", - "properties" => OpenAPISchemaUtils::makeExpandables($class, $app->getContainer()) - ], - ] + "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); - $properties_create = OpenAPISchemaUtils::buildPatchPost(OpenAPISchemaUtils::makeProperties($class->getAllPostParameters($class->getCreateValidFeatures())), $name); + $postProperties = OpenAPISchemaUtils::makeProperties($class->getAllPostParameters($class->getCreateValidFeatures())); $properties_get = array_merge($json_api_header, $links, $properties_get_single, $included); - $properties_patch = OpenAPISchemaUtils::buildPatchPost(OpenAPISchemaUtils::makeProperties($class->getPatchValidFeatures(), true), $name); - $properties_patch_post_relation = OpenAPISchemaUtils::buildPostPatchRelation($relation, ($isToMany && !$isToOne)); - $responseGetRelation = $properties_patch_post_relation; + $patch_properties = OpenAPISchemaUtils::makeProperties($class->getPatchValidFeatures()); - $components[$name . "Create"] = - [ - "type" => "object", - "properties" => $properties_create, - ]; + if (count($postProperties) > 0) { + $properties_create = OpenAPISchemaUtils::buildPatchPost(OpenAPISchemaUtils::makeProperties($class->getAllPostParameters($class->getCreateValidFeatures())), $name); + $components[$name . "Create"] = + [ + "type" => "object", + "properties" => $properties_create, + ]; + } - $components[$name . "Patch"] = - [ - "type" => "object", - "properties" => $properties_patch, - ]; + if (count($patch_properties) > 0) { + $properties_patch = OpenAPISchemaUtils::buildPatchPost($patch_properties, $name); + $components[$name . "Patch"] = + [ + "type" => "object", + "properties" => $properties_patch, + ]; + } $components[$name . "Response"] = [ @@ -273,17 +291,20 @@ "properties" => $properties_get, ]; - $components[$name . "Relation" . ucfirst($relation)] = - [ - "type" => "object", - "properties" => $properties_patch_post_relation, - ]; - - $components[$name . "Relation" . ucfirst($relation) . "GetResponse"] = - [ - "type" => "object", - "properties" => $responseGetRelation - ]; + 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"] = [ @@ -355,9 +376,7 @@ ], "security" => [ [ - "bearerAuth" => [ - $required_scopes - ] + "bearerAuth" => $required_scopes ] ] ]; @@ -481,26 +500,18 @@ } else { /* Empty JSON object required */ - $paths[$path][$method]["requestBody"] = [ - "required" => true, - "content" => [ - "application/json" => [], - ] - ]; + // $paths[$path][$method]["requestBody"] = [ + // "required" => false, + // "content" => [ + // "application/json" => [], + // ] + // ]; } } elseif ($method == 'post') { $paths[$path][$method]["responses"]["204"] = [ "description" => "successfully created", ]; - - /* Empty JSON object required */ - $paths[$path][$method]["requestBody"] = [ - "required" => true, - "content" => [ - "application/json" => [], - ] - ]; } else { throw new HttpErrorException("Method '$method' not implemented"); @@ -574,17 +585,21 @@ } elseif ($method == 'patch') { - // TODO add patch many here + $paths[$path][$method]["responses"]["204"] = [ + "description" => "successfully patched", + ]; } elseif ($method == 'delete') { - // TODO add delete many here + $paths[$path][$method]["responses"]["200"] = [ + "description" => "successfully deleted", + ]; } else { throw new HttpErrorException("Method '$method' not implemented"); } } - if ($singleObject && $method == 'get') { + if ($singleObject) { $parameters = [ [ "name" => "id", @@ -614,54 +629,89 @@ $parameters = [ [ "name" => "page[after]", - "in" => "path", + "in" => "query", "schema" => [ "type" => "integer", "format" => "int32" ], "example" => 0, + "required" => false, "description" => "Pointer to paginate to retrieve the data after the value provided" ], [ "name" => "page[before]", - "in" => "path", + "in" => "query", "schema" => [ "type" => "integer", "format" => "int32" ], "example" => 0, + "required" => false, "description" => "Pointer to paginate to retrieve the data before the value provided" ], [ "name" => "page[size]", - "in" => "path", + "in" => "query", "schema" => [ "type" => "integer", "format" => "int32" ], + "required" => false, "example" => 100, "description" => "Amout of data to retrieve inside a single page" ], [ "name" => "filter", - "in" => "path", - "style" => "deepobject", + "in" => "query", + "style" => "deepObject", "explode" => true, "schema" => [ - "type" => "object", + "type" => "string", ], "description" => "Filters results using a query", "example" => '"filter[hashlistId__gt]": 200' ], [ "name" => "include", - "in" => "path", + "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(",", $options); + $aggregateDescriptionParts[] = $fieldset . ": " . implode(", ", $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 = []; @@ -782,7 +832,7 @@ [ "name" => "Upload-Metadata", "in" => "header", - "required" => "true", + "required" => true, "schema" => [ "type" => "string", "pattern" => '^([a-zA-Z0-9]+ [A-Za-z0-9+/=]+)(,[a-zA-Z0-9]+ [A-Za-z0-9+/=]+)*$' @@ -816,43 +866,72 @@ ] ]; - $paths["/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}"]["patch"]["parameters"] = [ + $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", + "required" => true, "schema" => [ "type" => "integer", ], - "example" => "512", + "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", + "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}"]["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"] = [ + $paths["/api/v2/helper/importFile/{id}"]["head"]["responses"]["200"] = [ "description" => "successful request", "headers" => [ "Tus-Resumable" => OpenAPISchemaUtils::getTUSHeader(), @@ -882,6 +961,9 @@ ] ] ]; + $paths["/api/v2/helper/importFile/{id}"]["delete"]["responses"]["204"] = [ + "description" => "successful operation" + ]; $paths["/api/v2/helper/importFile"]["post"]["responses"]["201"] = [ "description" => "successful operation", @@ -896,12 +978,14 @@ ], "content" => [ "application/pdf" => [ - "type" => "string", - "format" => "binary" + "schema" => [ + "type" => "string", + "format" => "binary" + ] ] ] ]; - $paths["/api/v2/helper/importFile/{id:[0-9]{14}-[0-9a-f]{32}}"]["patch"]["responses"]["204"] = [ + $paths["/api/v2/helper/importFile/{id}"]["patch"]["responses"]["204"] = [ "description" => "Chunk accepted", "headers" => [ "Tus-Resumable" => OpenAPISchemaUtils::getTUSHeader(), diff --git a/src/inc/apiv2/helper/CurrentUserHelperAPI.php b/src/inc/apiv2/helper/CurrentUserHelperAPI.php index a51b2c6ff..6da458fe2 100644 --- a/src/inc/apiv2/helper/CurrentUserHelperAPI.php +++ b/src/inc/apiv2/helper/CurrentUserHelperAPI.php @@ -85,10 +85,7 @@ static public function register($app): void { $app->patch($baseUri, "Hashtopolis\\inc\\apiv2\\helper\\CurrentUserHelperAPI:actionPatch"); } - /** - * getCurrentUser is different because it returns via another function - */ public static function getResponse(): array|string|null { - return null; + return "User"; } } diff --git a/src/inc/apiv2/helper/GetAccessGroupsHelperAPI.php b/src/inc/apiv2/helper/GetAccessGroupsHelperAPI.php index 1a265adba..9d3231bc7 100644 --- a/src/inc/apiv2/helper/GetAccessGroupsHelperAPI.php +++ b/src/inc/apiv2/helper/GetAccessGroupsHelperAPI.php @@ -76,7 +76,7 @@ static public function register($app): void { /** * getAccessGroups is different because it returns via another function */ - public static function getResponse(): array|string|null { - return null; + public static function getResponse(): string { + return "AccessGroup"; } } diff --git a/src/inc/apiv2/helper/GetBestTasksAgent.php b/src/inc/apiv2/helper/GetBestTasksAgent.php index 35b343125..be9945d2a 100644 --- a/src/inc/apiv2/helper/GetBestTasksAgent.php +++ b/src/inc/apiv2/helper/GetBestTasksAgent.php @@ -25,8 +25,8 @@ public function getRequiredPermissions(string $method): array { return [Agent::PERM_READ, Task::PERM_READ]; } - public static function getResponse(): null { - return null; + public static function getResponse(): string { + return "Task"; } diff --git a/src/inc/apiv2/helper/GetCracksOfTaskHelper.php b/src/inc/apiv2/helper/GetCracksOfTaskHelper.php index fe97ddf5e..8245f1825 100644 --- a/src/inc/apiv2/helper/GetCracksOfTaskHelper.php +++ b/src/inc/apiv2/helper/GetCracksOfTaskHelper.php @@ -34,8 +34,8 @@ public function getRequiredPermissions(string $method): array { return [Hashlist::PERM_READ, Hash::PERM_READ, Task::PERM_READ]; } - public static function getResponse(): null { - return null; + public static function getResponse(): string { + return "Hash"; } diff --git a/src/inc/apiv2/helper/GetUserPermissionHelperAPI.php b/src/inc/apiv2/helper/GetUserPermissionHelperAPI.php index 7fc1b380f..e568114da 100644 --- a/src/inc/apiv2/helper/GetUserPermissionHelperAPI.php +++ b/src/inc/apiv2/helper/GetUserPermissionHelperAPI.php @@ -70,8 +70,8 @@ static public function register($app): void { /** * getAccessGroups is different because it returns via another function */ - public static function getResponse(): array|string|null { - return null; + public static function getResponse(): string { + return "RightGroup"; } } diff --git a/src/inc/apiv2/helper/ImportFileHelperAPI.php b/src/inc/apiv2/helper/ImportFileHelperAPI.php index 680c955a1..6651768ea 100644 --- a/src/inc/apiv2/helper/ImportFileHelperAPI.php +++ b/src/inc/apiv2/helper/ImportFileHelperAPI.php @@ -130,11 +130,8 @@ function processHead(Request $request, Response $response, array $args): Respons } } - /** - * getfile is different because it returns actual binary data. - */ - public static function getResponse(): null { - return null; + public static function getResponse(): array { + return ["file" => "abc.txt", "size" => 123]; } /** File import API @@ -423,7 +420,6 @@ function processGet(Request $request, Response $response, array $args): Response return self::getMetaResponse($importFiles, $request, $response); } - static public function register(App $app): void { $me = get_called_class(); $baseUri = $me::getBaseUri(); diff --git a/src/inc/apiv2/model/LogEntryAPI.php b/src/inc/apiv2/model/LogEntryAPI.php index b7a840c9e..479c263c9 100644 --- a/src/inc/apiv2/model/LogEntryAPI.php +++ b/src/inc/apiv2/model/LogEntryAPI.php @@ -29,4 +29,9 @@ protected function createObject(array $data): int { protected function deleteObject(object $object): void { throw new HttpError("Logentries cannot be deleted via API"); } + + public static function getAvailableMethods(): array { + return ['GET']; + } + } diff --git a/src/inc/apiv2/model/TaskAPI.php b/src/inc/apiv2/model/TaskAPI.php index 6827b9141..309fa5ef1 100644 --- a/src/inc/apiv2/model/TaskAPI.php +++ b/src/inc/apiv2/model/TaskAPI.php @@ -136,6 +136,18 @@ public function getFormFields(): array { "files" => ['type' => 'array', 'subtype' => 'int'], ]; } + + public function getAggregateFieldsets(): array { + return [ + 'task' => [ + 'assignedAgents', + 'dispatched', + 'searched', + 'isActive', + 'taskExtraDetails', + ] + ]; + } /** * @throws HttpError