diff --git a/app/config/swagger.php b/app/config/swagger.php new file mode 100644 index 00000000..c36f975e --- /dev/null +++ b/app/config/swagger.php @@ -0,0 +1,68 @@ + [ + 'info' => [ + 'title' => 'Buggregator API', + 'description' => '', + 'version' => env('API_VERSION', '1.0.0'), + ], + 'components' => [ + 'schemas' => [ + 'ResponseMeta' => [ + 'type' => 'object', + 'properties' => [ + 'grid' => [ + 'type' => 'object', + 'properties' => [ + // TODO: add grid meta + ], + ], + ], + ], + 'NotFoundError' => [ + 'type' => 'object', + 'properties' => [ + 'error' => [ + 'type' => 'string', + 'example' => 'Http Error - 404', + ], + 'status' => [ + 'type' => 'integer', + 'example' => 404, + ], + ], + ], + 'ValidationError' => [ + 'type' => 'object', + 'properties' => [ + 'message' => [ + 'type' => 'string', + 'example' => 'The given data was invalid.', + ], + 'code' => [ + 'type' => 'integer', + 'example' => 433, + ], + 'errors' => [ + 'type' => 'object', + 'properties' => [ + 'field' => [ + 'type' => 'string', + ], + ], + ], + 'context' => [ + 'type' => 'object', + ], + ], + ], + ], + ], + ], + 'paths' => [ + directory('app') . 'src/Application/HTTP/Response', + directory('app') . 'src/Interfaces/Http', + directory('app') . 'modules/Events/Interfaces/Http', + ], +]; diff --git a/app/modules/Events/Interfaces/Http/Controllers/ClearAction.php b/app/modules/Events/Interfaces/Http/Controllers/ClearAction.php index ac3b4b91..f22546f5 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/ClearAction.php +++ b/app/modules/Events/Interfaces/Http/Controllers/ClearAction.php @@ -10,7 +10,23 @@ use Modules\Events\Interfaces\Http\Request\ClearEventsRequest; use Spiral\Cqrs\CommandBusInterface; use Spiral\Router\Annotation\Route; +use OpenApi\Attributes as OA; +#[OA\Delete( + path: '/api/events', + description: 'Clear all events', + requestBody: new OA\RequestBody( + content: new OA\JsonContent(ref: ClearEventsRequest::class), + ), + tags: ['Events'], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: SuccessResource::class), + ), + ] +)] final class ClearAction { #[Route(route: 'events', name: 'events.clear', methods: 'DELETE', group: 'api')] diff --git a/app/modules/Events/Interfaces/Http/Controllers/DeleteAction.php b/app/modules/Events/Interfaces/Http/Controllers/DeleteAction.php index 3c72df1c..95e80778 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/DeleteAction.php +++ b/app/modules/Events/Interfaces/Http/Controllers/DeleteAction.php @@ -10,7 +10,28 @@ use App\Application\HTTP\Response\SuccessResource; use Spiral\Cqrs\CommandBusInterface; use Spiral\Router\Annotation\Route; +use OpenApi\Attributes as OA; +#[OA\Delete( + path: '/api/event/{uuid}', + description: 'Delete an event by UUID', + tags: ['Events'], + parameters: [ + new OA\PathParameter( + name: 'uuid', + description: 'Event UUID', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid'), + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: SuccessResource::class), + ), + ] +)] final class DeleteAction { #[Route(route: 'event/', name: 'event.delete', methods: 'DELETE', group: 'api')] diff --git a/app/modules/Events/Interfaces/Http/Controllers/ListAction.php b/app/modules/Events/Interfaces/Http/Controllers/ListAction.php index 57427357..20e67cf7 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/ListAction.php +++ b/app/modules/Events/Interfaces/Http/Controllers/ListAction.php @@ -7,9 +7,53 @@ use App\Application\Commands\FindEvents; use Modules\Events\Interfaces\Http\Request\EventsRequest; use Modules\Events\Interfaces\Http\Resources\EventCollection; +use Modules\Events\Interfaces\Http\Resources\EventResource; use Spiral\Cqrs\QueryBusInterface; use Spiral\Router\Annotation\Route; +use OpenApi\Attributes as OA; +#[OA\Get( + path: '/api/events', + description: 'Retrieve all events', + tags: ['Events'], + parameters: [ + new OA\QueryParameter( + name: 'type', + description: 'Filter by event type', + required: false, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items( + ref: EventResource::class, + ), + ), + new OA\Property( + property: 'meta', + ref: '#/components/schemas/ResponseMeta', + type: 'object', + ), + ], + ), + ), + new OA\Response( + response: 404, + description: 'Not found', + content: new OA\JsonContent( + ref: '#/components/schemas/NotFoundError', + ), + ), + ] +)] final class ListAction { #[Route(route: 'events', name: 'events.list', methods: 'GET', group: 'api')] diff --git a/app/modules/Events/Interfaces/Http/Controllers/ShowAction.php b/app/modules/Events/Interfaces/Http/Controllers/ShowAction.php index 6a53ecca..80affec1 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/ShowAction.php +++ b/app/modules/Events/Interfaces/Http/Controllers/ShowAction.php @@ -11,7 +11,35 @@ use Spiral\Cqrs\QueryBusInterface; use Spiral\Http\Exception\ClientException\NotFoundException; use Spiral\Router\Annotation\Route; +use OpenApi\Attributes as OA; +#[OA\Get( + path: '/api/event/{uuid}', + description: 'Retrieve an event by UUID', + tags: ['Events'], + parameters: [ + new OA\PathParameter( + name: 'uuid', + description: 'Event UUID', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid'), + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: EventResource::class), + ), + new OA\Response( + response: 404, + description: 'Not found', + content: new OA\JsonContent( + ref: '#/components/schemas/NotFoundError', + ), + ), + ] +)] final class ShowAction { #[Route(route: 'event/', name: 'event.show', methods: 'GET', group: 'api')] @@ -19,7 +47,7 @@ public function __invoke(QueryBusInterface $bus, Uuid $uuid): EventResource { try { return new EventResource( - $bus->ask(new FindEventByUuid($uuid)) + $bus->ask(new FindEventByUuid($uuid)), ); } catch (EntityNotFoundException $e) { throw new NotFoundException($e->getMessage()); diff --git a/app/modules/Events/Interfaces/Http/Request/ClearEventsRequest.php b/app/modules/Events/Interfaces/Http/Request/ClearEventsRequest.php index 5b229290..5650148e 100644 --- a/app/modules/Events/Interfaces/Http/Request/ClearEventsRequest.php +++ b/app/modules/Events/Interfaces/Http/Request/ClearEventsRequest.php @@ -9,9 +9,20 @@ use Spiral\Filters\Model\FilterDefinitionInterface; use Spiral\Filters\Model\HasFilterDefinition; use Spiral\Validator\FilterDefinition; +use OpenApi\Attributes as OA; + +#[OA\Schema( + schema: 'ClearEventsRequest', +)] final class ClearEventsRequest extends Filter implements HasFilterDefinition { + #[OA\Property( + property: 'type', + description: 'Event type', + type: 'string', + nullable: true, + )] #[Data] public ?string $type = null; diff --git a/app/modules/Events/Interfaces/Http/Resources/EventResource.php b/app/modules/Events/Interfaces/Http/Resources/EventResource.php index 634e137d..b58d63b6 100644 --- a/app/modules/Events/Interfaces/Http/Resources/EventResource.php +++ b/app/modules/Events/Interfaces/Http/Resources/EventResource.php @@ -6,10 +6,20 @@ use App\Application\HTTP\Response\JsonResource; use Modules\Events\Domain\Event; +use OpenApi\Attributes as OA; /** * @property-read Event $data */ +#[OA\Schema( + schema: 'Event', + properties: [ + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'type', type: 'string'), + new OA\Property(property: 'payload', description: 'Event payload based on type', type: 'object'), + new OA\Property(property: 'timestamp', type: 'float', example: 1630540800.12312), + ], +)] final class EventResource extends JsonResource { public function __construct(Event $data) diff --git a/app/src/Application/Bootloader/RoutesBootloader.php b/app/src/Application/Bootloader/RoutesBootloader.php index 45351b5d..de8e92f9 100644 --- a/app/src/Application/Bootloader/RoutesBootloader.php +++ b/app/src/Application/Bootloader/RoutesBootloader.php @@ -12,6 +12,7 @@ use Spiral\Router\Bootloader\AnnotatedRoutesBootloader; use Spiral\Router\GroupRegistry; use Spiral\Router\Loader\Configurator\RoutingConfigurator; +use Spiral\OpenApi\Controller\DocumentationController; final class RoutesBootloader extends BaseRoutesBootloader { @@ -31,7 +32,9 @@ protected function globalMiddleware(): array protected function middlewareGroups(): array { return [ + 'web' => [], 'api' => [], + 'docs' => [], ]; } @@ -45,6 +48,21 @@ protected function configureRouteGroups(GroupRegistry $groups): void protected function defineRoutes(RoutingConfigurator $routes): void { + $routes + ->add('swagger-api-html', '/api/docs') + ->group('docs') + ->action(DocumentationController::class, 'html'); + + $routes + ->add('swagger-api-json', '/api/docs.json') + ->group('docs') + ->action(DocumentationController::class, 'json'); + + $routes + ->add('swagger-api-yaml', '/api/docs.yaml') + ->group('docs') + ->action(DocumentationController::class, 'yaml'); + $routes->default('/') ->group('web') ->action(EventHandlerAction::class, 'handle'); diff --git a/app/src/Application/HTTP/Response/SuccessResource.php b/app/src/Application/HTTP/Response/SuccessResource.php index c7675b8b..67fbf5f5 100644 --- a/app/src/Application/HTTP/Response/SuccessResource.php +++ b/app/src/Application/HTTP/Response/SuccessResource.php @@ -4,9 +4,18 @@ namespace App\Application\HTTP\Response; +use OpenApi\Attributes as OA; + /** * @property-read bool $data */ +#[OA\Schema( + schema: 'SuccessResource', + properties: [ + new OA\Property(property: 'status', type: 'boolean'), + ], + type: 'object' +)] final class SuccessResource extends JsonResource { public function __construct(bool $status = true) diff --git a/app/src/Application/Kernel.php b/app/src/Application/Kernel.php index bf5f2a7e..5d76a3db 100644 --- a/app/src/Application/Kernel.php +++ b/app/src/Application/Kernel.php @@ -24,6 +24,7 @@ use Spiral\Monolog\Bootloader\MonologBootloader; use Spiral\Nyholm\Bootloader\NyholmBootloader; use Spiral\RoadRunnerBridge\Bootloader as RoadRunnerBridge; +use Spiral\Stempler\Bootloader\StemplerBootloader; use Spiral\Storage\Bootloader\StorageBootloader; use Spiral\Tokenizer\Bootloader\TokenizerListenerBootloader; use Spiral\Validation\Bootloader\ValidationBootloader; @@ -61,6 +62,8 @@ protected function defineBootloaders(): array ValidationBootloader::class, ValidatorBootloader::class, + StemplerBootloader::class, + // HTTP extensions NyholmBootloader::class, Framework\Http\RouterBootloader::class, @@ -86,6 +89,7 @@ protected function defineBootloaders(): array RoadRunnerBridge\CommandBootloader::class, // Configure route groups, middleware for route groups + \Spiral\OpenApi\Bootloader\SwaggerBootloader::class, Bootloader\RoutesBootloader::class, StorageBootloader::class, diff --git a/composer.json b/composer.json index 6d98bb4b..0f3f5ec2 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,8 @@ "spiral/validator": "^1.1", "symfony/mime": "^6.2", "symfony/var-dumper": "^6.1", - "zbateson/mail-mime-parser": "^2.0" + "zbateson/mail-mime-parser": "^2.0", + "zentlix/swagger-php": "1.x-dev" }, "require-dev": { "phpunit/phpunit": "^9.5",