diff --git a/app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php b/app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php deleted file mode 100644 index 5369743d..00000000 --- a/app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php +++ /dev/null @@ -1,107 +0,0 @@ -isInstanceOf(PaginatedResourceCollection::class); - } - - public function toSchema(Type $type): ?OpenApiObjectType - { - /** @var Type|null $collectingClassType */ - $collectingClassType = $type->templateTypes[0] ?? null; - - if (! $collectingClassType instanceof ObjectType) { - return null; - } - - if (! $collectingClassType->isInstanceOf(JsonResource::class) && ! $collectingClassType->isInstanceOf(Model::class)) { - return null; - } - - $collectingType = $this->openApiTransformer->transform($collectingClassType); - - $newType = new OpenApiObjectType; - $newType->addProperty('data', (new ArrayType)->setItems($collectingType)); - if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) { - $newType->addProperty( - 'meta', - (new OpenApiObjectType) - ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.')) - ->setRequired(['total']) - ); - $newType->setRequired(['data', 'meta']); - } else { - $newType->addProperty( - 'links', - (new OpenApiObjectType) - ->addProperty('first', (new StringType)->nullable(true)) - ->addProperty('last', (new StringType)->nullable(true)) - ->addProperty('prev', (new StringType)->nullable(true)) - ->addProperty('next', (new StringType)->nullable(true)) - ->setRequired(['first', 'last', 'prev', 'next']) - ); - $newType->addProperty( - 'meta', - (new OpenApiObjectType) - ->addProperty('current_page', new IntegerType) - ->addProperty('from', (new IntegerType)->nullable(true)) - ->addProperty('last_page', new IntegerType) - ->addProperty('links', (new ArrayType)->setItems( - (new OpenApiObjectType) - ->addProperty('url', (new StringType)->nullable(true)) - ->addProperty('label', new StringType) - ->addProperty('active', new BooleanType) - ->setRequired(['url', 'label', 'active']) - )->setDescription('Generated paginator links.')) - ->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.')) - ->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.')) - ->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.')) - ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.')) - ->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total']) - ); - $newType->setRequired(['data', 'links', 'meta']); - } - - return $newType; - } - - /** - * @param Generic $type - */ - public function toResponse(Type $type): ?Response - { - /** @var ObjectType|null $collectingClassType */ - $collectingClassType = $type->templateTypes[0] ?? null; - if (! $collectingClassType instanceof ObjectType) { - return null; - } - $type = $this->toSchema($type); - - return Response::make(200) - ->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`') - ->setContent('application/json', Schema::fromType($type)); - } -} diff --git a/app/Http/Controllers/Api/FallbackController.php b/app/Http/Controllers/Api/FallbackController.php new file mode 100644 index 00000000..811c9153 --- /dev/null +++ b/app/Http/Controllers/Api/FallbackController.php @@ -0,0 +1,21 @@ + - * * @throws AuthorizationException * * @operationId getClients diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index ff39d815..e036131f 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -9,7 +9,6 @@ use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Http\Requests\V1\Invitation\InvitationIndexRequest; use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Resources\V1\Invitation\InvitationCollection; -use App\Http\Resources\V1\Invitation\InvitationResource; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Service\InvitationService; @@ -30,8 +29,6 @@ class InvitationController extends Controller /** * List all invitations of an organization * - * @return InvitationCollection - * * @throws AuthorizationException * * @operationId getInvitations diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index 3b6132a1..5f297a13 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -48,8 +48,6 @@ class MemberController extends Controller /** * List all members of an organization * - * @return MemberCollection - * * @throws AuthorizationException * * @operationId getMembers diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 6f884265..d836b4ac 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -35,8 +35,6 @@ class ProjectController extends Controller /** * Get projects visible to the current user * - * @return ProjectCollection - * * @throws AuthorizationException * * @operationId getProjects diff --git a/app/Http/Controllers/Api/V1/ProjectMemberController.php b/app/Http/Controllers/Api/V1/ProjectMemberController.php index cb940f9a..0cc6d4c4 100644 --- a/app/Http/Controllers/Api/V1/ProjectMemberController.php +++ b/app/Http/Controllers/Api/V1/ProjectMemberController.php @@ -36,8 +36,6 @@ class ProjectMemberController extends Controller /** * Get project members for project * - * @return ProjectMemberCollection - * * @throws AuthorizationException * * @operationId getProjectMembers diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php index 710d1722..4f592265 100644 --- a/app/Http/Controllers/Api/V1/ReportController.php +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -10,7 +10,6 @@ use App\Http\Requests\V1\Report\ReportStoreRequest; use App\Http\Requests\V1\Report\ReportUpdateRequest; use App\Http\Resources\V1\Report\DetailedReportResource; use App\Http\Resources\V1\Report\ReportCollection; -use App\Http\Resources\V1\Report\ReportResource; use App\Models\Organization; use App\Models\Report; use App\Service\Dto\ReportPropertiesDto; @@ -35,8 +34,6 @@ class ReportController extends Controller /** * Get reports * - * @return ReportCollection - * * @throws AuthorizationException * * @operationId getReports diff --git a/app/Http/Controllers/Api/V1/TagController.php b/app/Http/Controllers/Api/V1/TagController.php index 45a1d90e..5ed7c7db 100644 --- a/app/Http/Controllers/Api/V1/TagController.php +++ b/app/Http/Controllers/Api/V1/TagController.php @@ -29,8 +29,6 @@ class TagController extends Controller /** * Get tags * - * @return TagCollection - * * @operationId getTags * * @throws AuthorizationException diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index ea04cf15..d5a90757 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -51,8 +51,6 @@ class TaskController extends Controller /** * Get tasks * - * @return TaskCollection - * * @throws AuthorizationException * * @operationId getTasks diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index b5fce2ac..9c77b2c4 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -117,8 +117,6 @@ class TimeEntryController extends Controller * Results are paginated with `limit` (default 100, max 500) and `offset`; check `meta.total` and fetch further pages when needed. * To find the running timer, use `active=true` (or `GET /v1/users/me/time-entries/active`). * - * @return TimeEntryCollection - * * @throws AuthorizationException * * @operationId getTimeEntries diff --git a/app/Http/Resources/PaginatedResourceCollection.php b/app/Http/Resources/PaginatedResourceCollection.php deleted file mode 100644 index f1fe2a8f..00000000 --- a/app/Http/Resources/PaginatedResourceCollection.php +++ /dev/null @@ -1,7 +0,0 @@ - [ ApiExceptionTypeToSchema::class, - PaginatedResourceCollectionTypeToSchema::class, ], ]; diff --git a/routes/api.php b/routes/api.php index 6f1cfae2..37e459d5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Http\Controllers\Api\FallbackController; use App\Http\Controllers\Api\V1\ApiTokenController; use App\Http\Controllers\Api\V1\ChartController; use App\Http\Controllers\Api\V1\ClientController; @@ -23,7 +24,6 @@ use App\Http\Controllers\Api\V1\UserController; use App\Http\Controllers\Api\V1\UserMembershipController; use App\Http\Controllers\Api\V1\UserTimeEntryController; use Illuminate\Support\Facades\Route; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /* |-------------------------------------------------------------------------- @@ -200,9 +200,5 @@ Route::prefix('v1')->name('v1.')->group(static function (): void { * Fallback routes, to prevent a rendered HTML page in /api/* routes * The / route is also included since the fallback is not triggered on the root route */ -Route::get('/', function (): void { - throw new NotFoundHttpException('API resource not found'); -}); -Route::fallback(function (): void { - throw new NotFoundHttpException('API resource not found'); -}); +Route::get('/', FallbackController::class); +Route::fallback(FallbackController::class); diff --git a/tests/Unit/ApiDocs/ApiDocsExportTest.php b/tests/Unit/ApiDocs/ApiDocsExportTest.php new file mode 100644 index 00000000..351b6879 --- /dev/null +++ b/tests/Unit/ApiDocs/ApiDocsExportTest.php @@ -0,0 +1,113 @@ +path = storage_path('framework/testing/api-docs-'.uniqid().'.json'); + File::ensureDirectoryExists(dirname($this->path)); + } + + protected function tearDown(): void + { + File::delete($this->path); + parent::tearDown(); + } + + /** + * @return array + */ + private function exportApiDocs(): array + { + $exitCode = $this->withoutMockingConsoleOutput()->artisan('scramble:export', [ + '--path' => $this->path, + ]); + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertFileExists($this->path); + + return json_decode(File::get($this->path), true, flags: JSON_THROW_ON_ERROR); + } + + /** + * @param array $docs + * @param array $schema + * @return array + */ + private function resolveSchema(array $docs, array $schema): array + { + if (isset($schema['$ref'])) { + $name = str_replace('#/components/schemas/', '', $schema['$ref']); + + return $this->resolveSchema($docs, $docs['components']['schemas'][$name]); + } + + return $schema; + } + + public function test_api_docs_can_be_exported(): void + { + // Act + $docs = $this->exportApiDocs(); + + // Assert + $this->assertArrayHasKey('paths', $docs); + $this->assertNotEmpty($docs['paths']); + } + + public function test_paginated_endpoints_are_documented_with_pagination(): void + { + // Act + $docs = $this->exportApiDocs(); + + // Assert + $tagsSchema = $docs['paths']['/v1/organizations/{organization}/tags']['get']['responses']['200']['content']['application/json']['schema']; + $this->assertSame(['data', 'links', 'meta'], $tagsSchema['required']); + $tagsData = $this->resolveSchema($docs, $tagsSchema['properties']['data']); + $this->assertSame('array', $tagsData['type']); + $this->assertSame('#/components/schemas/TagResource', $tagsData['items']['$ref']); + + $timeEntriesSchema = $docs['paths']['/v1/organizations/{organization}/time-entries']['get']['responses']['200']['content']['application/json']['schema']; + $this->assertSame(['data', 'meta'], $timeEntriesSchema['required']); + $this->assertSame(['total'], $timeEntriesSchema['properties']['meta']['required']); + } + + public function test_fallback_routes_are_excluded_from_api_docs(): void + { + // Act + $docs = $this->exportApiDocs(); + + // Assert + $this->assertArrayNotHasKey('/', $docs['paths']); + $this->assertArrayNotHasKey('/{fallbackPlaceholder}', $docs['paths']); + } + + public function test_fallback_routes_return_not_found(): void + { + // Act + $rootResponse = $this->getJson('/api'); + $unknownResponse = $this->getJson('/api/does-not-exist'); + + // Assert + $rootResponse->assertNotFound(); + $rootResponse->assertJsonPath('message', 'API resource not found'); + $unknownResponse->assertNotFound(); + $unknownResponse->assertJsonPath('message', 'API resource not found'); + } +}