diff --git a/.gitignore b/.gitignore index 9bc4ba0..c9d96f9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ composer.lock coverage/ vendor/ .idea/ +/.claude/settings.local.json diff --git a/README.md b/README.md index 2693429..b1c5c80 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,7 @@ class LoginRequest class AuthController { + // Method-level with explicit ref #[OAT\Post(path: '/auth/login', operationId: 'login')] #[OAX\JsonRequestBody(ref: LoginRequest::class, required: true)] public function login(): mixed @@ -385,6 +386,13 @@ class AuthController // description auto-derived as "Login credentials" from schema title return '...'; } + + // Parameter-level — ref inferred from type-hint + #[OAT\Post(path: '/auth/register', operationId: 'register')] + public function register(#[OAX\JsonRequestBody] LoginRequest $request): mixed + { + return '...'; + } } ``` @@ -398,6 +406,24 @@ This is equivalent to the more verbose: )] ``` +When `ref` points to a class with an `#[OAT\RequestBody]` annotation, a component `$ref` is generated instead of inline `JsonContent`: + +```php +#[OAT\RequestBody(request: 'SharedCreateBody')] +class SharedCreateBody { /* ... */ } + +class ItemController +{ + #[OAT\Post(path: '/items', operationId: 'createItem')] + #[OAX\JsonRequestBody(ref: SharedCreateBody::class)] + public function create(): mixed + { + // produces: $ref: '#/components/requestBodies/SharedCreateBody' + return '...'; + } +} +``` + ## License diff --git a/src/Annotations/JsonRequestBody.php b/src/Annotations/JsonRequestBody.php index da23d15..e3df93d 100644 --- a/src/Annotations/JsonRequestBody.php +++ b/src/Annotations/JsonRequestBody.php @@ -21,12 +21,7 @@ public function __construct(array $properties) $type = $properties['type'] ?? Generator::UNDEFINED; unset($properties['ref'], $properties['type']); - $resolved = $this->resolveSource($ref, Generator::isDefault($type) ? null : $type); - - if ($resolved['ref'] !== null || $resolved['type'] !== null) { - $jsonContent = new OA\JsonContent(array_filter($resolved)); - $properties['value'] = array_merge($properties['value'] ?? [], [$jsonContent]); - } + $this->resolveSource($ref, Generator::isDefault($type) ? null : $type); parent::__construct($properties); } diff --git a/src/Annotations/JsonResponse.php b/src/Annotations/JsonResponse.php index db3925f..1bfcd7a 100644 --- a/src/Annotations/JsonResponse.php +++ b/src/Annotations/JsonResponse.php @@ -21,12 +21,7 @@ public function __construct(array $properties) $type = $properties['type'] ?? Generator::UNDEFINED; unset($properties['ref'], $properties['type']); - $resolved = $this->resolveSource($ref, Generator::isDefault($type) ? null : $type); - - if ($resolved['ref'] !== null || $resolved['type'] !== null) { - $jsonContent = new OA\JsonContent(array_filter($resolved)); - $properties['value'] = array_merge($properties['value'] ?? [], [$jsonContent]); - } + $this->resolveSource($ref, Generator::isDefault($type) ? null : $type); parent::__construct($properties); } diff --git a/src/Attributes/JsonRequestBody.php b/src/Attributes/JsonRequestBody.php index 26d0fce..6248fdb 100644 --- a/src/Attributes/JsonRequestBody.php +++ b/src/Attributes/JsonRequestBody.php @@ -6,7 +6,7 @@ use OpenApi\Generator; use Radebatz\OpenApi\Extras\JsonContentTrait; -#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] class JsonRequestBody extends OAT\RequestBody { use JsonContentTrait; @@ -26,17 +26,12 @@ public function __construct( ?array $x = null, ?array $attachables = null, ) { - $resolved = $this->resolveSource($ref, $type); - - $jsonContent = ($resolved['ref'] !== null || $resolved['type'] !== null) - ? new OAT\JsonContent(ref: $resolved['ref'], type: $resolved['type']) - : null; + $this->resolveSource($ref, $type); parent::__construct( request: $request, description: $description ?? Generator::UNDEFINED, required: $required, - content: $jsonContent, x: $x, attachables: $attachables, ); diff --git a/src/Attributes/JsonResponse.php b/src/Attributes/JsonResponse.php index 71bd505..e1766d0 100644 --- a/src/Attributes/JsonResponse.php +++ b/src/Attributes/JsonResponse.php @@ -27,17 +27,12 @@ public function __construct( ?array $x = null, ?array $attachables = null, ) { - $resolved = $this->resolveSource($ref, $type); - - $jsonContent = ($resolved['ref'] !== null || $resolved['type'] !== null) - ? new OAT\JsonContent(ref: $resolved['ref'], type: $resolved['type']) - : null; + $this->resolveSource($ref, $type); parent::__construct( response: $response !== Generator::UNDEFINED ? $response : null, description: $description ?? Generator::UNDEFINED, headers: $headers, - content: $jsonContent, x: $x, attachables: $attachables, ); diff --git a/src/JsonContentTrait.php b/src/JsonContentTrait.php index da5c8c5..3835ba5 100644 --- a/src/JsonContentTrait.php +++ b/src/JsonContentTrait.php @@ -11,23 +11,12 @@ trait JsonContentTrait public static $_blacklist = ['_context', '_unmerged', '_analysis', 'attachables', 'source']; - /** - * @return array{ref: string|class-string|null, type: string|class-string|null} - */ - protected function resolveSource(string|object $ref, ?string $type): array + protected function resolveSource(string|object $ref, ?string $type): void { - $resolvedRef = null; - $resolvedType = null; - if (!Generator::isDefault($ref)) { - $resolvedRef = $ref; $this->source = $ref; + } elseif ($type !== null) { + $this->source = $type; } - if ($type !== null) { - $resolvedType = $type; - $this->source = $this->source !== Generator::UNDEFINED ? $this->source : $type; - } - - return ['ref' => $resolvedRef, 'type' => $resolvedType]; } } diff --git a/src/Processors/AugmentJsonRequestBody.php b/src/Processors/AugmentJsonRequestBody.php index 0dbcc16..c79bb56 100644 --- a/src/Processors/AugmentJsonRequestBody.php +++ b/src/Processors/AugmentJsonRequestBody.php @@ -4,45 +4,67 @@ use OpenApi\Analysis; use OpenApi\Annotations as OA; +use OpenApi\Context; use OpenApi\Generator; use Radebatz\OpenApi\Extras\Annotations as OAX; use Radebatz\OpenApi\Extras\Attributes as OAXT; +use Radebatz\OpenApi\Extras\Processors\Concerns\ResolvesDescription; class AugmentJsonRequestBody { + use ResolvesDescription; + public function __invoke(Analysis $analysis): void { $requestBodies = $analysis->getAnnotationsOfType([OAX\JsonRequestBody::class, OAXT\JsonRequestBody::class]); foreach ($requestBodies as $requestBody) { - if (!Generator::isDefault($requestBody->description)) { + $source = $this->resolveSource($requestBody); + if ($source === null) { continue; } - if (!Generator::isDefault($requestBody->source)) { - $requestBody->description = $this->resolveDescription($requestBody->source, $analysis); + $requestBody->source = $source; + + if (Generator::isDefault($requestBody->content) && Generator::isDefault($requestBody->ref)) { + $this->dispatch($requestBody, $source, $analysis); + } + + if (Generator::isDefault($requestBody->description)) { + $requestBody->description = $this->resolveDescription($source, $analysis); } } } - protected function resolveDescription(string $source, Analysis $analysis): string + protected function resolveSource(OAX\JsonRequestBody|OAXT\JsonRequestBody $requestBody): ?string { - $schema = $analysis->getAnnotationForSource($source, OA\Schema::class); + if (!Generator::isDefault($requestBody->source)) { + return $requestBody->source; + } - if ($schema instanceof OA\Schema) { - if (!Generator::isDefault($schema->title)) { - return $schema->title; - } - if (!Generator::isDefault($schema->description)) { - return $schema->description; - } - if (!Generator::isDefault($schema->schema)) { - return $schema->schema; + $reflector = $requestBody->_context->reflector ?? null; + if ($reflector instanceof \ReflectionParameter) { + $type = $reflector->getType(); + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + return $type->getName(); } } - $pos = strrpos($source, '\\'); + return null; + } + + protected function dispatch(OA\RequestBody $requestBody, string $source, Analysis $analysis): void + { + $sourceRequestBody = $analysis->getAnnotationForSource($source, OA\RequestBody::class); + if ($sourceRequestBody instanceof OA\AbstractAnnotation) { + $requestBody->ref = OA\Components::ref($sourceRequestBody); + + return; + } + + $context = new Context(['nested' => $requestBody], $requestBody->_context); + $jsonContent = new OA\JsonContent(['ref' => $source, '_context' => $context]); - return $pos !== false ? substr($source, $pos + 1) : $source; + $analysis->addAnnotation($jsonContent, $context); } } diff --git a/src/Processors/AugmentJsonResponse.php b/src/Processors/AugmentJsonResponse.php index 53cf520..e947429 100644 --- a/src/Processors/AugmentJsonResponse.php +++ b/src/Processors/AugmentJsonResponse.php @@ -4,46 +4,40 @@ use OpenApi\Analysis; use OpenApi\Annotations as OA; +use OpenApi\Context; use OpenApi\Generator; use Radebatz\OpenApi\Extras\Annotations as OAX; use Radebatz\OpenApi\Extras\Attributes as OAXT; +use Radebatz\OpenApi\Extras\Processors\Concerns\ResolvesDescription; class AugmentJsonResponse { + use ResolvesDescription; + public function __invoke(Analysis $analysis): void { $responses = $analysis->getAnnotationsOfType([OAX\JsonResponse::class, OAXT\JsonResponse::class]); foreach ($responses as $response) { - if (!Generator::isDefault($response->description)) { + if (Generator::isDefault($response->source)) { continue; } - if (!Generator::isDefault($response->source)) { + if (Generator::isDefault($response->content)) { + $this->createJsonContent($response, $analysis); + } + + if (Generator::isDefault($response->description)) { $response->description = $this->resolveDescription($response->source, $analysis); } } } - protected function resolveDescription(string $source, Analysis $analysis): string + protected function createJsonContent(OAX\JsonResponse|OAXT\JsonResponse $response, Analysis $analysis): void { - $schema = $analysis->getAnnotationForSource($source, OA\Schema::class); - - if ($schema instanceof OA\Schema) { - if (!Generator::isDefault($schema->title)) { - return $schema->title; - } - if (!Generator::isDefault($schema->description)) { - return $schema->description; - } - if (!Generator::isDefault($schema->schema)) { - return $schema->schema; - } - } - - // Fallback to short class name - $pos = strrpos($source, '\\'); + $context = new Context(['nested' => $response], $response->_context); + $jsonContent = new OA\JsonContent(['ref' => $response->source, '_context' => $context]); - return $pos !== false ? substr($source, $pos + 1) : $source; + $analysis->addAnnotation($jsonContent, $context); } } diff --git a/src/Processors/Concerns/ResolvesDescription.php b/src/Processors/Concerns/ResolvesDescription.php new file mode 100644 index 0000000..138ef93 --- /dev/null +++ b/src/Processors/Concerns/ResolvesDescription.php @@ -0,0 +1,31 @@ +getAnnotationForSource($source, OA\Schema::class); + + if ($schema instanceof OA\Schema) { + if (!Generator::isDefault($schema->title)) { + return $schema->title; + } + if (!Generator::isDefault($schema->description)) { + return $schema->description; + } + if (!Generator::isDefault($schema->schema)) { + return $schema->schema; + } + } + + $pos = strrpos($source, '\\'); + + return $pos !== false ? substr($source, $pos + 1) : $source; + } +} diff --git a/tests/Fixtures/Controllers/Attributes/ParameterController.php b/tests/Fixtures/Controllers/Attributes/ParameterController.php new file mode 100644 index 0000000..4b9c0ac --- /dev/null +++ b/tests/Fixtures/Controllers/Attributes/ParameterController.php @@ -0,0 +1,12 @@ +assertNotEmpty($requestBody->_unmerged); - $this->assertInstanceOf(OA\JsonContent::class, $requestBody->_unmerged[0]); + $this->assertEquals(TokenPairResource::class, $requestBody->source); } public function testAttributeExplicitDescription(): void @@ -34,6 +33,7 @@ public function testAttributeNoRef(): void $requestBody = new JsonRequestBody(); $this->assertEquals(Generator::UNDEFINED, $requestBody->description); + $this->assertEquals(Generator::UNDEFINED, $requestBody->source); } public function testAttributeRequired(): void @@ -49,8 +49,21 @@ public function testAnnotationWithRef(): void 'ref' => TokenPairResource::class, ]); - $this->assertNotEmpty($requestBody->_unmerged); - $this->assertInstanceOf(OA\JsonContent::class, $requestBody->_unmerged[0]); + $this->assertEquals(TokenPairResource::class, $requestBody->source); + } + + public function testProcessorCreatesJsonContent(): void + { + $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); + + $requestBody = new JsonRequestBody(ref: TokenPairResource::class); + $analysis->addAnnotation($requestBody, new Context([])); + + (new AugmentJsonRequestBody())($analysis); + + $jsonContents = $analysis->getAnnotationsOfType(OA\JsonContent::class); + $this->assertCount(1, $jsonContents); + $this->assertEquals(TokenPairResource::class, $jsonContents[0]->ref); } public function testProcessorResolvesDescriptionFromTitle(): void @@ -101,6 +114,36 @@ public function testProcessorSkipsExplicitDescription(): void $this->assertEquals('My desc', $requestBody->description); } + public function testProcessorResolvesSourceFromParameterTypeHint(): void + { + $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); + + $requestBody = new JsonRequestBody(); + $rp = new \ReflectionParameter([Fixtures\Controllers\Attributes\ParameterController::class, 'create'], 'resource'); + $requestBody->_context = new Context(['reflector' => $rp]); + $analysis->addAnnotation($requestBody, $requestBody->_context); + + (new AugmentJsonRequestBody())($analysis); + + $this->assertEquals(TokenPairResource::class, $requestBody->source); + $jsonContents = $analysis->getAnnotationsOfType(OA\JsonContent::class); + $this->assertCount(1, $jsonContents); + $this->assertEquals(TokenPairResource::class, $jsonContents[0]->ref); + } + + public function testProcessorSetsComponentRefForRequestBodySource(): void + { + $analysis = $this->createAnalysisWithRequestBody('App\\Requests\\SharedCreateBody', 'SharedCreateBody'); + + $requestBody = new JsonRequestBody(ref: 'App\\Requests\\SharedCreateBody'); + $analysis->addAnnotation($requestBody, new Context([])); + + (new AugmentJsonRequestBody())($analysis); + + $this->assertEquals('#/components/requestBodies/SharedCreateBody', $requestBody->ref); + $this->assertEmpty($requestBody->_unmerged); + } + protected function createAnalysisWithSchema(string $class, string $title, string $description): Analysis { $shortName = substr($class, strrpos($class, '\\') + 1); @@ -121,4 +164,23 @@ protected function createAnalysisWithSchema(string $class, string $title, string return $analysis; } + + protected function createAnalysisWithRequestBody(string $class, string $name): Analysis + { + $shortName = substr($class, strrpos($class, '\\') + 1); + $namespace = substr($class, 0, strrpos($class, '\\')); + + $context = new Context(['namespace' => $namespace, 'class' => $shortName]); + $rb = new OA\RequestBody([ + 'request' => $name, + '_context' => $context, + ]); + $context->annotations = [$rb]; + + $analysis = new Analysis([], new Context([])); + $analysis->addClassDefinition(['class' => $shortName, 'context' => $context]); + $analysis->addAnnotation($rb, $context); + + return $analysis; + } } diff --git a/tests/JsonResponseTest.php b/tests/JsonResponseTest.php index d2c226a..0e891c7 100644 --- a/tests/JsonResponseTest.php +++ b/tests/JsonResponseTest.php @@ -19,8 +19,7 @@ public function testAttributeWithRef(): void $response = new JsonResponse(response: 200, ref: TokenPairResource::class); $this->assertEquals(200, $response->response); - $this->assertNotEmpty($response->_unmerged); - $this->assertInstanceOf(OA\JsonContent::class, $response->_unmerged[0]); + $this->assertEquals(TokenPairResource::class, $response->source); } public function testAttributeExplicitDescription(): void @@ -35,6 +34,7 @@ public function testAttributeNoRef(): void $response = new JsonResponse(response: 204); $this->assertEquals(Generator::UNDEFINED, $response->description); + $this->assertEquals(Generator::UNDEFINED, $response->source); } public function testAnnotationWithRef(): void @@ -45,8 +45,21 @@ public function testAnnotationWithRef(): void ]); $this->assertEquals(200, $response->response); - $this->assertNotEmpty($response->_unmerged); - $this->assertInstanceOf(OA\JsonContent::class, $response->_unmerged[0]); + $this->assertEquals(TokenPairResource::class, $response->source); + } + + public function testProcessorCreatesJsonContent(): void + { + $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); + + $response = new JsonResponse(response: 200, ref: TokenPairResource::class); + $analysis->addAnnotation($response, new Context([])); + + (new AugmentJsonResponse())($analysis); + + $jsonContents = $analysis->getAnnotationsOfType(OA\JsonContent::class); + $this->assertCount(1, $jsonContents); + $this->assertEquals(TokenPairResource::class, $jsonContents[0]->ref); } public function testProcessorResolvesDescriptionFromTitle(): void