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); + } +} 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..26d0fce --- /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 = true, + ?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, + ); + } +} 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; + } +}