From dd74a80b1ec7f23b7a6ed556202ebe3a0681f705 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:50:21 +0000 Subject: [PATCH] fix(api): infer JsonResource schemas in docs --- .../src/Api/Documentation/OpenApiBuilder.php | 171 +++++++++++++++++- .../OpenApiDocumentationComprehensiveTest.php | 23 +++ 2 files changed, 192 insertions(+), 2 deletions(-) diff --git a/src/php/src/Api/Documentation/OpenApiBuilder.php b/src/php/src/Api/Documentation/OpenApiBuilder.php index cfb7090..249962f 100644 --- a/src/php/src/Api/Documentation/OpenApiBuilder.php +++ b/src/php/src/Api/Documentation/OpenApiBuilder.php @@ -646,14 +646,181 @@ class OpenApiBuilder return ['type' => 'object']; } - // For now, return a generic object schema - // A more sophisticated implementation would analyze the resource's toArray method + try { + $resource = new $resourceClass(new \stdClass); + $data = $resource->toArray(request()); + + if (is_array($data)) { + return $this->inferArraySchema($data); + } + } catch (\Throwable) { + // Fall back to a generic object schema when the resource cannot + // be instantiated safely in the current context. + } + return [ 'type' => 'object', 'additionalProperties' => true, ]; } + /** + * Infer an OpenAPI schema from a PHP array. + */ + protected function inferArraySchema(array $value): array + { + if (array_is_list($value)) { + $itemSchema = ['type' => 'object']; + + foreach ($value as $item) { + if ($item === null) { + continue; + } + + $itemSchema = $this->inferValueSchema($item); + break; + } + + return [ + 'type' => 'array', + 'items' => $itemSchema, + ]; + } + + $properties = []; + foreach ($value as $key => $item) { + $properties[(string) $key] = $this->inferValueSchema($item, (string) $key); + } + + return [ + 'type' => 'object', + 'properties' => $properties, + 'additionalProperties' => true, + ]; + } + + /** + * Infer an OpenAPI schema node from a PHP value. + */ + protected function inferValueSchema(mixed $value, ?string $key = null): array + { + if ($value === null) { + return $this->inferNullableSchema($key); + } + + if (is_bool($value)) { + return ['type' => 'boolean']; + } + + if (is_int($value)) { + return ['type' => 'integer']; + } + + if (is_float($value)) { + return ['type' => 'number']; + } + + if (is_string($value)) { + return $this->inferStringSchema($value, $key); + } + + if (is_array($value)) { + return $this->inferArraySchema($value); + } + + if (is_object($value)) { + return $this->inferObjectSchema($value); + } + + return []; + } + + /** + * Infer a schema for a null value using the field name as a hint. + */ + protected function inferNullableSchema(?string $key): array + { + if ($key === null) { + return ['nullable' => true]; + } + + $normalized = strtolower($key); + + return match (true) { + $normalized === 'id', + str_ends_with($normalized, '_id'), + str_ends_with($normalized, 'count'), + str_ends_with($normalized, 'total'), + str_ends_with($normalized, 'page'), + str_ends_with($normalized, 'limit'), + str_ends_with($normalized, 'offset'), + str_ends_with($normalized, 'size'), + str_ends_with($normalized, 'quantity'), + str_ends_with($normalized, 'rank'), + str_ends_with($normalized, 'score') => ['type' => 'integer', 'nullable' => true], + str_starts_with($normalized, 'is_'), + str_starts_with($normalized, 'has_'), + str_starts_with($normalized, 'can_'), + str_starts_with($normalized, 'should_'), + str_starts_with($normalized, 'enabled'), + str_starts_with($normalized, 'active') => ['type' => 'boolean', 'nullable' => true], + str_ends_with($normalized, '_at'), + str_ends_with($normalized, '_on'), + str_contains($normalized, 'date'), + str_contains($normalized, 'time'), + str_contains($normalized, 'timestamp') => ['type' => 'string', 'format' => 'date-time', 'nullable' => true], + str_contains($normalized, 'email') => ['type' => 'string', 'format' => 'email', 'nullable' => true], + str_contains($normalized, 'url'), + str_contains($normalized, 'uri') => ['type' => 'string', 'format' => 'uri', 'nullable' => true], + str_contains($normalized, 'uuid') => ['type' => 'string', 'format' => 'uuid', 'nullable' => true], + str_contains($normalized, 'name'), + str_contains($normalized, 'title'), + str_contains($normalized, 'description'), + str_contains($normalized, 'status'), + str_contains($normalized, 'type'), + str_contains($normalized, 'code'), + str_contains($normalized, 'token'), + str_contains($normalized, 'slug'), + str_contains($normalized, 'key') => ['type' => 'string', 'nullable' => true], + default => ['nullable' => true], + }; + } + + /** + * Infer a schema for a string value using the field name as a hint. + */ + protected function inferStringSchema(string $value, ?string $key): array + { + if ($key !== null) { + $nullable = $this->inferNullableSchema($key); + + if (($nullable['type'] ?? null) === 'string') { + $nullable['nullable'] = false; + return $nullable; + } + } + + return ['type' => 'string']; + } + + /** + * Infer a schema for an object value. + */ + protected function inferObjectSchema(object $value): array + { + $properties = []; + + foreach (get_object_vars($value) as $key => $item) { + $properties[$key] = $this->inferValueSchema($item, (string) $key); + } + + return [ + 'type' => 'object', + 'properties' => $properties, + 'additionalProperties' => true, + ]; + } + /** * Wrap schema in pagination structure. */ diff --git a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php index d0c74f7..ae33d5c 100644 --- a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php +++ b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php @@ -458,6 +458,29 @@ describe('ApiResponse Attribute Rendering', function () { expect($response->resource)->toBe(TestJsonResource::class); }); + it('infers resource schema fields from JsonResource payloads', function () { + config(['api-docs.routes.include' => ['api/*']]); + config(['api-docs.routes.exclude' => []]); + + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/test-scan/items/{id}', [TestOpenApiController::class, 'show']); + }); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $schema = $spec['paths']['/api/test-scan/items/{id}']['get']['responses']['200']['content']['application/json']['schema'] ?? null; + + expect($schema)->not->toBeNull(); + expect($schema['type'])->toBe('object'); + expect($schema['properties'])->toHaveKey('id') + ->toHaveKey('name'); + expect($schema['properties']['id']['type'])->toBe('integer'); + expect($schema['properties']['name']['type'])->toBe('string'); + }); + it('supports paginated flag', function () { $response = new ApiResponse( status: 200,