diff --git a/README.md b/README.md index b1c5c80..1dde0c0 100644 --- a/README.md +++ b/README.md @@ -177,57 +177,6 @@ class UserController extends BaseController } ``` -### `DataSchema` - -The `DataSchema` annotation/attribute wraps properties inside a `data` object envelope, reducing boilerplate for APIs that use a standard wrapper pattern. - -Properties with `nullable: false` are automatically added to the `data` object's `required` list. -You can also explicitly pass a `required` list in the constructor. - -```php - description > schema name > class short name). +| Parameter | Default | Effect | +|---------------|----------|-----------------------------------| +| `wrap` | `'data'` | Property name for the envelope | + ```php description > schema name > class short name). diff --git a/composer.json b/composer.json index 112415d..1b7a01d 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,7 @@ "php": ">=8.1", "psr/log": "^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", - "zircote/swagger-php": "^4.11.1 || ^5.0 || ^6.0.3" + "zircote/swagger-php": "^5.0 || ^6.0.3" }, "require-dev": { "composer/package-versions-deprecated": "^1.11", diff --git a/docs/adr/002-processor-pipeline-ordering.md b/docs/adr/002-processor-pipeline-ordering.md index 3c25460..42ae386 100644 --- a/docs/adr/002-processor-pipeline-ordering.md +++ b/docs/adr/002-processor-pipeline-ordering.md @@ -6,7 +6,7 @@ Accepted ## Context -The swagger-php `Generator` runs annotations through a pipeline of processors in a fixed order. This library adds three custom processors that must be inserted at specific positions relative to the default pipeline to function correctly. +The swagger-php `Generator` runs annotations through a pipeline of processors in a fixed order. This library adds custom processors that must be inserted at specific positions relative to the default pipeline to function correctly. ## Decision @@ -24,14 +24,21 @@ This processor generates human-readable descriptions from PHP enum cases. It mus - `ExpandEnums` replaces enum class references with their scalar values. After it runs, the link back to the `ReflectionEnum` (needed to enumerate case names) is lost. -### `MiddlewareCustomizers` — inserted before `AugmentParameters` +### `AugmentJsonResponse` / `AugmentJsonRequestBody` — inserted before `BuildPaths` -This processor applies scoped customizers from middleware classes implementing `ProvidesCustomizersInterface`. It runs **after** `BuildPaths` because: +These processors create `JsonContent` from `source` (the `ref` parameter) when no explicit content is provided, and resolve descriptions from the referenced schema. They must run **before** `BuildPaths` because: -- Operations must be fully assembled with their merged middleware attachables (done by `MergeControllerDefaults` before `BuildPaths`). -- It needs to see the final operation structure to apply mutations like `security`. +- `BuildPaths` needs the response/request body structure to be in place. +- The generated `JsonContent` must be available for `MergeJsonContent` (which runs later) to convert into `MediaType`. -It runs **before** `AugmentParameters` so that any parameters or references added by middleware customizers are still processed by the standard augmentation pipeline. +### `WrapJsonResponseContent` — inserted before `OperationId` (after `MergeJsonContent`) + +This processor wraps the resolved schema of `JsonResponse` instances inside an envelope property. It must run **after** `MergeJsonContent` because: + +- `MergeJsonContent` converts `JsonContent` annotations into `MediaType` with a resolved `schema`. The wrapper needs that fully resolved schema to nest it inside the envelope property. +- Running earlier would mean the schema hasn't been assembled yet. + +It pairs with `AugmentJsonResponse` (which runs early to create `JsonContent` from `ref`/`source` and resolve descriptions). The two together form a create-then-wrap pipeline. ### `Customizers` — appended at the end diff --git a/docs/adr/004-response-wrapping-as-transport-concern.md b/docs/adr/004-response-wrapping-as-transport-concern.md new file mode 100644 index 0000000..851f6e4 --- /dev/null +++ b/docs/adr/004-response-wrapping-as-transport-concern.md @@ -0,0 +1,25 @@ +# ADR-004: Response Wrapping as a Transport Concern + +## Status + +Accepted + +## Context + +APIs commonly wrap resource payloads in an envelope (e.g. `{"data": ...}`). Many frameworks follow this convention — for example Laravel's `JsonResource` defaults to `$wrap = 'data'` — but the pattern is framework-agnostic. + +The question is where this wrapping should be expressed in the OpenAPI spec: on the schema definition (data shape) or on the response annotation (transport layer). + +## Decision + +Wrapping belongs on the response, not the schema. + +`JsonResponse` accepts a `wrap` parameter (default `'data'`) that tells the `WrapJsonResponseContent` processor to wrap the resolved schema inside an inline envelope with the wrap key as a required property. + +Schemas remain pure data shapes — they describe the resource, not how it's delivered. + +## Consequences + +- A single schema can be referenced by responses with different envelope conventions (or no envelope at all via a regular `OAT\Response`). +- The processor generates the wrapper inline per-response, so there's no shared "envelope" schema polluting components. +- If additional top-level properties are needed alongside the wrap key (pagination, links), the processor can be extended without changing schema definitions. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index aab4991..7bfece8 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,2 +1,7 @@ parameters: - ignoreErrors: [] + ignoreErrors: + - + message: '#^Call to function method_exists\(\) with OpenApi\\Analysis and ''removeAnnotation'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Processors/WrapJsonResponseContent.php diff --git a/src/Annotations/DataSchema.php b/src/Annotations/DataSchema.php deleted file mode 100644 index 8313cdf..0000000 --- a/src/Annotations/DataSchema.php +++ /dev/null @@ -1,79 +0,0 @@ -data_property = new OA\Property([ - 'property' => 'data', - 'required' => $dataRequired, - 'properties' => $dataProperties, - 'type' => 'object', - ]); - - $properties['required'] = ['data']; - $properties['properties'] = [$this->data_property]; - - parent::__construct($properties); - } - - public function merge(array $annotations, bool $ignore = false): array - { - $forwarded = []; - $remaining = []; - - foreach ($annotations as $annotation) { - if ($annotation instanceof OA\Property) { - $forwarded[] = $annotation; - } else { - $remaining[] = $annotation; - } - } - - if ($forwarded !== []) { - if (Generator::isDefault($this->data_property->properties)) { - $this->data_property->properties = []; - } - - foreach ($forwarded as $property) { - $this->data_property->properties[] = $property; - - if ($property->nullable === false) { - $name = Generator::isDefault($property->property) - ? $property->_context->property ?? null - : ($property->property); - - if ($name) { - if (Generator::isDefault($this->data_property->required)) { - $this->data_property->required = []; - } - if (!in_array($name, $this->data_property->required, true)) { - $this->data_property->required[] = $name; - } - } - } - } - } - - return parent::merge($remaining, $ignore); - } -} diff --git a/src/Annotations/JsonRequestBody.php b/src/Annotations/JsonRequestBody.php index e3df93d..306dfa4 100644 --- a/src/Annotations/JsonRequestBody.php +++ b/src/Annotations/JsonRequestBody.php @@ -4,24 +4,23 @@ use OpenApi\Annotations as OA; use OpenApi\Generator; -use Radebatz\OpenApi\Extras\JsonContentTrait; /** - * Shorthand for a JSON request body with a schema ref or type. + * Shorthand for a JSON request body with a schema ref. * * @Annotation */ class JsonRequestBody extends OA\RequestBody { - use JsonContentTrait; + /** @var string|class-string */ + public string|object $source = Generator::UNDEFINED; + + public static $_blacklist = ['_context', '_unmerged', '_analysis', 'attachables', 'source']; public function __construct(array $properties) { - $ref = $properties['ref'] ?? Generator::UNDEFINED; - $type = $properties['type'] ?? Generator::UNDEFINED; - unset($properties['ref'], $properties['type']); - - $this->resolveSource($ref, Generator::isDefault($type) ? null : $type); + $this->source = $properties['ref'] ?? Generator::UNDEFINED; + unset($properties['ref']); parent::__construct($properties); } diff --git a/src/Annotations/JsonResponse.php b/src/Annotations/JsonResponse.php index 1bfcd7a..6b4c4f7 100644 --- a/src/Annotations/JsonResponse.php +++ b/src/Annotations/JsonResponse.php @@ -4,24 +4,26 @@ use OpenApi\Annotations as OA; use OpenApi\Generator; -use Radebatz\OpenApi\Extras\JsonContentTrait; /** - * Shorthand for a JSON response with a schema ref or type. + * Shorthand for a JSON response with a schema ref. * * @Annotation */ class JsonResponse extends OA\Response { - use JsonContentTrait; + /** @var string|class-string */ + public string|object $source = Generator::UNDEFINED; + + public string $wrap = 'data'; + + public static $_blacklist = ['_context', '_unmerged', '_analysis', 'attachables', 'source', 'wrap']; public function __construct(array $properties) { - $ref = $properties['ref'] ?? Generator::UNDEFINED; - $type = $properties['type'] ?? Generator::UNDEFINED; - unset($properties['ref'], $properties['type']); - - $this->resolveSource($ref, Generator::isDefault($type) ? null : $type); + $this->source = $properties['ref'] ?? Generator::UNDEFINED; + $this->wrap = $properties['wrap'] ?? 'data'; + unset($properties['ref'], $properties['wrap']); parent::__construct($properties); } diff --git a/src/Attributes/DataSchema.php b/src/Attributes/DataSchema.php deleted file mode 100644 index b7b402b..0000000 --- a/src/Attributes/DataSchema.php +++ /dev/null @@ -1,92 +0,0 @@ - $required - * @param list $properties - * @param array|null $x - * @param OA\Attachable[]|null $attachables - */ - public function __construct( - ?string $schema = null, - ?string $title = null, - ?string $description = Generator::UNDEFINED, - array $required = [], - array $properties = [], - ?array $x = null, - ?array $attachables = null, - ) { - $this->data_property = new OA\Property( - property: 'data', - required: $required ?: null, - properties: $properties ?: null, - type: 'object', - ); - - parent::__construct( - schema: $schema, - title: $title, - description: $description, - required: ['data'], - properties: [$this->data_property], - x: $x, - attachables: $attachables, - ); - } - - public function merge(array $annotations, bool $ignore = false): array - { - $forwarded = []; - $remaining = []; - - foreach ($annotations as $annotation) { - if ($annotation instanceof OA\Property) { - $forwarded[] = $annotation; - } else { - $remaining[] = $annotation; - } - } - - if ($forwarded !== []) { - if (Generator::isDefault($this->data_property->properties)) { - $this->data_property->properties = []; - } - - foreach ($forwarded as $property) { - $this->data_property->properties[] = $property; - - if ($property->nullable === false) { - $name = Generator::isDefault($property->property) - ? $property->_context->property ?? null - : ($property->property); - - if ($name) { - if (Generator::isDefault($this->data_property->required)) { - $this->data_property->required = []; - } - if (!in_array($name, $this->data_property->required, true)) { - $this->data_property->required[] = $name; - } - } - } - } - } - - return parent::merge($remaining, $ignore); - } -} diff --git a/src/Attributes/JsonRequestBody.php b/src/Attributes/JsonRequestBody.php index 6248fdb..923d9e4 100644 --- a/src/Attributes/JsonRequestBody.php +++ b/src/Attributes/JsonRequestBody.php @@ -4,29 +4,29 @@ use OpenApi\Attributes as OAT; use OpenApi\Generator; -use Radebatz\OpenApi\Extras\JsonContentTrait; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] class JsonRequestBody extends OAT\RequestBody { - use JsonContentTrait; + /** @var string|class-string */ + public string|object $source = Generator::UNDEFINED; + + public static $_blacklist = ['_context', '_unmerged', '_analysis', 'attachables', 'source']; /** * @param string|class-string $ref - * @param string|class-string|null $type * @param array|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, ) { - $this->resolveSource($ref, $type); + $this->source = $ref; parent::__construct( request: $request, diff --git a/src/Attributes/JsonResponse.php b/src/Attributes/JsonResponse.php index e1766d0..d7286ca 100644 --- a/src/Attributes/JsonResponse.php +++ b/src/Attributes/JsonResponse.php @@ -4,35 +4,45 @@ use OpenApi\Attributes as OAT; use OpenApi\Generator; -use Radebatz\OpenApi\Extras\JsonContentTrait; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class JsonResponse extends OAT\Response { - use JsonContentTrait; + /** @var string|class-string */ + public string|object $source = Generator::UNDEFINED; + + public string $wrap = 'data'; + + public static $_blacklist = ['_context', '_unmerged', '_analysis', 'attachables', 'source', 'wrap']; /** - * @param string|class-string $ref - * @param string|class-string|null $type - * @param OAT\Header[]|null $headers - * @param array|null $x - * @param OAT\Attachable[]|null $attachables + * @param string|class-string $ref + * @param OAT\MediaType[]|OAT\JsonContent|OAT\XmlContent|OAT\Attachable[]|null $content + * @param OAT\Header[]|null $headers + * @param OAT\Link[]|null $links + * @param array|null $x + * @param OAT\Attachable[]|null $attachables */ public function __construct( int|string $response = Generator::UNDEFINED, string|object $ref = Generator::UNDEFINED, - string|null $type = null, + string $wrap = 'data', ?string $description = null, + array|OAT\JsonContent|OAT\XmlContent|null $content = null, ?array $headers = null, + ?array $links = null, ?array $x = null, ?array $attachables = null, ) { - $this->resolveSource($ref, $type); + $this->source = $ref; + $this->wrap = $wrap; parent::__construct( response: $response !== Generator::UNDEFINED ? $response : null, description: $description ?? Generator::UNDEFINED, headers: $headers, + content: $content, + links: $links, x: $x, attachables: $attachables, ); diff --git a/src/JsonContentTrait.php b/src/JsonContentTrait.php deleted file mode 100644 index 3835ba5..0000000 --- a/src/JsonContentTrait.php +++ /dev/null @@ -1,22 +0,0 @@ -source = $ref; - } elseif ($type !== null) { - $this->source = $type; - } - } -} diff --git a/src/OpenApiBuilder.php b/src/OpenApiBuilder.php index d406ec0..df9dcd3 100644 --- a/src/OpenApiBuilder.php +++ b/src/OpenApiBuilder.php @@ -6,12 +6,14 @@ use OpenApi\Generator; use OpenApi\Processors\BuildPaths; use OpenApi\Processors\ExpandEnums; +use OpenApi\Processors\OperationId; 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; use Radebatz\OpenApi\Extras\Processors\MergeControllerDefaults; +use Radebatz\OpenApi\Extras\Processors\WrapJsonResponseContent; /** * A simple `builder` wrapper around the OpenApi `Generator` class. @@ -198,7 +200,8 @@ public function build(?LoggerInterface $logger = null): Generator $generator->getProcessorPipeline() ->insert(new MergeControllerDefaults(), BuildPaths::class) ->insert(new AugmentJsonResponse(), BuildPaths::class) - ->insert(new AugmentJsonRequestBody(), BuildPaths::class); + ->insert(new AugmentJsonRequestBody(), BuildPaths::class) + ->insert(new WrapJsonResponseContent(), OperationId::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 index c79bb56..b7e4551 100644 --- a/src/Processors/AugmentJsonRequestBody.php +++ b/src/Processors/AugmentJsonRequestBody.php @@ -56,7 +56,7 @@ protected function resolveSource(OAX\JsonRequestBody|OAXT\JsonRequestBody $reque protected function dispatch(OA\RequestBody $requestBody, string $source, Analysis $analysis): void { $sourceRequestBody = $analysis->getAnnotationForSource($source, OA\RequestBody::class); - if ($sourceRequestBody instanceof OA\AbstractAnnotation) { + if ($sourceRequestBody instanceof OA\RequestBody) { $requestBody->ref = OA\Components::ref($sourceRequestBody); return; diff --git a/src/Processors/AugmentJsonResponse.php b/src/Processors/AugmentJsonResponse.php index e947429..0fb14ec 100644 --- a/src/Processors/AugmentJsonResponse.php +++ b/src/Processors/AugmentJsonResponse.php @@ -19,21 +19,17 @@ public function __invoke(Analysis $analysis): void $responses = $analysis->getAnnotationsOfType([OAX\JsonResponse::class, OAXT\JsonResponse::class]); foreach ($responses as $response) { - if (Generator::isDefault($response->source)) { - continue; + if (!Generator::isDefault($response->source) && Generator::isDefault($response->content)) { + $this->createContent($response, $analysis); } - if (Generator::isDefault($response->content)) { - $this->createJsonContent($response, $analysis); - } - - if (Generator::isDefault($response->description)) { + if (Generator::isDefault($response->description) && !Generator::isDefault($response->source)) { $response->description = $this->resolveDescription($response->source, $analysis); } } } - protected function createJsonContent(OAX\JsonResponse|OAXT\JsonResponse $response, Analysis $analysis): void + protected function createContent(OAX\JsonResponse|OAXT\JsonResponse $response, Analysis $analysis): void { $context = new Context(['nested' => $response], $response->_context); $jsonContent = new OA\JsonContent(['ref' => $response->source, '_context' => $context]); diff --git a/src/Processors/WrapJsonResponseContent.php b/src/Processors/WrapJsonResponseContent.php new file mode 100644 index 0000000..ae8bec9 --- /dev/null +++ b/src/Processors/WrapJsonResponseContent.php @@ -0,0 +1,55 @@ +getAnnotationsOfType([OAX\JsonResponse::class, OAXT\JsonResponse::class]); + + foreach ($responses as $response) { + $this->wrapContent($response, $analysis); + } + } + + protected function wrapContent(OAX\JsonResponse|OAXT\JsonResponse $response, Analysis $analysis): void + { + if (Generator::isDefault($response->content) || !is_array($response->content)) { + return; + } + + $mediaType = $response->content['application/json'] ?? null; + if (!$mediaType instanceof OA\MediaType || !$mediaType->schema instanceof OA\Schema) { + return; + } + + $original = $mediaType->schema; + + $property = new OA\Property([ + 'property' => $response->wrap, + 'ref' => $original->ref, + '_context' => $original->_context, + ]); + + $mediaType->schema = new OA\Schema([ + 'required' => [$response->wrap], + 'properties' => [$property], + '_context' => $original->_context, + ]); + + if ($original instanceof OA\JsonContent) { + if (method_exists($analysis, 'removeAnnotation')) { + $analysis->removeAnnotation($original); + } else { + $analysis->annotations->offsetUnset($original); + } + } + } +} diff --git a/tests/DataSchemaTest.php b/tests/DataSchemaTest.php deleted file mode 100644 index 43532cc..0000000 --- a/tests/DataSchemaTest.php +++ /dev/null @@ -1,116 +0,0 @@ -in(__DIR__ . '/Fixtures/Models') - ->name('UserResource.php'); - - $openapi = (new Generator())->generate($finder, validate: false); - - $expected = <<<'YAML' -openapi: 3.0.0 -components: - schemas: - UserResource: - required: - - data - properties: - data: - required: - - id - - name - properties: - id: - type: integer - nullable: false - name: - type: string - nullable: false - email: - type: string - type: object - type: object -YAML; - - $this->assertSpecEquals($openapi, $expected); - } - - public function testDataSchemaAnnotation(): void - { - $namespace = 'Radebatz\\OpenApi\\Extras\\Annotations'; - $finder = (new Finder()) - ->in(__DIR__ . '/Fixtures/Models') - ->name('UserResourceAnnotation.php'); - - $openapi = (new Generator()) - ->addNamespace($namespace . '\\') - ->addAlias('oax', $namespace) - ->generate($finder, validate: false); - - $expected = <<<'YAML' -openapi: 3.0.0 -components: - schemas: - UserResourceAnnotation: - required: - - data - properties: - data: - required: - - id - - name - properties: - id: - type: integer - nullable: false - name: - type: string - nullable: false - email: - type: string - type: object - type: object -YAML; - - $this->assertSpecEquals($openapi, $expected); - } - - public function testDataSchemaNoRequired(): void - { - $finder = (new Finder()) - ->in(__DIR__ . '/Fixtures/Models') - ->name('SimpleResource.php'); - - $openapi = (new Generator())->generate($finder, validate: false); - - $expected = <<<'YAML' -openapi: 3.0.0 -components: - schemas: - SimpleResource: - required: - - data - properties: - data: - properties: - label: - type: string - type: object - type: object -YAML; - - $this->assertSpecEquals($openapi, $expected); - } -} diff --git a/tests/Fixtures/Models/SimpleResource.php b/tests/Fixtures/Models/SimpleResource.php index 6dc9eff..8a42b77 100644 --- a/tests/Fixtures/Models/SimpleResource.php +++ b/tests/Fixtures/Models/SimpleResource.php @@ -3,9 +3,8 @@ namespace Radebatz\OpenApi\Extras\Tests\Fixtures\Models; use OpenApi\Attributes as OAT; -use Radebatz\OpenApi\Extras\Attributes as OAX; -#[OAX\DataSchema(schema: 'SimpleResource')] +#[OAT\Schema(schema: 'SimpleResource')] class SimpleResource { #[OAT\Property(property: 'label', type: 'string')] diff --git a/tests/Fixtures/Models/UserResource.php b/tests/Fixtures/Models/UserResource.php index d9c6904..8249c7a 100644 --- a/tests/Fixtures/Models/UserResource.php +++ b/tests/Fixtures/Models/UserResource.php @@ -3,15 +3,14 @@ namespace Radebatz\OpenApi\Extras\Tests\Fixtures\Models; use OpenApi\Attributes as OAT; -use Radebatz\OpenApi\Extras\Attributes as OAX; -#[OAX\DataSchema(schema: 'UserResource', required: ['id', 'name'])] +#[OAT\Schema(schema: 'UserResource')] class UserResource { - #[OAT\Property(property: 'id', type: 'integer', nullable: false)] + #[OAT\Property(property: 'id', type: 'integer')] public int $id; - #[OAT\Property(property: 'name', type: 'string', nullable: false)] + #[OAT\Property(property: 'name', type: 'string')] public string $name; #[OAT\Property(property: 'email', type: 'string')] diff --git a/tests/Fixtures/Models/UserResourceAnnotation.php b/tests/Fixtures/Models/UserResourceAnnotation.php deleted file mode 100644 index 198308e..0000000 --- a/tests/Fixtures/Models/UserResourceAnnotation.php +++ /dev/null @@ -1,30 +0,0 @@ -assertEquals(TokenPairResource::class, $requestBody->source); - } - - 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); - $this->assertEquals(Generator::UNDEFINED, $requestBody->source); - } - - 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->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 + public function testCreatesContentFromRef(): void { $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); @@ -74,35 +22,44 @@ public function testProcessorResolvesDescriptionFromTitle(): void $analysis->addAnnotation($requestBody, new Context([])); (new AugmentJsonRequestBody())($analysis); + (new MergeJsonContent())($analysis); + $this->assertIsArray($requestBody->content); + $schema = $requestBody->content['application/json']->schema; + $this->assertEquals(TokenPairResource::class, $schema->ref); $this->assertEquals('Token pair', $requestBody->description); } - public function testProcessorResolvesDescriptionFromSchemaDescription(): void + public function testResolvesSourceFromParameterTypeHint(): void { - $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, Generator::UNDEFINED, 'A token pair request'); + $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); - $requestBody = new JsonRequestBody(ref: TokenPairResource::class); - $analysis->addAnnotation($requestBody, new Context([])); + $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); + (new MergeJsonContent())($analysis); - $this->assertEquals('A token pair request', $requestBody->description); + $this->assertEquals(TokenPairResource::class, $requestBody->source); + $schema = $requestBody->content['application/json']->schema; + $this->assertEquals(TokenPairResource::class, $schema->ref); } - public function testProcessorFallsBackToClassName(): void + public function testSetsComponentRefForRequestBodySource(): void { - $analysis = new Analysis([], new Context([])); + $analysis = $this->createAnalysisWithRequestBody('App\\Requests\\SharedCreateBody', 'SharedCreateBody'); - $requestBody = new JsonRequestBody(ref: 'App\\Models\\SomeUnknownClass'); + $requestBody = new JsonRequestBody(ref: 'App\\Requests\\SharedCreateBody'); $analysis->addAnnotation($requestBody, new Context([])); (new AugmentJsonRequestBody())($analysis); - $this->assertEquals('SomeUnknownClass', $requestBody->description); + $this->assertEquals('#/components/requestBodies/SharedCreateBody', $requestBody->ref); } - public function testProcessorSkipsExplicitDescription(): void + public function testExplicitDescriptionNotOverridden(): void { $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); @@ -114,34 +71,17 @@ public function testProcessorSkipsExplicitDescription(): void $this->assertEquals('My desc', $requestBody->description); } - public function testProcessorResolvesSourceFromParameterTypeHint(): void + public function testPlainRequestBodyUnaffected(): void { - $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); + $analysis = new Analysis([], new Context([])); - $requestBody = new JsonRequestBody(); - $rp = new \ReflectionParameter([Fixtures\Controllers\Attributes\ParameterController::class, 'create'], 'resource'); - $requestBody->_context = new Context(['reflector' => $rp]); + $requestBody = new OA\RequestBody(['description' => 'Plain body', '_context' => new Context([])]); $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); + $this->assertEquals('Plain body', $requestBody->description); + $this->assertEquals(Generator::UNDEFINED, $requestBody->content); } protected function createAnalysisWithSchema(string $class, string $title, string $description): Analysis diff --git a/tests/JsonResponseTest.php b/tests/JsonResponseTest.php index 0e891c7..2ae0aa7 100644 --- a/tests/JsonResponseTest.php +++ b/tests/JsonResponseTest.php @@ -6,108 +6,94 @@ use OpenApi\Annotations as OA; use OpenApi\Context; use OpenApi\Generator; +use OpenApi\Processors\MergeJsonContent; use PHPUnit\Framework\TestCase; -use Radebatz\OpenApi\Extras\Annotations\JsonResponse as JsonResponseAnnotation; use Radebatz\OpenApi\Extras\Attributes\JsonResponse; use Radebatz\OpenApi\Extras\Processors\AugmentJsonResponse; +use Radebatz\OpenApi\Extras\Processors\WrapJsonResponseContent; use Radebatz\OpenApi\Extras\Tests\Fixtures\Models\TokenPairResource; class JsonResponseTest extends TestCase { - public function testAttributeWithRef(): void + public static function wrapKeyProvider(): array { - $response = new JsonResponse(response: 200, ref: TokenPairResource::class); - - $this->assertEquals(200, $response->response); - $this->assertEquals(TokenPairResource::class, $response->source); - } - - public function testAttributeExplicitDescription(): void - { - $response = new JsonResponse(response: 200, ref: TokenPairResource::class, description: 'Custom desc'); - - $this->assertEquals('Custom desc', $response->description); - } - - 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 - { - $response = new JsonResponseAnnotation([ - 'response' => 200, - 'ref' => TokenPairResource::class, - ]); - - $this->assertEquals(200, $response->response); - $this->assertEquals(TokenPairResource::class, $response->source); + return [ + 'default wrap key' => ['data'], + 'custom wrap key' => ['result'], + ]; } - public function testProcessorCreatesJsonContent(): void + /** + * @dataProvider wrapKeyProvider + */ + public function testWrapsRefInEnvelope(string $wrap): void { $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); - $response = new JsonResponse(response: 200, ref: TokenPairResource::class); + $response = new JsonResponse(response: 200, ref: TokenPairResource::class, wrap: $wrap); $analysis->addAnnotation($response, new Context([])); - (new AugmentJsonResponse())($analysis); + $this->runPipeline($analysis); - $jsonContents = $analysis->getAnnotationsOfType(OA\JsonContent::class); - $this->assertCount(1, $jsonContents); - $this->assertEquals(TokenPairResource::class, $jsonContents[0]->ref); + $schema = $response->content['application/json']->schema; + $this->assertEquals([$wrap], $schema->required); + $this->assertCount(1, $schema->properties); + $this->assertEquals($wrap, $schema->properties[0]->property); } - public function testProcessorResolvesDescriptionFromTitle(): void + public static function descriptionProvider(): array { - $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); - - $this->assertEquals('Token pair', $response->description); + return [ + 'from schema title' => [TokenPairResource::class, 'Token pair', Generator::UNDEFINED, null, 'Token pair'], + 'from schema description' => [TokenPairResource::class, Generator::UNDEFINED, 'A response', null, 'A response'], + 'falls back to class name' => ['App\\Models\\SomeUnknown', Generator::UNDEFINED, Generator::UNDEFINED, null, 'SomeUnknown'], + 'explicit not overridden' => [TokenPairResource::class, 'Token pair', Generator::UNDEFINED, 'My desc', 'My desc'], + ]; } - public function testProcessorResolvesDescriptionFromSchemaDescription(): void + /** + * @dataProvider descriptionProvider + */ + public function testDescriptionResolution(string $ref, string $title, string $schemaDesc, ?string $explicit, string $expected): void { - $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, Generator::UNDEFINED, 'A token pair response'); + if ($ref === 'App\\Models\\SomeUnknown') { + $analysis = new Analysis([], new Context([])); + } else { + $analysis = $this->createAnalysisWithSchema($ref, $title, $schemaDesc); + } - $response = new JsonResponse(response: 200, ref: TokenPairResource::class); + $response = new JsonResponse(response: 200, ref: $ref, description: $explicit); $analysis->addAnnotation($response, new Context([])); (new AugmentJsonResponse())($analysis); - $this->assertEquals('A token pair response', $response->description); + $this->assertEquals($expected, $response->description); } - public function testProcessorFallsBackToClassName(): void + public function testPlainResponseUnaffected(): void { $analysis = new Analysis([], new Context([])); - $response = new JsonResponse(response: 200, ref: 'App\\Models\\SomeUnknownClass'); - $analysis->addAnnotation($response, new Context([])); + $context = new Context([]); + $response = new OA\Response(['response' => 200, 'description' => 'OK', '_context' => $context]); + $jsonContent = new OA\JsonContent(['ref' => TokenPairResource::class, '_context' => new Context(['nested' => $response], $context)]); + $analysis->addAnnotation($response, $context); + $analysis->addAnnotation($jsonContent, $jsonContent->_context); + + (new MergeJsonContent())($analysis); + $schemaBefore = $response->content['application/json']->schema; (new AugmentJsonResponse())($analysis); + (new WrapJsonResponseContent())($analysis); - $this->assertEquals('SomeUnknownClass', $response->description); + $this->assertSame($schemaBefore, $response->content['application/json']->schema); } - public function testProcessorSkipsExplicitDescription(): void + protected function runPipeline(Analysis $analysis): void { - $analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED); - - $response = new JsonResponse(response: 200, ref: TokenPairResource::class, description: 'My desc'); - $analysis->addAnnotation($response, new Context([])); - (new AugmentJsonResponse())($analysis); - - $this->assertEquals('My desc', $response->description); + (new MergeJsonContent())($analysis); + (new WrapJsonResponseContent())($analysis); } protected function createAnalysisWithSchema(string $class, string $title, string $description): Analysis