From deec86dceba1ac06ba18ace2cdfaa42ea22117bd Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Fri, 12 Jun 2026 15:19:46 +1200 Subject: [PATCH 1/3] Add `JsonRequestBody` shorthand annotation and attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing `JsonResponse` pattern — wraps ref/type in JsonContent automatically and auto-derives description from the referenced schema. Renames `JsonResponseTrait` to `JsonContentTrait` to reflect its shared usage. Co-Authored-By: Claude Opus 4.6 --- README.md | 44 +++++++ src/Annotations/JsonRequestBody.php | 33 +++++ src/Annotations/JsonResponse.php | 4 +- src/Attributes/JsonRequestBody.php | 44 +++++++ src/Attributes/JsonResponse.php | 4 +- ...ResponseTrait.php => JsonContentTrait.php} | 2 +- src/OpenApiBuilder.php | 4 +- src/Processors/AugmentJsonRequestBody.php | 48 +++++++ tests/JsonRequestBodyTest.php | 124 ++++++++++++++++++ 9 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 src/Annotations/JsonRequestBody.php create mode 100644 src/Attributes/JsonRequestBody.php rename src/{JsonResponseTrait.php => JsonContentTrait.php} (97%) create mode 100644 src/Processors/AugmentJsonRequestBody.php create mode 100644 tests/JsonRequestBodyTest.php diff --git a/README.md b/README.md index 7600062..2693429 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,50 @@ This is equivalent to the more verbose: )] ``` +### `JsonRequestBody` + +A shorthand for JSON request bodies that reference a schema. Reduces nesting by wrapping the ref/type in a `JsonContent` automatically. + +If no `description` is provided, it is derived from the referenced schema (fallback order: title > description > schema name > class short name). + +```php +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]); + } + + parent::__construct($properties); + } +} \ No newline at end of file diff --git a/src/Annotations/JsonResponse.php b/src/Annotations/JsonResponse.php index 7e48570..db3925f 100644 --- a/src/Annotations/JsonResponse.php +++ b/src/Annotations/JsonResponse.php @@ -4,7 +4,7 @@ use OpenApi\Annotations as OA; use OpenApi\Generator; -use Radebatz\OpenApi\Extras\JsonResponseTrait; +use Radebatz\OpenApi\Extras\JsonContentTrait; /** * Shorthand for a JSON response with a schema ref or type. @@ -13,7 +13,7 @@ */ class JsonResponse extends OA\Response { - use JsonResponseTrait; + use JsonContentTrait; public function __construct(array $properties) { diff --git a/src/Attributes/JsonRequestBody.php b/src/Attributes/JsonRequestBody.php new file mode 100644 index 0000000..ddd2b34 --- /dev/null +++ b/src/Attributes/JsonRequestBody.php @@ -0,0 +1,44 @@ +|null $x + * @param OAT\Attachable[]|null $attachables + */ + public function __construct( + string|object $ref = Generator::UNDEFINED, + string|null $type = null, + ?string $request = null, + ?string $description = null, + ?bool $required = null, + ?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; + + parent::__construct( + request: $request, + description: $description ?? Generator::UNDEFINED, + required: $required, + content: $jsonContent, + x: $x, + attachables: $attachables, + ); + } +} \ No newline at end of file diff --git a/src/Attributes/JsonResponse.php b/src/Attributes/JsonResponse.php index fa7f1e0..71bd505 100644 --- a/src/Attributes/JsonResponse.php +++ b/src/Attributes/JsonResponse.php @@ -4,12 +4,12 @@ use OpenApi\Attributes as OAT; use OpenApi\Generator; -use Radebatz\OpenApi\Extras\JsonResponseTrait; +use Radebatz\OpenApi\Extras\JsonContentTrait; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class JsonResponse extends OAT\Response { - use JsonResponseTrait; + use JsonContentTrait; /** * @param string|class-string $ref diff --git a/src/JsonResponseTrait.php b/src/JsonContentTrait.php similarity index 97% rename from src/JsonResponseTrait.php rename to src/JsonContentTrait.php index 0b41cd6..da5c8c5 100644 --- a/src/JsonResponseTrait.php +++ b/src/JsonContentTrait.php @@ -4,7 +4,7 @@ use OpenApi\Generator; -trait JsonResponseTrait +trait JsonContentTrait { /** @var string|class-string */ public $source = Generator::UNDEFINED; diff --git a/src/OpenApiBuilder.php b/src/OpenApiBuilder.php index 2b517de..d406ec0 100644 --- a/src/OpenApiBuilder.php +++ b/src/OpenApiBuilder.php @@ -7,6 +7,7 @@ use OpenApi\Processors\BuildPaths; use OpenApi\Processors\ExpandEnums; use Psr\Log\LoggerInterface; +use Radebatz\OpenApi\Extras\Processors\AugmentJsonRequestBody; use Radebatz\OpenApi\Extras\Processors\AugmentJsonResponse; use Radebatz\OpenApi\Extras\Processors\Customizers; use Radebatz\OpenApi\Extras\Processors\EnumDescription; @@ -196,7 +197,8 @@ public function build(?LoggerInterface $logger = null): Generator $generator->getProcessorPipeline() ->insert(new MergeControllerDefaults(), BuildPaths::class) - ->insert(new AugmentJsonResponse(), BuildPaths::class); + ->insert(new AugmentJsonResponse(), BuildPaths::class) + ->insert(new AugmentJsonRequestBody(), BuildPaths::class); $customizers = $this->customizers; $customizers[OA\Operation::class][] = static function (OA\Operation $operation): void { diff --git a/src/Processors/AugmentJsonRequestBody.php b/src/Processors/AugmentJsonRequestBody.php new file mode 100644 index 0000000..0dbcc16 --- /dev/null +++ b/src/Processors/AugmentJsonRequestBody.php @@ -0,0 +1,48 @@ +getAnnotationsOfType([OAX\JsonRequestBody::class, OAXT\JsonRequestBody::class]); + + foreach ($requestBodies as $requestBody) { + if (!Generator::isDefault($requestBody->description)) { + continue; + } + + if (!Generator::isDefault($requestBody->source)) { + $requestBody->description = $this->resolveDescription($requestBody->source, $analysis); + } + } + } + + protected function resolveDescription(string $source, Analysis $analysis): string + { + $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; + } + } + + $pos = strrpos($source, '\\'); + + return $pos !== false ? substr($source, $pos + 1) : $source; + } +} diff --git a/tests/JsonRequestBodyTest.php b/tests/JsonRequestBodyTest.php new file mode 100644 index 0000000..0b2daf9 --- /dev/null +++ b/tests/JsonRequestBodyTest.php @@ -0,0 +1,124 @@ +assertNotEmpty($requestBody->_unmerged); + $this->assertInstanceOf(OA\JsonContent::class, $requestBody->_unmerged[0]); + } + + public function testAttributeExplicitDescription(): void + { + $requestBody = new JsonRequestBody(ref: TokenPairResource::class, description: 'Custom desc'); + + $this->assertEquals('Custom desc', $requestBody->description); + } + + public function testAttributeNoRef(): void + { + $requestBody = new JsonRequestBody(); + + $this->assertEquals(Generator::UNDEFINED, $requestBody->description); + } + + public function testAttributeRequired(): void + { + $requestBody = new JsonRequestBody(ref: TokenPairResource::class, required: true); + + $this->assertTrue($requestBody->required); + } + + public function testAnnotationWithRef(): void + { + $requestBody = new JsonRequestBodyAnnotation([ + 'ref' => TokenPairResource::class, + ]); + + $this->assertNotEmpty($requestBody->_unmerged); + $this->assertInstanceOf(OA\JsonContent::class, $requestBody->_unmerged[0]); + } + + public function testProcessorResolvesDescriptionFromTitle(): void + { + $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); + + $requestBody = new JsonRequestBody(ref: TokenPairResource::class); + $analysis->addAnnotation($requestBody, new Context([])); + + (new AugmentJsonRequestBody())($analysis); + + $this->assertEquals('Token pair', $requestBody->description); + } + + public function testProcessorResolvesDescriptionFromSchemaDescription(): void + { + $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, Generator::UNDEFINED, 'A token pair request'); + + $requestBody = new JsonRequestBody(ref: TokenPairResource::class); + $analysis->addAnnotation($requestBody, new Context([])); + + (new AugmentJsonRequestBody())($analysis); + + $this->assertEquals('A token pair request', $requestBody->description); + } + + public function testProcessorFallsBackToClassName(): void + { + $analysis = new Analysis([], new Context([])); + + $requestBody = new JsonRequestBody(ref: 'App\\Models\\SomeUnknownClass'); + $analysis->addAnnotation($requestBody, new Context([])); + + (new AugmentJsonRequestBody())($analysis); + + $this->assertEquals('SomeUnknownClass', $requestBody->description); + } + + public function testProcessorSkipsExplicitDescription(): void + { + $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); + + $requestBody = new JsonRequestBody(ref: TokenPairResource::class, description: 'My desc'); + $analysis->addAnnotation($requestBody, new Context([])); + + (new AugmentJsonRequestBody())($analysis); + + $this->assertEquals('My desc', $requestBody->description); + } + + protected function createAnalysisWithSchema(string $class, string $title, string $description): Analysis + { + $shortName = substr($class, strrpos($class, '\\') + 1); + $namespace = substr($class, 0, strrpos($class, '\\')); + + $context = new Context(['namespace' => $namespace, 'class' => $shortName]); + $schema = new OA\Schema([ + 'schema' => $shortName, + 'title' => $title, + 'description' => $description, + '_context' => $context, + ]); + $context->annotations = [$schema]; + + $analysis = new Analysis([], new Context([])); + $analysis->addClassDefinition(['class' => $shortName, 'context' => $context]); + $analysis->addAnnotation($schema, $context); + + return $analysis; + } +} From 0a02c33fdf9dfff76654b95aaf3f29f4ee5fae89 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Fri, 12 Jun 2026 15:21:33 +1200 Subject: [PATCH 2/3] Default `required` to `true` for JsonRequestBody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request bodies are almost always required — this matches the convention used in the custom implementation this feature was inspired by. Co-Authored-By: Claude Opus 4.6 --- src/Attributes/JsonRequestBody.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Attributes/JsonRequestBody.php b/src/Attributes/JsonRequestBody.php index ddd2b34..a7a53c3 100644 --- a/src/Attributes/JsonRequestBody.php +++ b/src/Attributes/JsonRequestBody.php @@ -22,7 +22,7 @@ public function __construct( string|null $type = null, ?string $request = null, ?string $description = null, - ?bool $required = null, + ?bool $required = true, ?array $x = null, ?array $attachables = null, ) { From 592e81afc07a74f2432a50e209629bcc89d4aba3 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Fri, 12 Jun 2026 15:27:39 +1200 Subject: [PATCH 3/3] Fix code style (missing trailing newline) Co-Authored-By: Claude Opus 4.6 --- src/Annotations/JsonRequestBody.php | 2 +- src/Attributes/JsonRequestBody.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Annotations/JsonRequestBody.php b/src/Annotations/JsonRequestBody.php index 3a8b8d9..da23d15 100644 --- a/src/Annotations/JsonRequestBody.php +++ b/src/Annotations/JsonRequestBody.php @@ -30,4 +30,4 @@ public function __construct(array $properties) parent::__construct($properties); } -} \ No newline at end of file +} diff --git a/src/Attributes/JsonRequestBody.php b/src/Attributes/JsonRequestBody.php index a7a53c3..26d0fce 100644 --- a/src/Attributes/JsonRequestBody.php +++ b/src/Attributes/JsonRequestBody.php @@ -41,4 +41,4 @@ public function __construct( attachables: $attachables, ); } -} \ No newline at end of file +}