diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index e6a1b80..6651fba 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -1,4 +1,8 @@ api_platform: + title: 'ITKsites Detection API' + description: 'REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.' + version: '1.0.0' + mapping: paths: ['%kernel.project_dir%/src/Entity'] formats: diff --git a/public/api-spec-v1.json b/public/api-spec-v1.json index 1514960..48e872c 100644 --- a/public/api-spec-v1.json +++ b/public/api-spec-v1.json @@ -1,13 +1,13 @@ { "openapi": "3.1.0", "info": { - "title": "", - "description": "", - "version": "0.0.0" + "title": "ITKsites Detection API", + "description": "REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.", + "version": "1.0.0" }, "servers": [ { - "url": "/", + "url": "https://itksites.local.itkdev.dk", "description": "" } ], @@ -20,11 +20,11 @@ ], "responses": { "202": { - "description": "DetectionResult resource created", + "description": "Detection result accepted for processing", "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid input \u2014 malformed request body", "content": { "application/ld+json": { "schema": { @@ -44,8 +44,14 @@ }, "links": {} }, + "401": { + "description": "Unauthorized \u2014 missing or invalid API key. The Authorization header must use the format: Apikey {key}" + }, + "403": { + "description": "Forbidden \u2014 the authenticated server does not have the required ROLE_SERVER role" + }, "422": { - "description": "An error occurred", + "description": "Validation error \u2014 one or more fields failed constraint validation", "content": { "application/ld+json": { "schema": { @@ -66,8 +72,8 @@ "links": {} } }, - "summary": "Creates a DetectionResult resource.", - "description": "Creates a DetectionResult resource.", + "summary": "Submit a detection result for async processing", + "description": "Accepts a detection result from the server harvester and queues it for asynchronous processing. The result is deduplicated by content hash \u2014 identical submissions update the last contact timestamp without triggering reprocessing. Returns 202 Accepted with an empty body.", "parameters": [], "requestBody": { "description": "The new DetectionResult resource", @@ -228,15 +234,25 @@ "type": "object", "properties": { "type": { - "default": "", - "type": "string" + "enum": [ + "dir", + "docker", + "drupal", + "git", + "nginx", + "symfony" + ] }, "rootDir": { + "description": "Absolute path to the root directory of the detected installation on the server", "default": "", + "example": "/data/www/example-site/htdocs", "type": "string" }, "data": { + "description": "JSON-encoded payload from the server harvester containing the detection details. Structure varies by type.", "default": "", + "example": "{\"packages\":{\"symfony/framework-bundle\":{\"version\":\"7.2.1\"}}}", "type": "string" } } @@ -347,7 +363,7 @@ "securitySchemes": { "apiKey": { "type": "apiKey", - "description": "Value for the Authorization header parameter.", + "description": "Server API key. Use the format: Apikey {your-api-key}", "name": "Authorization", "in": "header" } diff --git a/public/api-spec-v1.yaml b/public/api-spec-v1.yaml index 9d21ff8..8def8bd 100755 --- a/public/api-spec-v1.yaml +++ b/public/api-spec-v1.yaml @@ -1,11 +1,11 @@ openapi: 3.1.0 info: - title: '' - description: '' - version: 0.0.0 + title: 'ITKsites Detection API' + description: 'REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.' + version: 1.0.0 servers: - - url: / + url: 'https://itksites.local.itkdev.dk' description: '' paths: /api/detection_results: @@ -15,10 +15,10 @@ paths: - DetectionResult responses: '202': - description: 'DetectionResult resource created' + description: 'Detection result accepted for processing' links: { } '400': - description: 'Invalid input' + description: 'Invalid input — malformed request body' content: application/ld+json: schema: @@ -30,8 +30,12 @@ paths: schema: $ref: '#/components/schemas/Error' links: { } + '401': + description: 'Unauthorized — missing or invalid API key. The Authorization header must use the format: Apikey {key}' + '403': + description: 'Forbidden — the authenticated server does not have the required ROLE_SERVER role' '422': - description: 'An error occurred' + description: 'Validation error — one or more fields failed constraint validation' content: application/ld+json: schema: @@ -43,8 +47,8 @@ paths: schema: $ref: '#/components/schemas/ConstraintViolation' links: { } - summary: 'Creates a DetectionResult resource.' - description: 'Creates a DetectionResult resource.' + summary: 'Submit a detection result for async processing' + description: 'Accepts a detection result from the server harvester and queues it for asynchronous processing. The result is deduplicated by content hash — identical submissions update the last contact timestamp without triggering reprocessing. Returns 202 Accepted with an empty body.' parameters: [] requestBody: description: 'The new DetectionResult resource' @@ -159,13 +163,22 @@ components: type: object properties: type: - default: '' - type: string + enum: + - dir + - docker + - drupal + - git + - nginx + - symfony rootDir: + description: 'Absolute path to the root directory of the detected installation on the server' default: '' + example: /data/www/example-site/htdocs type: string data: + description: 'JSON-encoded payload from the server harvester containing the detection details. Structure varies by type.' default: '' + example: '{"packages":{"symfony/framework-bundle":{"version":"7.2.1"}}}' type: string Error: type: object @@ -246,7 +259,7 @@ components: securitySchemes: apiKey: type: apiKey - description: 'Value for the Authorization header parameter.' + description: 'Server API key. Use the format: Apikey {your-api-key}' name: Authorization in: header security: diff --git a/src/Entity/DetectionResult.php b/src/Entity/DetectionResult.php index a53f163..d47821f 100644 --- a/src/Entity/DetectionResult.php +++ b/src/Entity/DetectionResult.php @@ -4,16 +4,44 @@ namespace App\Entity; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Post; +use ApiPlatform\OpenApi\Model; use App\Repository\DetectionResultRepository; +use App\Types\DetectionType; use App\Utils\RootDirNormalizer; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new Post(status: 202, output: false, messenger: true), + new Post( + status: 202, + output: false, + messenger: true, + openapi: new Model\Operation( + summary: 'Submit a detection result for async processing', + description: 'Accepts a detection result from the server harvester and queues it for asynchronous processing. The result is deduplicated by content hash — identical submissions update the last contact timestamp without triggering reprocessing. Returns 202 Accepted with an empty body.', + responses: [ + '202' => new Model\Response( + description: 'Detection result accepted for processing', + ), + '400' => new Model\Response( + description: 'Invalid input — malformed request body', + ), + '401' => new Model\Response( + description: 'Unauthorized — missing or invalid API key. The Authorization header must use the format: Apikey {key}', + ), + '403' => new Model\Response( + description: 'Forbidden — the authenticated server does not have the required ROLE_SERVER role', + ), + '422' => new Model\Response( + description: 'Validation error — one or more fields failed constraint validation', + ), + ], + ), + ), ], denormalizationContext: ['groups' => ['write']], )] @@ -24,10 +52,19 @@ class DetectionResult extends AbstractBaseEntity implements \Stringable { #[ORM\Column(type: 'string', length: 255)] #[Groups(['write'])] + #[ApiProperty( + description: 'The type of detection result, determines which handler processes the data', + example: DetectionType::NGINX, + schema: ['enum' => [DetectionType::DIRECTORY, DetectionType::DOCKER, DetectionType::DRUPAL, DetectionType::GIT, DetectionType::NGINX, DetectionType::SYMFONY]], + )] private string $type = ''; #[ORM\Column(type: 'string', length: 255)] #[Groups(['write'])] + #[ApiProperty( + description: 'Absolute path to the root directory of the detected installation on the server', + example: '/data/www/example-site/htdocs', + )] private string $rootDir = ''; #[ORM\ManyToOne(targetEntity: Server::class, inversedBy: 'detectionResults')] @@ -36,6 +73,10 @@ class DetectionResult extends AbstractBaseEntity implements \Stringable #[ORM\Column(type: 'text')] #[Groups(['write'])] + #[ApiProperty( + description: 'JSON-encoded payload from the server harvester containing the detection details. Structure varies by type.', + example: '{"packages":{"symfony/framework-bundle":{"version":"7.2.1"}}}', + )] private string $data = ''; #[ORM\Column(type: 'string', length: 255, unique: true)] diff --git a/src/OpenApi/OpenApiFactory.php b/src/OpenApi/OpenApiFactory.php new file mode 100644 index 0000000..9e6d642 --- /dev/null +++ b/src/OpenApi/OpenApiFactory.php @@ -0,0 +1,43 @@ +decorated->__invoke($context); + + $domain = ($this->serverDomain ?? '') !== '' ? $this->serverDomain : $this->fallbackDomain; + + if (null !== $domain && '' !== $domain) { + $openApi = $openApi->withServers([new Server('https://'.$domain)]); + } + + $securitySchemes = $openApi->getComponents()->getSecuritySchemes(); + if ($securitySchemes instanceof \ArrayObject && isset($securitySchemes['apiKey']) && $securitySchemes['apiKey'] instanceof SecurityScheme) { + $securitySchemes['apiKey'] = $securitySchemes['apiKey']->withDescription('Server API key. Use the format: Apikey {your-api-key}'); + } + + return $openApi; + } +}