diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1184b36..c7a6899 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,14 +11,21 @@ permissions: jobs: tests: - name: Build + name: PHP ${{ matrix.php }} (${{ matrix.dependencies }} dependencies) runs-on: ubuntu-latest strategy: + fail-fast: false matrix: php: - - 8.2 - - 8.3 - - 8.4 + - '8.2' + - '8.3' + - '8.4' + - '8.5' + dependencies: + - highest + include: + - php: '8.2' + dependencies: lowest steps: - name: Set up PHP @@ -27,24 +34,18 @@ jobs: php-version: ${{ matrix.php }} extensions: bcmath, gmp, intl, dom, mbstring ini-values: zend.assertions=1 + coverage: none - name: Checkout code uses: actions/checkout@v4 - - name: Validate composer.json and composer.lock - run: composer validate --strict - - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v3 - with: - path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php- + - name: Validate composer.json + run: composer validate --strict --no-check-lock - name: Install dependencies - run: composer install --classmap-authoritative + uses: ramsey/composer-install@v3 + with: + dependency-versions: ${{ matrix.dependencies }} - name: Run tests run: composer test diff --git a/.gitignore b/.gitignore index aa2d78d..ebe897f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ vendor/ composer.lock +.phpunit.cache .phpunit.result.cache .php-cs-fixer.cache diff --git a/README.md b/README.md index df74882..85b1c90 100644 --- a/README.md +++ b/README.md @@ -42,26 +42,56 @@ class PostProductAction ## Main Features -- [x] Generate OpenAPI spec from PHP attributes (Mainly based on `zircote/swagger-php`) +- [x] Generate OpenAPI spec from PHP attributes (based on `zircote/swagger-php` v6) - Automatic `Operation`, `Schema` and `Property` guessing from PHP classes and methods - [x] Expose Swagger UI to explore the OpenAPI spec and test API endpoints - [x] Export OpenAPI spec in JSON or YAML format (via HTTP and console command) - [x] Import OpenAPI spec in JSON or YAML format (via config file) - [x] Define Symfony routes and OpenAPI Paths using the same attributes: - - `#[Post]`, `#[Get]`, `#[Put]`, `#[Patch]`, `#[Delete]` + - `#[Get]`, `#[Post]`, `#[Put]`, `#[Patch]`, `#[Delete]`, `#[Head]`, `#[Options]` + - One controller class can declare many of them - [x] Conditional OpenAPI Path/Route definition: - Example: `#[Get('/me', when: 'service("toggle_me").isEnabled()')]` +- [x] Native Symfony mapping attributes: + - `#[MapRequestPayload]` (including `type: Item::class` for arrays) becomes the request body + - `#[MapQueryString]` (including `key: 'filter'`) and `#[MapQueryParameter]` become query parameters - [x] Symfony attributes abbreviations: - `#[Payload]` instead of `#[MapRequestPayload]` - `#[Query]` instead of `#[MapQueryString]` - [x] OpenAPI attributes abbreviations: - `#[Path]` instead of `#[PathParameter]` - `#[Param]` instead of `#[QueryParameter]` -- [x] Symfony's validation constraints definition using OpenAPI attributes: +- [x] Query and path parameter inference from PHP reflection: + - `type`, `format`, `items`, `enum`, `default`, `required` and the array `style` + - `description` from the property or method docblock +- [x] Schema inference from PHP reflection: + - `required` from nullability, default values and the `NotNull` / `NotBlank` constraints + - `type` and `enum` from backed and pure PHP enums + - `default` from promoted constructor parameters +- [x] Symfony Validator constraints describe the spec: + - `Length`, `Count`, `Range`, comparisons, `Regex`, `Choice`, `All`, formats (`Uuid`, `Email`, `Url`, ...) and more +- [x] OpenAPI attributes define Symfony Validator constraints: - Example: `#[Property(minLength: 3, maxLength: 255)]` +- [x] Response inference from the controller return type: + - Item types of arrays from `@return list` (or the `itemsType` option) + - Declared `responses` are merged with the inferred success response + - `401` and `403` responses for operations with `security` + - Custom success status code with `statusCode: 202`, for the spec and the response - [x] Automatic controller response serialization (JSON format by default) - [ ] Generate new endpoints from API spec (WIP) (based on `open-solid/open-api-assistant-bundle`) +## Upgrade to swagger-php v6 + +- The bundle requires `zircote/swagger-php` ^6.12 and `symfony/type-info`. +- Docblock annotations (`@OA\...`) are not read anymore. Use PHP attributes. +- Custom processors implement `OpenSolid\OpenApiBundle\OpenApi\Processor\ProcessorInterface`. + They are tagged `openapi.processor` automatically. The `OpenApi\Processors\ProcessorInterface` + of swagger-php does not exist anymore. +- The `format` and `enum` options of `#[Path]` now go into the parameter `schema`. +- Object schemas now declare their `required` properties. +- Query parameters now declare their `schema`, with the `default` value instead of an `example`. +- An empty array result now returns `200` with `[]`, not `204`. + ## License This software is published under the [MIT License](LICENSE) diff --git a/composer.json b/composer.json index b920611..ca298df 100644 --- a/composer.json +++ b/composer.json @@ -18,14 +18,16 @@ "symfony/property-access": "^7.4|^8.0", "symfony/serializer": "^7.4|^8.0", "symfony/validator": "^7.4|^8.0", - "zircote/swagger-php": "^4.7", - "symfony/expression-language": "^7.4|^8.0" + "zircote/swagger-php": "^6.12", + "symfony/expression-language": "^7.4|^8.0", + "symfony/type-info": "^7.4|^8.0" }, "require-dev": { "roave/security-advisories": "dev-latest", - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^11.5|^12.5|^13.0", "symfony/browser-kit": "^7.4|^8.0", - "friendsofphp/php-cs-fixer": "^3.54" + "friendsofphp/php-cs-fixer": "^3.54", + "symfony/intl": "^7.4|^8.0" }, "autoload": { "psr-4": { @@ -38,8 +40,11 @@ } }, "scripts": { - "test": "vendor/bin/phpunit tests", + "test": "vendor/bin/phpunit", "psalm": "vendor/bin/psalm --no-cache", "cs": "vendor/bin/php-cs-fixer fix --allow-risky=yes" + }, + "suggest": { + "symfony/intl": "To validate the \"currency\" and \"locale\" formats" } } diff --git a/config/openapi/default.yaml b/config/openapi/default.yaml index 4ae1fe7..bce2e2a 100644 --- a/config/openapi/default.yaml +++ b/config/openapi/default.yaml @@ -15,6 +15,36 @@ components: type: string type: object readOnly: true + UnauthorizedErrorView: + properties: + type: + type: string + default: 'https://tools.ietf.org/html/rfc2616#section-10' + title: + type: string + default: 'Unauthorized' + status: + type: integer + default: 401 + detail: + type: string + type: object + readOnly: true + ForbiddenErrorView: + properties: + type: + type: string + default: 'https://tools.ietf.org/html/rfc2616#section-10' + title: + type: string + default: 'Forbidden' + status: + type: integer + default: 403 + detail: + type: string + type: object + readOnly: true NotFoundErrorView: properties: type: @@ -73,6 +103,18 @@ components: application/json: schema: $ref: '#/components/schemas/BadRequestErrorView' + '401': + description: 'Unauthorized' + content: + application/json: + schema: + $ref: '#/components/schemas/UnauthorizedErrorView' + '403': + description: 'Forbidden' + content: + application/json: + schema: + $ref: '#/components/schemas/ForbiddenErrorView' '404': description: 'Not found' content: diff --git a/config/services.php b/config/services.php index 98225f5..ad2fec0 100644 --- a/config/services.php +++ b/config/services.php @@ -20,7 +20,7 @@ ->set('openapi.generator', Generator::class) ->args([ service(AnalyserInterface::class), - tagged_iterator('openapi.processor', defaultPriorityMethod: 'priority'), + tagged_iterator('openapi.processor'), param('openapi_paths'), ]) diff --git a/config/services/openapi/analysers.php b/config/services/openapi/analysers.php index d617b0b..3efb8db 100644 --- a/config/services/openapi/analysers.php +++ b/config/services/openapi/analysers.php @@ -21,15 +21,9 @@ ->set(OABAnalyser\Guesser\Property\PropertyDefaultGuesser::class) ->tag('openapi.analyser_guesser') - ->set(OABAnalyser\Guesser\Property\PropertyEnumGuesser::class) - ->tag('openapi.analyser_guesser') - ->set(OABAnalyser\Guesser\Schema\SchemaDefaultGuesser::class) ->tag('openapi.analyser_guesser') - ->set(Analysers\DocBlockAnnotationFactory::class) - ->tag('openapi.annotation_factory') - ->set(OABAnalyser\Factory\AttributeFactory::class) ->args([ tagged_iterator('openapi.analyser_guesser'), diff --git a/config/services/openapi/processors.php b/config/services/openapi/processors.php index e645a5a..c844e8d 100644 --- a/config/services/openapi/processors.php +++ b/config/services/openapi/processors.php @@ -9,8 +9,7 @@ return static function (ContainerConfigurator $container): void { $container->services() - ->instanceof(Processors\ProcessorInterface::class) - ->tag('openapi.processor') + ->set(OAB\Constraint\ConstraintSchemaApplier::class) ->set(Processors\DocBlockDescriptions::class) ->tag('openapi.processor', ['priority' => 100]) @@ -18,6 +17,9 @@ ->set(Processors\MergeIntoOpenApi::class) ->tag('openapi.processor', ['priority' => 95]) + ->set(OAB\Processor\RemoveScannedQueryParameters::class) + ->tag('openapi.processor', ['priority' => 92]) + ->set(Processors\MergeIntoComponents::class) ->tag('openapi.processor', ['priority' => 90]) @@ -36,20 +38,47 @@ ->set(Processors\AugmentSchemas::class) ->tag('openapi.processor', ['priority' => 65]) + ->set(OAB\Processor\AugmentSchemas::class) + ->args([service(OAB\Constraint\ConstraintSchemaApplier::class)]) + ->tag('openapi.processor', ['priority' => 64]) + + ->set(OAB\Processor\AugmentSchemaConstraints::class) + ->args([service(OAB\Constraint\ConstraintSchemaApplier::class)]) + ->tag('openapi.processor', ['priority' => 63]) + + ->set(Processors\AugmentRequestBody::class) + ->tag('openapi.processor', ['priority' => 62]) + ->set(Processors\AugmentProperties::class) ->tag('openapi.processor', ['priority' => 60]) + ->set(Processors\AugmentDiscriminators::class) + ->tag('openapi.processor', ['priority' => 55]) + ->set(Processors\BuildPaths::class) ->tag('openapi.processor', ['priority' => 50]) ->set(Processors\AugmentParameters::class) ->tag('openapi.processor', ['priority' => 45]) + ->set(OAB\Processor\AugmentPathParameters::class) + ->tag('openapi.processor', ['priority' => 44]) + + ->set(OAB\Processor\AugmentQueryParameters::class) + ->tag('openapi.processor', ['priority' => 43]) + + ->set(OAB\Processor\AugmentParameterConstraints::class) + ->args([service(OAB\Constraint\ConstraintSchemaApplier::class)]) + ->tag('openapi.processor', ['priority' => 42]) + + ->set(OAB\Processor\AugmentEnumSchemas::class) + ->tag('openapi.processor', ['priority' => 41]) + ->set(Processors\AugmentRefs::class) ->tag('openapi.processor', ['priority' => 40]) - ->set(OAB\Processor\AugmentSchemas::class) - ->tag('openapi.processor', ['priority' => 35]) + ->set(Processors\AugmentItems::class) + ->tag('openapi.processor', ['priority' => 33]) ->set(Processors\MergeJsonContent::class) ->tag('openapi.processor', ['priority' => 30]) @@ -57,6 +86,9 @@ ->set(Processors\MergeXmlContent::class) ->tag('openapi.processor', ['priority' => 25]) + ->set(Processors\AugmentMediaType::class) + ->tag('openapi.processor', ['priority' => 23]) + ->set(Processors\OperationId::class) ->tag('openapi.processor', ['priority' => 20]) @@ -72,5 +104,8 @@ ->set(OAB\Processor\CleanupAnnotations::class) ->tag('openapi.processor', ['priority' => 5]) + + ->set(Processors\AugmentTags::class) + ->tag('openapi.processor', ['priority' => 0]) ; }; diff --git a/config/services/routing.php b/config/services/routing.php index 1f07afe..fabff26 100644 --- a/config/services/routing.php +++ b/config/services/routing.php @@ -1,43 +1,15 @@ services() - ->set('routing.loader.attribute.post') - ->class(OpenApiRouteControllerLoader::class) - ->args(['%kernel.environment%']) - ->call('setRouteAttributeClass', [Post::class]) - ->tag('routing.loader', ['priority' => -5]) - - ->set('routing.loader.attribute.get') - ->class(OpenApiRouteControllerLoader::class) - ->args(['%kernel.environment%']) - ->call('setRouteAttributeClass', [Get::class]) - ->tag('routing.loader', ['priority' => -5]) - - ->set('routing.loader.attribute.put') - ->class(OpenApiRouteControllerLoader::class) - ->args(['%kernel.environment%']) - ->call('setRouteAttributeClass', [Put::class]) - ->tag('routing.loader', ['priority' => -5]) - - ->set('routing.loader.attribute.patch') - ->class(OpenApiRouteControllerLoader::class) - ->args(['%kernel.environment%']) - ->call('setRouteAttributeClass', [Patch::class]) - ->tag('routing.loader', ['priority' => -5]) - - ->set('routing.loader.attribute.delete') + ->set('routing.loader.attribute.openapi') ->class(OpenApiRouteControllerLoader::class) ->args(['%kernel.environment%']) - ->call('setRouteAttributeClass', [Delete::class]) + ->call('setRouteAttributeClass', [ApiRouteInterface::class]) ->tag('routing.loader', ['priority' => -5]) ; }; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..f929c48 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + + + + + + tests + + + + + + src + + + diff --git a/src/Attribute/Path.php b/src/Attribute/Path.php index a808c65..c809766 100644 --- a/src/Attribute/Path.php +++ b/src/Attribute/Path.php @@ -18,11 +18,21 @@ use OpenApi\Attributes\PathParameter; use OpenApi\Attributes\Schema; use OpenApi\Attributes\XmlContent; -use OpenApi\Generator; +use OpenApi\Undefined; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] class Path extends PathParameter { + /** + * "format" and "enum" describe the parameter value, so they are moved to the schema. + * + * @see \OpenSolid\OpenApiBundle\OpenApi\Processor\AugmentPathParameters + */ + public static $_blacklist = ['_context', '_unmerged', '_analysis', 'attachables', 'format', 'enum']; + + /** + * @param array|class-string $enum + */ /** * @param array|class-string $enum */ @@ -45,6 +55,7 @@ public function __construct( ?bool $allowReserved = null, ?array $spaceDelimited = null, ?array $pipeDelimited = null, + mixed $deepObject = null, ?array $x = null, ?array $attachables = null, // custom properties @@ -52,29 +63,30 @@ public function __construct( public array|string|null $enum = null, ) { $defaults = static::defaults(); - $this->format = $format ?? $defaults->format ?? Generator::UNDEFINED; - $this->enum = $enum ?? $defaults->enum ?? Generator::UNDEFINED; + $this->format = $format ?? $defaults->format ?? Undefined::UNDEFINED; + $this->enum = $enum ?? $defaults->enum ?? Undefined::UNDEFINED; parent::__construct( - $parameter ?? $defaults->parameter ?? null, - $name ?? $defaults->name ?? null, - $description ?? $defaults->description ?? null, - $in ?? $defaults->in ?? null, - $required ?? $defaults->isRequired ?? null, - $deprecated ?? $defaults->deprecated ?? null, - $allowEmptyValue ?? $defaults->allowEmptyValue ?? null, - $ref ?? $defaults->ref ?? null, - $schema ?? $defaults->schema ?? null, - $example ?? $defaults->example ?? Generator::UNDEFINED, - $examples ?? $defaults->examples ?? null, - $content ?? $defaults->content ?? null, - $style ?? $defaults->style ?? null, - $explode ?? $defaults->explode ?? null, - $allowReserved ?? $defaults->allowReserved ?? null, - $spaceDelimited ?? $defaults->spaceDelimited ?? null, - $pipeDelimited ?? $defaults->pipeDelimited ?? null, - $x ?? $defaults->x ?? null, - $attachables ?? $defaults->attachables ?? null, + parameter: $parameter ?? $defaults->parameter ?? null, + name: $name ?? $defaults->name ?? null, + description: $description ?? $defaults->description ?? Undefined::UNDEFINED, + in: $in ?? $defaults->in ?? null, + required: $required ?? $defaults->required ?? null, + deprecated: $deprecated ?? $defaults->deprecated ?? null, + allowEmptyValue: $allowEmptyValue ?? $defaults->allowEmptyValue ?? null, + ref: $ref ?? $defaults->ref ?? null, + schema: $schema ?? $defaults->schema ?? null, + example: $example ?? $defaults->example ?? Undefined::UNDEFINED, + examples: $examples ?? $defaults->examples ?? null, + content: $content ?? $defaults->content ?? null, + style: $style ?? $defaults->style ?? null, + explode: $explode ?? $defaults->explode ?? null, + allowReserved: $allowReserved ?? $defaults->allowReserved ?? null, + spaceDelimited: $spaceDelimited ?? $defaults->spaceDelimited ?? null, + pipeDelimited: $pipeDelimited ?? $defaults->pipeDelimited ?? null, + deepObject: $deepObject ?? $defaults->deepObject ?? null, + x: $x ?? $defaults->x ?? null, + attachables: $attachables ?? $defaults->attachables ?? null, ); } diff --git a/src/Attribute/PathDefaults.php b/src/Attribute/PathDefaults.php index 85763e5..725edb0 100644 --- a/src/Attribute/PathDefaults.php +++ b/src/Attribute/PathDefaults.php @@ -37,6 +37,7 @@ class PathDefaults public bool $allowReserved; public array $spaceDelimited; public array $pipeDelimited; + public mixed $deepObject; public array $x; public array $attachables; public string $format; @@ -166,6 +167,13 @@ public function pipeDelimited(array $pipeDelimited): self return $this; } + public function deepObject(mixed $deepObject): self + { + $this->deepObject = $deepObject; + + return $this; + } + public function x(array $x): self { $this->x = $x; diff --git a/src/Attribute/Payload.php b/src/Attribute/Payload.php index 801585f..9e7d41c 100644 --- a/src/Attribute/Payload.php +++ b/src/Attribute/Payload.php @@ -28,7 +28,9 @@ public function __construct( string $resolver = RequestPayloadValueResolver::class, int $validationFailedStatusCode = Response::HTTP_UNPROCESSABLE_ENTITY, public ?string $itemsType = null, + ?string $type = null, + bool $mapWhenEmpty = false, ) { - parent::__construct($acceptFormat, $serializationContext, $validationGroups, $resolver, $validationFailedStatusCode); + parent::__construct($acceptFormat, $serializationContext, $validationGroups, $resolver, $validationFailedStatusCode, $type, $mapWhenEmpty); } } diff --git a/src/Attribute/Property.php b/src/Attribute/Property.php index d3fffb9..db8d91b 100644 --- a/src/Attribute/Property.php +++ b/src/Attribute/Property.php @@ -16,11 +16,12 @@ use OpenApi\Attributes\AdditionalProperties; use OpenApi\Attributes\Attachable; use OpenApi\Attributes\Discriminator; +use OpenApi\Attributes\Encoding; use OpenApi\Attributes\ExternalDocumentation; use OpenApi\Attributes\Items; use OpenApi\Attributes\Schema; use OpenApi\Attributes\Xml; -use OpenApi\Generator; +use OpenApi\Undefined; #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER | \Attribute::TARGET_CLASS_CONSTANT | \Attribute::IS_REPEATABLE)] class Property extends \OpenApi\Attributes\Property @@ -35,10 +36,11 @@ class Property extends \OpenApi\Attributes\Property * @param array $oneOf * @param array|null $x * @param Attachable[]|null $attachables + * @param string[]|null $groups */ public function __construct( ?string $property = null, - // schema + ?Encoding $encoding = null, string|object|null $ref = null, ?string $schema = null, ?string $title = null, @@ -47,37 +49,58 @@ public function __construct( ?int $minProperties = null, ?array $required = null, ?array $properties = null, - ?string $type = null, + string|array|null $type = null, ?string $format = null, ?Items $items = null, ?string $collectionFormat = null, - mixed $default = null, - float|int|null $maximum = null, - ?bool $exclusiveMaximum = null, - float|int|null $minimum = null, - ?bool $exclusiveMinimum = null, - ?int $maxLength = null, - ?int $minLength = null, - ?int $maxItems = null, - ?int $minItems = null, - ?bool $uniqueItems = null, ?string $pattern = null, - array|string|null $enum = null, ?Discriminator $discriminator = null, ?bool $readOnly = null, ?bool $writeOnly = null, ?Xml $xml = null, ?ExternalDocumentation $externalDocs = null, mixed $example = null, + ?array $examples = null, ?bool $nullable = null, ?bool $deprecated = null, ?array $allOf = null, ?array $anyOf = null, ?array $oneOf = null, + ?string $contentEncoding = null, + ?string $contentMediaType = null, + mixed $default = null, + int|float|null $maximum = null, + bool|int|float|null $exclusiveMaximum = null, + int|float|null $minimum = null, + bool|int|float|null $exclusiveMinimum = null, + ?int $maxLength = null, + ?int $minLength = null, + ?int $maxItems = null, + ?int $minItems = null, + ?bool $uniqueItems = null, + array|string|null $enum = null, + mixed $not = null, AdditionalProperties|bool|null $additionalProperties = null, - // annotation + ?array $additionalItems = null, + ?array $contains = null, + ?int $minContains = null, + ?int $maxContains = null, + ?array $prefixItems = null, + ?array $patternProperties = null, + ?array $unevaluatedProperties = null, + mixed $unevaluatedItems = null, + mixed $dependencies = null, + ?array $dependentRequired = null, + ?array $dependentSchemas = null, + mixed $propertyNames = null, + mixed $const = null, + mixed $if = null, + mixed $then = null, + mixed $else = null, + mixed $contentSchema = null, ?array $x = null, ?array $attachables = null, + int|float|null $multipleOf = null, // custom properties public ?array $groups = null, ) { @@ -89,46 +112,72 @@ public function __construct( $this->groups = $groups ?? $defaults->groups ?? null; parent::__construct( - $property ?? $defaults->property ?? null, - $ref ?? $defaults->ref ?? null, - $schema ?? $defaults->schema ?? null, - $title ?? $defaults->title ?? null, - $description ?? $defaults->description ?? null, - $maxProperties ?? $defaults->maxProperties ?? null, - $minProperties ?? $defaults->minProperties ?? null, - $required ?? $defaults->required ?? null, - $properties ?? $defaults->properties ?? null, - $type ?? $defaults->type ?? null, - $format ?? $defaults->format ?? null, - $items ?? $defaults->items ?? null, - $collectionFormat ?? $defaults->collectionFormat ?? null, - $default ?? $defaults->default ?? Generator::UNDEFINED, - $maximum ?? $defaults->maximum ?? null, - $exclusiveMaximum ?? $defaults->exclusiveMaximum ?? null, - $minimum ?? $defaults->minimum ?? null, - $exclusiveMinimum ?? $defaults->exclusiveMinimum ?? null, - $maxLength ?? $defaults->maxLength ?? null, - $minLength ?? $defaults->minLength ?? null, - $maxItems ?? $defaults->maxItems ?? null, - $minItems ?? $defaults->minItems ?? null, - $uniqueItems ?? $defaults->uniqueItems ?? null, - $pattern ?? $defaults->pattern ?? null, - $enum ?? $defaults->enum ?? null, - $discriminator ?? $defaults->discriminator ?? null, - $readOnly ?? $defaults->readOnly ?? null, - $writeOnly ?? $defaults->writeOnly ?? null, - $xml ?? $defaults->xml ?? null, - $externalDocs ?? $defaults->externalDocs ?? null, - $example ?? $defaults->example ?? Generator::UNDEFINED, - $nullable ?? $defaults->nullable ?? null, - $deprecated ?? $defaults->deprecated ?? null, - $allOf ?? $defaults->allOf ?? null, - $anyOf ?? $defaults->anyOf ?? null, - $oneOf ?? $defaults->oneOf ?? null, - $additionalProperties ?? $defaults->additionalProperties ?? null, - $x ?? $defaults->x ?? null, - $attachables ?? $defaults->attachables ?? null, + property: $property ?? $defaults->property ?? null, + encoding: $encoding ?? $defaults->encoding ?? null, + ref: $ref ?? $defaults->ref ?? null, + schema: $schema ?? $defaults->schema ?? null, + title: $title ?? $defaults->title ?? null, + description: $description ?? $defaults->description ?? Undefined::UNDEFINED, + maxProperties: $maxProperties ?? $defaults->maxProperties ?? null, + minProperties: $minProperties ?? $defaults->minProperties ?? null, + required: $required ?? $defaults->required ?? null, + properties: $properties ?? $defaults->properties ?? null, + type: $type ?? $defaults->type ?? null, + format: $format ?? $defaults->format ?? null, + items: $items ?? $defaults->items ?? null, + collectionFormat: $collectionFormat ?? $defaults->collectionFormat ?? null, + pattern: $pattern ?? $defaults->pattern ?? null, + discriminator: $discriminator ?? $defaults->discriminator ?? null, + readOnly: $readOnly ?? $defaults->readOnly ?? null, + writeOnly: $writeOnly ?? $defaults->writeOnly ?? null, + xml: $xml ?? $defaults->xml ?? null, + externalDocs: $externalDocs ?? $defaults->externalDocs ?? null, + example: $example ?? $defaults->example ?? Undefined::UNDEFINED, + examples: $examples ?? $defaults->examples ?? null, + nullable: $nullable ?? $defaults->nullable ?? null, + deprecated: $deprecated ?? $defaults->deprecated ?? null, + allOf: $allOf ?? $defaults->allOf ?? null, + anyOf: $anyOf ?? $defaults->anyOf ?? null, + oneOf: $oneOf ?? $defaults->oneOf ?? null, + contentEncoding: $contentEncoding ?? $defaults->contentEncoding ?? null, + contentMediaType: $contentMediaType ?? $defaults->contentMediaType ?? null, + default: $default ?? $defaults->default ?? Undefined::UNDEFINED, + maximum: $maximum ?? $defaults->maximum ?? null, + exclusiveMaximum: $exclusiveMaximum ?? $defaults->exclusiveMaximum ?? null, + minimum: $minimum ?? $defaults->minimum ?? null, + exclusiveMinimum: $exclusiveMinimum ?? $defaults->exclusiveMinimum ?? null, + maxLength: $maxLength ?? $defaults->maxLength ?? null, + minLength: $minLength ?? $defaults->minLength ?? null, + maxItems: $maxItems ?? $defaults->maxItems ?? null, + minItems: $minItems ?? $defaults->minItems ?? null, + uniqueItems: $uniqueItems ?? $defaults->uniqueItems ?? null, + enum: $enum ?? $defaults->enum ?? null, + not: $not ?? $defaults->not ?? Undefined::UNDEFINED, + additionalProperties: $additionalProperties ?? $defaults->additionalProperties ?? null, + additionalItems: $additionalItems ?? $defaults->additionalItems ?? null, + contains: $contains ?? $defaults->contains ?? null, + minContains: $minContains ?? $defaults->minContains ?? null, + maxContains: $maxContains ?? $defaults->maxContains ?? null, + prefixItems: $prefixItems ?? $defaults->prefixItems ?? null, + patternProperties: $patternProperties ?? $defaults->patternProperties ?? null, + unevaluatedProperties: $unevaluatedProperties ?? $defaults->unevaluatedProperties ?? null, + unevaluatedItems: $unevaluatedItems ?? $defaults->unevaluatedItems ?? Undefined::UNDEFINED, + dependencies: $dependencies ?? $defaults->dependencies ?? Undefined::UNDEFINED, + dependentRequired: $dependentRequired ?? $defaults->dependentRequired ?? null, + dependentSchemas: $dependentSchemas ?? $defaults->dependentSchemas ?? null, + propertyNames: $propertyNames ?? $defaults->propertyNames ?? Undefined::UNDEFINED, + const: $const ?? $defaults->const ?? Undefined::UNDEFINED, + if: $if ?? $defaults->if ?? Undefined::UNDEFINED, + then: $then ?? $defaults->then ?? Undefined::UNDEFINED, + else: $else ?? $defaults->else ?? Undefined::UNDEFINED, + contentSchema: $contentSchema ?? $defaults->contentSchema ?? Undefined::UNDEFINED, + x: $x ?? $defaults->x ?? null, + attachables: $attachables ?? $defaults->attachables ?? null, ); + + if (null !== $multipleOf ??= $defaults->multipleOf ?? null) { + $this->multipleOf = $multipleOf; + } } public static function defaults(): PropertyDefaults diff --git a/src/Attribute/PropertyDefaults.php b/src/Attribute/PropertyDefaults.php index 7ce309b..1c8f69d 100644 --- a/src/Attribute/PropertyDefaults.php +++ b/src/Attribute/PropertyDefaults.php @@ -15,6 +15,7 @@ use OpenApi\Attributes\AdditionalProperties; use OpenApi\Attributes\Discriminator; +use OpenApi\Attributes\Encoding; use OpenApi\Attributes\ExternalDocumentation; use OpenApi\Attributes\Items; use OpenApi\Attributes\Xml; @@ -22,6 +23,7 @@ class PropertyDefaults { public string $property; + public Encoding $encoding; public string|object $ref; public string $schema; public string $title; @@ -30,36 +32,58 @@ class PropertyDefaults public int $minProperties; public array $required; public array $properties; - public string $type; + public string|array $type; public string $format; public Items $items; public string $collectionFormat; - public mixed $default; - public int|float $maximum; - public bool $exclusiveMaximum; - public int|float $minimum; - public bool $exclusiveMinimum; - public int $maxLength; - public int $minLength; - public int $maxItems; - public int $minItems; - public bool $uniqueItems; public string $pattern; - public array|string $enum; public Discriminator $discriminator; public bool $readOnly; public bool $writeOnly; public Xml $xml; public ExternalDocumentation $externalDocs; public mixed $example; + public array $examples; public bool $nullable; public bool $deprecated; public array $allOf; public array $anyOf; public array $oneOf; + public string $contentEncoding; + public string $contentMediaType; + public mixed $default; + public int|float $maximum; + public bool|int|float $exclusiveMaximum; + public int|float $minimum; + public bool|int|float $exclusiveMinimum; + public int $maxLength; + public int $minLength; + public int $maxItems; + public int $minItems; + public bool $uniqueItems; + public array|string $enum; + public mixed $not; public AdditionalProperties|bool $additionalProperties; + public array $additionalItems; + public array $contains; + public int $minContains; + public int $maxContains; + public array $prefixItems; + public array $patternProperties; + public array $unevaluatedProperties; + public mixed $unevaluatedItems; + public mixed $dependencies; + public array $dependentRequired; + public array $dependentSchemas; + public mixed $propertyNames; + public mixed $const; + public mixed $if; + public mixed $then; + public mixed $else; + public mixed $contentSchema; public array $x; public array $attachables; + public int|float $multipleOf; public array $groups; public static function create(): self @@ -74,6 +98,13 @@ public function property(string $property): self return $this; } + public function encoding(Encoding $encoding): self + { + $this->encoding = $encoding; + + return $this; + } + public function ref(string|object $ref): self { $this->ref = $ref; @@ -130,7 +161,7 @@ public function properties(array $properties): self return $this; } - public function type(string $type): self + public function type(string|array $type): self { $this->type = $type; @@ -158,6 +189,111 @@ public function collectionFormat(string $collectionFormat): self return $this; } + public function pattern(string $pattern): self + { + $this->pattern = $pattern; + + return $this; + } + + public function discriminator(Discriminator $discriminator): self + { + $this->discriminator = $discriminator; + + return $this; + } + + public function readOnly(bool $value = true): self + { + $this->readOnly = $value; + + return $this; + } + + public function writeOnly(bool $value = true): self + { + $this->writeOnly = $value; + + return $this; + } + + public function xml(Xml $xml): self + { + $this->xml = $xml; + + return $this; + } + + public function externalDocs(ExternalDocumentation $externalDocs): self + { + $this->externalDocs = $externalDocs; + + return $this; + } + + public function example(mixed $example): self + { + $this->example = $example; + + return $this; + } + + public function examples(array $examples): self + { + $this->examples = $examples; + + return $this; + } + + public function nullable(bool $value = true): self + { + $this->nullable = $value; + + return $this; + } + + public function deprecated(bool $value = true): self + { + $this->deprecated = $value; + + return $this; + } + + public function allOf(array $allOf): self + { + $this->allOf = $allOf; + + return $this; + } + + public function anyOf(array $anyOf): self + { + $this->anyOf = $anyOf; + + return $this; + } + + public function oneOf(array $oneOf): self + { + $this->oneOf = $oneOf; + + return $this; + } + + public function contentEncoding(string $contentEncoding): self + { + $this->contentEncoding = $contentEncoding; + + return $this; + } + + public function contentMediaType(string $contentMediaType): self + { + $this->contentMediaType = $contentMediaType; + + return $this; + } + public function default(mixed $default): self { $this->default = $default; @@ -172,7 +308,7 @@ public function maximum(int|float $maximum): self return $this; } - public function exclusiveMaximum(bool $value = true): self + public function exclusiveMaximum(bool|int|float $value = true): self { $this->exclusiveMaximum = $value; @@ -186,7 +322,7 @@ public function minimum(int|float $minimum): self return $this; } - public function exclusiveMinimum(bool $value = true): self + public function exclusiveMinimum(bool|int|float $value = true): self { $this->exclusiveMinimum = $value; @@ -228,100 +364,142 @@ public function uniqueItems(bool $value = true): self return $this; } - public function pattern(string $pattern): self + public function enum(array|string $enum): self { - $this->pattern = $pattern; + $this->enum = $enum; return $this; } - public function enum(array|string $enum): self + public function not(mixed $not): self { - $this->enum = $enum; + $this->not = $not; return $this; } - public function discriminator(Discriminator $discriminator): self + public function additionalProperties(AdditionalProperties|bool $additionalProperties): self { - $this->discriminator = $discriminator; + $this->additionalProperties = $additionalProperties; return $this; } - public function readOnly(bool $value = true): self + public function additionalItems(array $additionalItems): self { - $this->readOnly = $value; + $this->additionalItems = $additionalItems; return $this; } - public function writeOnly(bool $value = true): self + public function contains(array $contains): self { - $this->writeOnly = $value; + $this->contains = $contains; return $this; } - public function xml(Xml $xml): self + public function minContains(int $minContains): self { - $this->xml = $xml; + $this->minContains = $minContains; return $this; } - public function externalDocs(ExternalDocumentation $externalDocs): self + public function maxContains(int $maxContains): self { - $this->externalDocs = $externalDocs; + $this->maxContains = $maxContains; return $this; } - public function example(mixed $example): self + public function prefixItems(array $prefixItems): self { - $this->example = $example; + $this->prefixItems = $prefixItems; return $this; } - public function nullable(bool $value = true): self + public function patternProperties(array $patternProperties): self { - $this->nullable = $value; + $this->patternProperties = $patternProperties; return $this; } - public function deprecated(bool $value = true): self + public function unevaluatedProperties(array $unevaluatedProperties): self { - $this->deprecated = $value; + $this->unevaluatedProperties = $unevaluatedProperties; return $this; } - public function allOf(array $allOf): self + public function unevaluatedItems(mixed $unevaluatedItems): self { - $this->allOf = $allOf; + $this->unevaluatedItems = $unevaluatedItems; return $this; } - public function anyOf(array $anyOf): self + public function dependencies(mixed $dependencies): self { - $this->anyOf = $anyOf; + $this->dependencies = $dependencies; return $this; } - public function oneOf(array $oneOf): self + public function dependentRequired(array $dependentRequired): self { - $this->oneOf = $oneOf; + $this->dependentRequired = $dependentRequired; return $this; } - public function additionalProperties(AdditionalProperties|bool $additionalProperties): self + public function dependentSchemas(array $dependentSchemas): self { - $this->additionalProperties = $additionalProperties; + $this->dependentSchemas = $dependentSchemas; + + return $this; + } + + public function propertyNames(mixed $propertyNames): self + { + $this->propertyNames = $propertyNames; + + return $this; + } + + public function const(mixed $const): self + { + $this->const = $const; + + return $this; + } + + public function if(mixed $if): self + { + $this->if = $if; + + return $this; + } + + public function then(mixed $then): self + { + $this->then = $then; + + return $this; + } + + public function else(mixed $else): self + { + $this->else = $else; + + return $this; + } + + public function contentSchema(mixed $contentSchema): self + { + $this->contentSchema = $contentSchema; return $this; } @@ -340,6 +518,13 @@ public function attachables(array $attachables): self return $this; } + public function multipleOf(int|float $multipleOf): self + { + $this->multipleOf = $multipleOf; + + return $this; + } + public function groups(array $groups): self { $this->groups = $groups; diff --git a/src/Controller/OpenApiController.php b/src/Controller/OpenApiController.php index d5fca86..85a034f 100644 --- a/src/Controller/OpenApiController.php +++ b/src/Controller/OpenApiController.php @@ -13,6 +13,7 @@ namespace OpenSolid\OpenApiBundle\Controller; +use OpenApi\Annotations as OA; use OpenSolid\OpenApiBundle\Generator; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -37,14 +38,7 @@ public function index(UrlGeneratorInterface $urlGenerator): Response throw new NotFoundHttpException('OpenAPI spec not found.'); } - $validationErrors = ''; - try { - if (!$openapi->validate()) { - throw new \ErrorException('OpenAPI spec is invalid.'); - } - } catch (\ErrorException $e) { - $validationErrors = $e->getMessage(); - } + $validationErrors = implode("\n", $this->validate($openapi)); $content = $this->render($this->template, [ 'url' => $urlGenerator->generate('openapi_json', [], UrlGeneratorInterface::RELATIVE_PATH), @@ -91,6 +85,29 @@ public function jsonSchema(Request $request, string $name): JsonResponse return new JsonResponse($data); } + /** + * @return list + */ + private function validate(OA\OpenApi $openapi): array + { + $errors = []; + set_error_handler(static function (int $level, string $message) use (&$errors): bool { + $errors[] = $message; + + return true; + }, \E_USER_WARNING | \E_USER_NOTICE); + + try { + if (!$openapi->validate(null, $openapi->openapi) && [] === $errors) { + $errors[] = 'OpenAPI spec is invalid.'; + } + } finally { + restore_error_handler(); + } + + return $errors; + } + private function render(string $name, array $context = []): string { extract($context, \EXTR_SKIP); diff --git a/src/Generator.php b/src/Generator.php index b869a00..29af485 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -16,27 +16,28 @@ use OpenApi\Analysers\AnalyserInterface; use OpenApi\Annotations as OA; use OpenApi\Generator as OpenApiGenerator; -use OpenApi\Processors; +use OpenApi\Utils\Pipeline; +use Psr\Log\LoggerInterface; readonly class Generator { /** - * @param array $processors - * @param string[] $paths + * @param iterable $processors + * @param string[] $paths */ public function __construct( private AnalyserInterface $analyser, private iterable $processors, private array $paths, + private ?LoggerInterface $logger = null, ) { } public function generate(): ?OA\OpenApi { - return OpenApiGenerator::scan($this->paths, [ - 'analyser' => $this->analyser, - 'processors' => iterator_to_array($this->processors), - 'validate' => false, - ]); + return (new OpenApiGenerator($this->logger)) + ->setAnalyser($this->analyser) + ->setProcessorPipeline(new Pipeline(iterator_to_array($this->processors, false))) + ->generate($this->paths, validate: false); } } diff --git a/src/HttpKernel/Controller/ArgumentResolver/RequestPayloadArrayResolver.php b/src/HttpKernel/Controller/ArgumentResolver/RequestPayloadArrayResolver.php index 0560e96..eafe5a1 100644 --- a/src/HttpKernel/Controller/ArgumentResolver/RequestPayloadArrayResolver.php +++ b/src/HttpKernel/Controller/ArgumentResolver/RequestPayloadArrayResolver.php @@ -25,6 +25,7 @@ use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\Serializer\Exception\NotEncodableValueException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Exception\UnsupportedFormatException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -81,10 +82,13 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable return [$attribute]; } - if ($attribute instanceof Payload && null !== $attribute->itemsType) { + $itemsType = ($attribute instanceof Payload ? $attribute->itemsType : null) + ?? ($attribute instanceof MapRequestPayload ? $attribute->type : null); + + if (null !== $itemsType) { $attribute->metadata = new ArgumentMetadata( $argument->getName(), - $attribute->itemsType.'[]', + $itemsType.'[]', $argument->isVariadic(), $argument->hasDefaultValue(), $argument->hasDefaultValue() ? $argument->getDefaultValue() : null, @@ -122,8 +126,8 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo $payload = $this->$payloadMapper($request, $type, $argument); } catch (PartialDenormalizationException $e) { $trans = $this->translator ? $this->translator->trans(...) : fn ($m, $p) => strtr($m, $p); - foreach ($e->getErrors() as $error) { - $parameters = ['{{ type }}' => implode('|', $error->getExpectedTypes())]; + foreach ($this->denormalizationErrors($e) as $error) { + $parameters = ['{{ type }}' => implode('|', $error->getExpectedTypes() ?? ['unknown'])]; if ($error->canUseMessageForUser()) { $parameters['hint'] = $error->getMessage(); } @@ -145,7 +149,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo try { $payload = $this->$payloadMapper($request, $type, $argument); } catch (PartialDenormalizationException $e) { - throw new HttpException($validationFailedCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), $e->getErrors())), $e); + throw new HttpException($validationFailedCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), $this->denormalizationErrors($e))), $e); } } @@ -153,7 +157,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo $payload = match (true) { $argument->metadata->hasDefaultValue() => $argument->metadata->getDefaultValue(), $argument->metadata->isNullable() => null, - default => throw new HttpException($validationFailedCode) + default => throw new HttpException($validationFailedCode), }; } @@ -170,10 +174,30 @@ public static function getSubscribedEvents(): array return RequestPayloadValueResolver::getSubscribedEvents(); } + /** + * @return array + */ + private function denormalizationErrors(PartialDenormalizationException $e): array + { + // Symfony 8.1+ renamed getErrors() to getNotNormalizableValueErrors() + return method_exists($e, 'getNotNormalizableValueErrors') ? $e->getNotNormalizableValueErrors() : $e->getErrors(); + } + private function mapQueryString(Request $request, string $type, MapQueryString $attribute): ?object { - if (!$data = $request->query->all()) { - return null; + $data = $request->query->all(); + + if (null !== $attribute->key) { + $data = $data[$attribute->key] ?? []; + } + + if (!$data || !\is_array($data)) { + // Symfony 8.1+ exposes "mapWhenEmpty" + if (!($attribute->mapWhenEmpty ?? false)) { + return null; + } + + $data = []; } return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE); @@ -194,7 +218,10 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay } if ('' === $data = $request->getContent()) { - return null; + // Symfony 8.1+ exposes "mapWhenEmpty" + return ($attribute->mapWhenEmpty ?? false) + ? $this->serializer->denormalize([], $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE) + : null; } if ('form' === $format) { diff --git a/src/HttpKernel/Controller/ControllerResultSubscriber.php b/src/HttpKernel/Controller/ControllerResultSubscriber.php index e595704..33d6a19 100644 --- a/src/HttpKernel/Controller/ControllerResultSubscriber.php +++ b/src/HttpKernel/Controller/ControllerResultSubscriber.php @@ -15,7 +15,7 @@ use OpenApi\Annotations\Operation; use OpenApi\Attributes as OA; -use OpenApi\Generator; +use OpenApi\Undefined; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ViewEvent; @@ -37,25 +37,36 @@ public function onKernelView(ViewEvent $event): void $result = $event->getControllerResult(); - if (!$result) { - $event->setResponse(new Response(status: 204)); + if ($result instanceof Response) { + return; + } + + // Symfony 8.1+ exposes "controllerMetadata", older versions the deprecated "controllerArgumentsEvent" + $controllerMetadata = property_exists($event, 'controllerMetadata') ? $event->controllerMetadata : $event->controllerArgumentsEvent; + $controllerAttributes = $controllerMetadata?->getAttributes() ?? []; + + if (null === $result) { + $event->setResponse(new Response(status: $this->guessStatusCode($controllerAttributes, 204))); return; } - if ($result instanceof Response || null === $event->controllerArgumentsEvent) { + if (null === $controllerMetadata) { return; } $request = $event->getRequest(); $content = $this->serializer->serialize($result, $request->getPreferredFormat('json')); - $statusCode = $this->guessStatusCode($event->controllerArgumentsEvent->getAttributes()); + $statusCode = $this->guessStatusCode($controllerAttributes); $contentType = $request->getAcceptableContentTypes()[0] ?? $request->headers->get('CONTENT_TYPE', 'application/json'); $event->setResponse(new Response($content, $statusCode, ['Content-Type' => $contentType])); } - protected function guessStatusCode(array $controllerAttributes): int + /** + * @param array> $controllerAttributes + */ + protected function guessStatusCode(array $controllerAttributes, ?int $default = null): int { foreach ($controllerAttributes as $attributes) { foreach ($attributes as $attribute) { @@ -63,7 +74,15 @@ protected function guessStatusCode(array $controllerAttributes): int continue; } - if (!Generator::isDefault($attribute->responses)) { + if (property_exists($attribute, 'statusCode') && null !== $attribute->statusCode) { + return $attribute->statusCode; + } + + if (null !== $default) { + return $default; + } + + if (!Undefined::isDefault($attribute->responses)) { foreach ($attribute->responses as $res) { if (is_numeric($res->response) && $res->response >= 200 && $res->response < 300) { return (int) $res->response; @@ -77,7 +96,7 @@ protected function guessStatusCode(array $controllerAttributes): int } } - return 200; + return $default ?? 200; } public static function getSubscribedEvents(): array diff --git a/src/HttpKernel/Controller/ValueResolver/ConstraintGuesser/NativeConstraintGuesser.php b/src/HttpKernel/Controller/ValueResolver/ConstraintGuesser/NativeConstraintGuesser.php index a889768..2e96d21 100644 --- a/src/HttpKernel/Controller/ValueResolver/ConstraintGuesser/NativeConstraintGuesser.php +++ b/src/HttpKernel/Controller/ValueResolver/ConstraintGuesser/NativeConstraintGuesser.php @@ -24,7 +24,7 @@ public function guess(ArgumentMetadata $argument, Path $attribute): array $constraints = [match ($attribute->format) { 'uuid' => new Assert\Uuid(), 'date' => new Assert\Date(), - 'datetime' => new Assert\DateTime(), + 'date-time', 'datetime' => new Assert\DateTime(), 'locale' => new Assert\Locale(), 'currency' => new Assert\Currency(), 'numeric' => new Assert\Type(type: 'numeric'), diff --git a/src/OpenApi/Analyser/Guesser/CollectionItemsTypeGuesser.php b/src/OpenApi/Analyser/Guesser/CollectionItemsTypeGuesser.php new file mode 100644 index 0000000..0b4d7ee --- /dev/null +++ b/src/OpenApi/Analyser/Guesser/CollectionItemsTypeGuesser.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser; + +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\NullableType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; + +/** + * Guesses the items type of an array from its docblock, e.g. "@return list" + * or "@param ResourceView[] $payload". + */ +final class CollectionItemsTypeGuesser +{ + private static ?TypeResolverInterface $typeResolver = null; + + /** + * @return string|null a class name or an OpenAPI scalar type, null when the items type is unknown + */ + public static function guess(\ReflectionFunctionAbstract|\ReflectionParameter $reflector): ?string + { + try { + $type = (self::$typeResolver ??= TypeResolver::create())->resolve($reflector); + } catch (\Throwable) { + return null; + } + + if ($type instanceof NullableType) { + $type = $type->getWrappedType(); + } + + if (!$type instanceof CollectionType) { + return null; + } + + return self::toItemsType($type->getCollectionValueType()); + } + + private static function toItemsType(Type $type): ?string + { + if ($type instanceof ObjectType) { + return $type->getClassName(); + } + + if (!$type instanceof BuiltinType) { + return null; + } + + return match ($type->getTypeIdentifier()) { + TypeIdentifier::INT => 'integer', + TypeIdentifier::FLOAT => 'number', + TypeIdentifier::BOOL => 'boolean', + TypeIdentifier::STRING => 'string', + default => null, + }; + } +} diff --git a/src/OpenApi/Analyser/Guesser/Operation/OperationQueryParameterGuesser.php b/src/OpenApi/Analyser/Guesser/Operation/OperationQueryParameterGuesser.php index 9d2b2ec..cd25a33 100644 --- a/src/OpenApi/Analyser/Guesser/Operation/OperationQueryParameterGuesser.php +++ b/src/OpenApi/Analyser/Guesser/Operation/OperationQueryParameterGuesser.php @@ -15,12 +15,22 @@ use OpenApi\Annotations\AbstractAnnotation; use OpenApi\Annotations\Operation; +use OpenApi\Annotations\Parameter; +use OpenApi\Attributes\QueryParameter; use OpenApi\Context; -use OpenApi\Generator; -use OpenSolid\OpenApiBundle\Attribute\Param; -use OpenSolid\OpenApiBundle\Attribute\Query; +use OpenApi\Undefined; use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\AnalyserGuesserInterface; - +use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; +use Symfony\Component\HttpKernel\Attribute\MapQueryString; + +/** + * Guesses the operation query parameters from the method signature: + * - #[MapQueryString] (or #[Query]): every DTO property with a #[QueryParameter] (or #[Param]) + * attribute becomes a query parameter, nested DTOs use the "parent[child]" notation; + * - #[MapQueryParameter]: the argument itself becomes a query parameter. + * + * Type, format, default, description and required are inferred later by AugmentQueryParameters. + */ class OperationQueryParameterGuesser implements AnalyserGuesserInterface { public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Context $context): void @@ -29,69 +39,104 @@ public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Con return; } - if (!Generator::isDefault($annotation->parameters)) { - return; - } + $parameters = Undefined::isDefault($annotation->parameters) ? [] : $annotation->parameters; + $count = \count($parameters); - $parameters = []; foreach ($reflector->getParameters() as $rp) { - foreach ($rp->getAttributes(Query::class, \ReflectionAttribute::IS_INSTANCEOF) as $_) { - if ((null === $rnt = $rp->getType()) || !$rnt instanceof \ReflectionNamedType || $rnt->isBuiltin() || !class_exists($rnt->getName())) { - continue; + if ($attribute = $rp->getAttributes(MapQueryString::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) { + $rnt = $rp->getType(); + + if ($rnt instanceof \ReflectionNamedType && !$rnt->isBuiltin() && class_exists($rnt->getName())) { + $this->guessRecursive($rnt->getName(), $parameters, $annotation, $context, $attribute->newInstance()->key); } - $this->guessRecursive($rnt->getName(), $parameters, $annotation, $context); + continue; + } + + if ($attribute = $rp->getAttributes(MapQueryParameter::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) { + $this->guessQueryParameter($rp, $attribute->newInstance(), $parameters, $annotation, $context); } } - if ($parameters) { + if (\count($parameters) > $count) { $annotation->parameters = $parameters; } } + /** + * @param array $parameters + */ protected function guessRecursive(string $class, array &$parameters, Operation $operation, Context $context, ?string $parent = null): void { $reflectionClass = new \ReflectionClass($class); foreach ($reflectionClass->getProperties(\ReflectionProperty::IS_PUBLIC) as $propertyReflector) { - if (null === $propertyType = $propertyReflector->getType()) { - continue; - } + $propertyType = $propertyReflector->getType(); + $name = null === $parent ? $propertyReflector->getName() : $parent.'['.$propertyReflector->getName().']'; - if ($propertyType instanceof \ReflectionNamedType && !$propertyType->isBuiltin()) { - $parentName = $parent ? $parent.'[' : ''; - $parentName .= $propertyReflector->getName(); - $parentName .= $parent ? ']' : ''; + $attributes = $propertyReflector->getAttributes(QueryParameter::class, \ReflectionAttribute::IS_INSTANCEOF); - $this->guessRecursive($propertyType->getName(), $parameters, $operation, $context, $parentName); + // a property without #[QueryParameter] typed with a class is a nested query DTO + if ([] === $attributes && $propertyType instanceof \ReflectionNamedType && !$propertyType->isBuiltin() && class_exists($propertyType->getName())) { + $this->guessRecursive($propertyType->getName(), $parameters, $operation, $context, $name); continue; } - foreach ($propertyReflector->getAttributes(Param::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + foreach ($attributes as $attribute) { $parameter = $attribute->newInstance(); - if (Generator::isDefault($parameter->name)) { - $parameter->name = $parent ? $parent.'[' : ''; - $parameter->name .= $propertyReflector->getName(); - $parameter->name .= $parent ? ']' : ''; - $parameter->parameter = $parameter->name; - } - - if ($propertyReflector->isDefault()) { - $defaultValue = $propertyReflector->getDefaultValue(); - if (null !== $defaultValue && '' !== $defaultValue) { - $parameter->example = $defaultValue; - } + if (Undefined::isDefault($parameter->name)) { + $parameter->name = $name; } - if (!$propertyType->allowsNull()) { - $parameter->required = true; + if ($this->has($parameters, $parameter->name)) { + continue; } - $parameter->_context = new Context(['nested' => $operation], $context); + $parameter->_context = new Context([ + 'nested' => $operation, + 'property' => $propertyReflector->getName(), + 'reflector' => $propertyReflector, + 'comment' => $propertyReflector->getDocComment() ?: null, + ], $context); $parameters[] = $parameter; } } } + + /** + * @param array $parameters + */ + protected function guessQueryParameter(\ReflectionParameter $rp, MapQueryParameter $attribute, array &$parameters, Operation $operation, Context $context): void + { + $name = $attribute->name ?? $rp->getName(); + + // an explicit #[QueryParameter] on the same argument is already part of the operation + if ($this->has($parameters, $name) || $rp->getAttributes(Parameter::class, \ReflectionAttribute::IS_INSTANCEOF)) { + return; + } + + $parameter = new QueryParameter(name: $name); + $parameter->_context = new Context([ + 'nested' => $operation, + 'reflector' => $rp, + 'comment' => null, + ], $context); + $parameters[] = $parameter; + } + + /** + * @param array $parameters + */ + private function has(array $parameters, string $name): bool + { + foreach ($parameters as $parameter) { + if ('query' === $parameter->in && $parameter->name === $name) { + return true; + } + } + + return false; + } } diff --git a/src/OpenApi/Analyser/Guesser/Operation/OperationRequestBodyGuesser.php b/src/OpenApi/Analyser/Guesser/Operation/OperationRequestBodyGuesser.php index ef0aaa0..d5afbab 100644 --- a/src/OpenApi/Analyser/Guesser/Operation/OperationRequestBodyGuesser.php +++ b/src/OpenApi/Analyser/Guesser/Operation/OperationRequestBodyGuesser.php @@ -17,10 +17,15 @@ use OpenApi\Annotations\Operation; use OpenApi\Attributes as OA; use OpenApi\Context; -use OpenApi\Generator; +use OpenApi\Undefined; use OpenSolid\OpenApiBundle\Attribute\Payload; use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\AnalyserGuesserInterface; +use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\CollectionItemsTypeGuesser; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +/** + * Guesses the operation request body from the #[MapRequestPayload] (or #[Payload]) argument. + */ class OperationRequestBodyGuesser implements AnalyserGuesserInterface { public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Context $context): void @@ -29,7 +34,7 @@ public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Con return; } - if (!Generator::isDefault($annotation->requestBody)) { + if (!Undefined::isDefault($annotation->requestBody)) { return; } @@ -38,24 +43,32 @@ public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Con } foreach ($reflector->getParameters() as $rp) { - foreach ($rp->getAttributes(Payload::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { - $type = (($rnt = $rp->getType()) && $rnt instanceof \ReflectionNamedType) ? $rnt->getName() : null; - - if (null === $type) { + foreach ($rp->getAttributes(MapRequestPayload::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + if (!($rnt = $rp->getType()) instanceof \ReflectionNamedType) { continue; } - $bodyAttribute = $attribute->newInstance(); + $type = $rnt->getName(); + $payload = $attribute->newInstance(); $annotation->requestBody = new OA\RequestBody(required: !$rnt->allowsNull()); $annotation->requestBody->_context = new Context(['nested' => $annotation], $context); $jsonContent = new OA\JsonContent(type: $type); - if ('array' === $type && null !== $bodyAttribute->itemsType) { - $jsonContent->items = new OA\Items(type: $bodyAttribute->itemsType); + if ('array' === $type && null !== $itemsType = $this->guessItemsType($rp, $payload)) { + $jsonContent->items = new OA\Items(type: $itemsType); } $jsonContent->_context = new Context(['nested' => $annotation->requestBody], $context); $annotation->requestBody->merge([$jsonContent]); + + return; } } } + + private function guessItemsType(\ReflectionParameter $parameter, MapRequestPayload $payload): ?string + { + return ($payload instanceof Payload ? $payload->itemsType : null) + ?? $payload->type + ?? CollectionItemsTypeGuesser::guess($parameter); + } } diff --git a/src/OpenApi/Analyser/Guesser/Operation/OperationResponseGuesser.php b/src/OpenApi/Analyser/Guesser/Operation/OperationResponseGuesser.php index b23549e..3c045f0 100644 --- a/src/OpenApi/Analyser/Guesser/Operation/OperationResponseGuesser.php +++ b/src/OpenApi/Analyser/Guesser/Operation/OperationResponseGuesser.php @@ -14,12 +14,25 @@ namespace OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\Operation; use OpenApi\Annotations\AbstractAnnotation; +use OpenApi\Annotations as OAA; use OpenApi\Annotations\Operation; use OpenApi\Attributes as OA; use OpenApi\Context; -use OpenApi\Generator; +use OpenApi\Undefined; use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\AnalyserGuesserInterface; +use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\CollectionItemsTypeGuesser; +use Symfony\Component\HttpFoundation\Response; +/** + * Guesses the operation responses from the controller signature. + * + * The success response is inferred from the return type. When the operation declares no + * responses, the common error responses are added too. When it declares some, the inferred + * success response is merged into them: a declared status code is never replaced, but it + * receives the inferred body when it declares none. + * + * A secured operation also documents the 401 and 403 responses. + */ class OperationResponseGuesser implements AnalyserGuesserInterface { public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Context $context): void @@ -28,49 +41,132 @@ public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Con return; } - if (!Generator::isDefault($annotation->responses)) { - return; + $declared = !Undefined::isDefault($annotation->responses); + $candidates = [$this->createSuccessResponse($reflector, $annotation, $context)]; + + if (!$declared) { + $returnType = $reflector->getReturnType(); + $returnsCollection = $returnType instanceof \ReflectionNamedType && 'array' === $returnType->getName(); + $isResourceAware = !$returnsCollection && ($annotation instanceof OA\Get || $annotation instanceof OA\Head || $annotation instanceof OA\Put || $annotation instanceof OA\Patch || $annotation instanceof OA\Delete); + $isMutable = $annotation instanceof OA\Post || $annotation instanceof OA\Put || $annotation instanceof OA\Patch; + + $candidates[] = $this->createRefResponse(400, $annotation, $context); + if ($isResourceAware) { + $candidates[] = $this->createRefResponse(404, $annotation, $context); + } + if ($isMutable) { + $candidates[] = $this->createRefResponse(422, $annotation, $context); + } + } + + if (!Undefined::isDefault($annotation->security) && [] !== $annotation->security) { + $candidates[] = $this->createRefResponse(401, $annotation, $context); + $candidates[] = $this->createRefResponse(403, $annotation, $context); + } + + $annotation->responses = $this->merge($declared ? $annotation->responses : [], $candidates); + } + + private function createSuccessResponse(\ReflectionMethod $reflector, Operation $operation, Context $context): OA\Response + { + $returnType = $reflector->getReturnType(); + $isVoid = !$returnType instanceof \ReflectionNamedType || ($returnType->isBuiltin() && 'array' !== $returnType->getName()); + $isResponse = !$isVoid && is_a($returnType->getName(), Response::class, true); + + $statusCode = property_exists($operation, 'statusCode') && null !== $operation->statusCode + ? $operation->statusCode + : ($isVoid ? 204 : ($this->declaredSuccessStatusCode($operation) ?? ($operation instanceof OA\Post ? 201 : 200))); + + $response = new OA\Response(response: $statusCode, description: 'Successful'); + $response->_context = new Context(['nested' => $operation], $context); + + if ($isVoid || $isResponse || $operation instanceof OA\Head || 204 === $statusCode) { + return $response; + } + + $jsonContent = new OA\JsonContent(type: $returnType->getName()); + $jsonContent->_context = new Context(['nested' => $response], $context); + if ('array' === $returnType->getName()) { + $itemsType = (property_exists($operation, 'itemsType') ? $operation->itemsType : null) ?? CollectionItemsTypeGuesser::guess($reflector); + if (null !== $itemsType) { + $jsonContent->items = new OA\Items(type: $itemsType); + } } + $response->merge([$jsonContent]); + + return $response; + } - $isVoid = (null === $rrt = $reflector->getReturnType()) || !$rrt instanceof \ReflectionNamedType || ($rrt->isBuiltin() && 'array' !== $rrt->getName()); - $isPost = $annotation instanceof OA\Post; - $isMutable = $annotation instanceof OA\Post || $annotation instanceof OA\Put || $annotation instanceof OA\Patch; - $isResourceAware = ($annotation instanceof OA\Get || $annotation instanceof OA\Put || $annotation instanceof OA\Patch || $annotation instanceof OA\Delete) && 'array' !== $rrt?->getName(); + /** + * The first declared 2xx status code. ControllerResultSubscriber sends this one at runtime. + */ + private function declaredSuccessStatusCode(Operation $operation): ?int + { + if (Undefined::isDefault($operation->responses)) { + return null; + } - if ($isVoid) { - $statusCode = 204; - } else { - $statusCode = $isPost ? 201 : 200; + foreach ($operation->responses as $response) { + if (is_numeric($response->response) && $response->response >= 200 && $response->response < 300) { + return (int) $response->response; + } } - $successResponse = new OA\Response(response: $statusCode, description: 'Successful'); - $successResponse->_context = new Context(['nested' => $annotation], $context); - if (!$isVoid) { - $jsonContent = new OA\JsonContent(type: $rrt->getName()); - $jsonContent->_context = new Context(['nested' => $successResponse], $context); - if (property_exists($annotation, 'itemsType') && null !== $annotation->itemsType && 'array' === $rrt->getName()) { - $jsonContent->items = new OA\Items(type: $annotation->itemsType); + + return null; + } + + private function createRefResponse(int $statusCode, Operation $operation, Context $context): OA\Response + { + $response = new OA\Response(ref: '#/components/responses/'.$statusCode, response: $statusCode); + $response->_context = new Context(['nested' => $operation], $context); + + return $response; + } + + /** + * @param array $responses + * @param array $candidates + * + * @return array + */ + private function merge(array $responses, array $candidates): array + { + foreach ($candidates as $candidate) { + foreach ($responses as $response) { + if ((string) $response->response !== (string) $candidate->response) { + continue; + } + + if (!$this->hasContent($response) && $this->hasContent($candidate)) { + foreach ($candidate->_unmerged as $content) { + $content->_context->nested = $response; + } + $response->merge($candidate->_unmerged); + } + + continue 2; } - $successResponse->merge([$jsonContent]); + + $responses[] = $candidate; } - $responses = [$successResponse]; + usort($responses, static fn (OAA\Response $a, OAA\Response $b): int => (is_numeric($a->response) ? (int) $a->response : \PHP_INT_MAX) <=> (is_numeric($b->response) ? (int) $b->response : \PHP_INT_MAX)); - $notFoundErrorResponse = new OA\Response(ref: '#/components/responses/400', response: 400); - $notFoundErrorResponse->_context = new Context(['nested' => $annotation], $context); - $responses[] = $notFoundErrorResponse; + return $responses; + } - if ($isResourceAware) { - $notFoundErrorResponse = new OA\Response(ref: '#/components/responses/404', response: 404); - $notFoundErrorResponse->_context = new Context(['nested' => $annotation], $context); - $responses[] = $notFoundErrorResponse; + private function hasContent(OAA\Response $response): bool + { + if (!Undefined::isDefault($response->ref) || (!Undefined::isDefault($response->content) && [] !== $response->content)) { + return true; } - if ($isMutable) { - $validationErrorResponse = new OA\Response(ref: '#/components/responses/422', response: 422); - $validationErrorResponse->_context = new Context(['nested' => $annotation], $context); - $responses[] = $validationErrorResponse; + foreach ($response->_unmerged as $annotation) { + if ($annotation instanceof OAA\MediaType || $annotation instanceof OAA\JsonContent || $annotation instanceof OAA\XmlContent) { + return true; + } } - $annotation->responses = $responses; + return false; } } diff --git a/src/OpenApi/Analyser/Guesser/Property/PropertyDefaultGuesser.php b/src/OpenApi/Analyser/Guesser/Property/PropertyDefaultGuesser.php index 72d1cd9..ee89efd 100644 --- a/src/OpenApi/Analyser/Guesser/Property/PropertyDefaultGuesser.php +++ b/src/OpenApi/Analyser/Guesser/Property/PropertyDefaultGuesser.php @@ -16,7 +16,7 @@ use OpenApi\Annotations\AbstractAnnotation; use OpenApi\Attributes\Property; use OpenApi\Context; -use OpenApi\Generator; +use OpenApi\Undefined; use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\AnalyserGuesserInterface; class PropertyDefaultGuesser implements AnalyserGuesserInterface @@ -27,11 +27,11 @@ public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Con return; } - if (Generator::isDefault($annotation->default) && $reflector->hasDefaultValue() && null !== $default = $reflector->getDefaultValue()) { + if (Undefined::isDefault($annotation->default) && $reflector->hasDefaultValue() && null !== $default = $reflector->getDefaultValue()) { $annotation->default = $default; } - if (Generator::isDefault($annotation->readOnly) && $reflector->isReadOnly()) { + if (Undefined::isDefault($annotation->readOnly) && $reflector->isReadOnly()) { $annotation->readOnly = true; } } diff --git a/src/OpenApi/Analyser/Guesser/Property/PropertyEnumGuesser.php b/src/OpenApi/Analyser/Guesser/Property/PropertyEnumGuesser.php deleted file mode 100644 index 4e1b193..0000000 --- a/src/OpenApi/Analyser/Guesser/Property/PropertyEnumGuesser.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\Property; - -use OpenApi\Annotations\AbstractAnnotation; -use OpenApi\Attributes\Property; -use OpenApi\Context; -use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\AnalyserGuesserInterface; - -class PropertyEnumGuesser implements AnalyserGuesserInterface -{ - public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Context $context): void - { - if (!$reflector instanceof \ReflectionProperty || !$annotation instanceof Property) { - return; - } - - if (null === $type = $reflector->getType()) { - return; - } - - if ($type instanceof \ReflectionNamedType && !$type->isBuiltin() && is_subclass_of($type->getName(), \BackedEnum::class)) { - $enumType = new \ReflectionEnum($type->getName()); - $annotation->enum = array_map(static fn (\ReflectionEnumBackedCase $case) => $case->getValue(), $enumType->getCases()); - $annotation->type = $enumType->getBackingType()?->getName(); - } - } -} diff --git a/src/OpenApi/Analyser/Guesser/Schema/SchemaDefaultGuesser.php b/src/OpenApi/Analyser/Guesser/Schema/SchemaDefaultGuesser.php index 2eaca99..d166f68 100644 --- a/src/OpenApi/Analyser/Guesser/Schema/SchemaDefaultGuesser.php +++ b/src/OpenApi/Analyser/Guesser/Schema/SchemaDefaultGuesser.php @@ -16,7 +16,7 @@ use OpenApi\Annotations\AbstractAnnotation; use OpenApi\Attributes\Schema; use OpenApi\Context; -use OpenApi\Generator; +use OpenApi\Undefined; use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\AnalyserGuesserInterface; class SchemaDefaultGuesser implements AnalyserGuesserInterface @@ -27,7 +27,7 @@ public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Con return; } - if (Generator::isDefault($annotation->readOnly) && $reflector->isReadOnly()) { + if (Undefined::isDefault($annotation->readOnly) && $reflector->isReadOnly()) { $annotation->readOnly = true; } } diff --git a/src/OpenApi/Analyser/ResolvableAnalyser.php b/src/OpenApi/Analyser/ResolvableAnalyser.php index 5af788f..6e9d2d8 100644 --- a/src/OpenApi/Analyser/ResolvableAnalyser.php +++ b/src/OpenApi/Analyser/ResolvableAnalyser.php @@ -30,9 +30,11 @@ public function __construct(private readonly iterable $resolvers) { } - public function setGenerator(Generator $generator): void + public function setGenerator(Generator $generator): static { $this->generator = $generator; + + return $this; } public function fromFile(string $filename, Context $context): Analysis diff --git a/src/OpenApi/Analyser/SerializedAnalyser.php b/src/OpenApi/Analyser/SerializedAnalyser.php index 739ba19..8fd6e5c 100644 --- a/src/OpenApi/Analyser/SerializedAnalyser.php +++ b/src/OpenApi/Analyser/SerializedAnalyser.php @@ -20,6 +20,7 @@ use OpenApi\Context; use OpenApi\Generator; use OpenApi\Serializer; +use OpenApi\Undefined; readonly class SerializedAnalyser implements AnalyserInterface { @@ -27,9 +28,10 @@ public function __construct(private Serializer $serializer = new Serializer()) { } - public function setGenerator(Generator $generator): void + public function setGenerator(Generator $generator): static { // no-op + return $this; } /** @@ -46,7 +48,7 @@ public function fromFile(string $filename, Context $context): Analysis /** @var OpenApi $openapi */ $openapi = $this->serializer->deserializeFile($filename, $format); - if (!Generator::isDefault($openapi->paths)) { + if (!Undefined::isDefault($openapi->paths)) { throw new \InvalidArgumentException('Only OpenAPI files with no paths are supported.'); } diff --git a/src/OpenApi/Constraint/ConstraintSchemaApplier.php b/src/OpenApi/Constraint/ConstraintSchemaApplier.php new file mode 100644 index 0000000..7f53706 --- /dev/null +++ b/src/OpenApi/Constraint/ConstraintSchemaApplier.php @@ -0,0 +1,298 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\OpenApi\Constraint; + +use OpenApi\Annotations as OA; +use OpenApi\Context; +use OpenApi\Undefined; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Maps Symfony Validator constraints onto an OpenAPI schema: + * - `format` from Uuid, Ulid, Email, Url, Hostname, Ip, Date, DateTime, Time + * - `minLength` / `maxLength` from Length, and `minLength` from NotBlank on a string + * - `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` from Range and comparison constraints + * - `pattern` from Regex + * - `enum` from Choice + * - `minItems` / `maxItems` from Count + * - the same keywords on `items` from the nested constraints of All + * + * Shared by the schema, parameter, and required-field processors so that a body property + * and a query parameter infer the same metadata from the same constraints. + */ +readonly class ConstraintSchemaApplier +{ + /** + * Resolves the reflector that carries the constraint attributes. + * + * A promoted constructor property is reported as a ReflectionParameter, but the + * attributes are readable from the declared property, so it is upgraded here. + * + * Only a promoted constructor property is upgraded. Any other method parameter keeps + * its own reflector: a plain argument that shares its name with a property of the + * declaring class, such as an __invoke() argument and an injected dependency, must not + * inherit the constraints of that property. + */ + public static function resolveReflector(?\Reflector $reflector): \ReflectionProperty|\ReflectionParameter|null + { + if ($reflector instanceof \ReflectionParameter && $reflector->isPromoted()) { + // only a constructor parameter can be promoted + return $reflector->getDeclaringClass()?->getProperty($reflector->getName()); + } + + return $reflector instanceof \ReflectionProperty || $reflector instanceof \ReflectionParameter + ? $reflector + : null; + } + + public function apply(OA\Schema $schema, \ReflectionProperty $reflector): void + { + $this->applyConstraints($schema, $this->constraints($reflector), $reflector); + } + + /** + * @param iterable $constraints + * @param \ReflectionProperty|null $reflector The property that declares the constraints, or + * null for the nested constraints of an All + * constraint, which describe an item instead + */ + private function applyConstraints(OA\Schema $schema, iterable $constraints, ?\ReflectionProperty $reflector): void + { + $hasNotBlank = false; + + foreach ($constraints as $constraint) { + // NotBlank only rules out the empty string, so it must not win over a Length that + // asks for more. It is applied last, whatever the order of the attributes. + if ($constraint instanceof Assert\NotBlank) { + $hasNotBlank = true; + + continue; + } + + match (true) { + $constraint instanceof Assert\Uuid => $this->applyFormat($schema, 'uuid'), + $constraint instanceof Assert\Ulid => $this->applyFormat($schema, 'ulid'), + $constraint instanceof Assert\Email => $this->applyFormat($schema, 'email'), + $constraint instanceof Assert\Url => $this->applyFormat($schema, 'uri'), + $constraint instanceof Assert\Hostname => $this->applyFormat($schema, 'hostname'), + $constraint instanceof Assert\Ip => $this->applyIpFormat($schema, $constraint), + $constraint instanceof Assert\Date => $this->applyFormat($schema, 'date'), + $constraint instanceof Assert\DateTime => $this->applyFormat($schema, 'date-time'), + $constraint instanceof Assert\Time => $this->applyFormat($schema, 'time'), + $constraint instanceof Assert\Length => $this->applyLength($schema, $constraint), + $constraint instanceof Assert\All => $this->applyAll($schema, $constraint), + $constraint instanceof Assert\Count => $this->applyCount($schema, $constraint), + $constraint instanceof Assert\Range => $this->applyRange($schema, $constraint), + $constraint instanceof Assert\Positive, + $constraint instanceof Assert\PositiveOrZero, + $constraint instanceof Assert\Negative, + $constraint instanceof Assert\NegativeOrZero, + $constraint instanceof Assert\GreaterThan, + $constraint instanceof Assert\GreaterThanOrEqual, + $constraint instanceof Assert\LessThan, + $constraint instanceof Assert\LessThanOrEqual => $this->applyComparison($schema, $constraint), + $constraint instanceof Assert\Regex => $this->applyPattern($schema, $constraint), + $constraint instanceof Assert\Choice => $this->applyChoice($schema, $constraint), + default => null, + }; + } + + if ($hasNotBlank) { + $this->applyNotBlank($schema, $reflector); + } + } + + /** + * Whether the constraints make the value mandatory, regardless of PHP type nullability + * or default values. + * + * NotBlank accepts null when allowNull is set, so it leaves the value optional. + */ + public function isRequired(\ReflectionProperty $reflector): bool + { + foreach ($this->constraints($reflector) as $constraint) { + if ($constraint instanceof Assert\NotNull) { + return true; + } + + if ($constraint instanceof Assert\NotBlank && !$constraint->allowNull) { + return true; + } + } + + return false; + } + + /** + * @return iterable + */ + private function constraints(\ReflectionProperty $reflector): iterable + { + foreach ($reflector->getAttributes(Constraint::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + yield $attribute->newInstance(); + } + } + + private function applyFormat(OA\Schema $schema, string $format): void + { + if (Undefined::isDefault($schema->format)) { + $schema->format = $format; + } + } + + private function applyIpFormat(OA\Schema $schema, Assert\Ip $constraint): void + { + if (Undefined::isDefault($schema->format)) { + $schema->format = str_starts_with($constraint->version, '6') ? 'ipv6' : 'ipv4'; + } + } + + private function applyLength(OA\Schema $schema, Assert\Length $constraint): void + { + if (null !== $constraint->min && Undefined::isDefault($schema->minLength)) { + $schema->minLength = $constraint->min; + } + + if (null !== $constraint->max && Undefined::isDefault($schema->maxLength)) { + $schema->maxLength = $constraint->max; + } + } + + /** + * NotBlank rejects the empty string, which is `minLength: 1` for a string value. + * + * The PHP type decides, not the schema type: this can run before type inference. An item + * has no reflector of its own, so there the schema type decides. + */ + private function applyNotBlank(OA\Schema $schema, ?\ReflectionProperty $reflector): void + { + if (null === $reflector) { + if ('string' !== $schema->type) { + return; + } + } else { + $type = $reflector->getType(); + + if (!$type instanceof \ReflectionNamedType || 'string' !== $type->getName()) { + return; + } + } + + if (Undefined::isDefault($schema->minLength)) { + $schema->minLength = 1; + } + } + + /** + * An All constraint validates every element of a collection, so its nested constraints + * describe the items of the array, not the array itself. + * + * The item type is left alone: a constraint carries none. When the schema has no items yet, + * an empty one is created, and the swagger-php type resolver fills its type in later from + * the `@var` line of the property. + */ + private function applyAll(OA\Schema $schema, Assert\All $constraint): void + { + if (!Undefined::isDefault($schema->type) && 'array' !== $schema->type) { + return; + } + + if (Undefined::isDefault($schema->items)) { + $schema->items = new OA\Items([ + '_context' => new Context(['generated' => true], $schema->_context), + ]); + } + + $this->applyConstraints( + $schema->items, + \is_array($constraint->constraints) ? $constraint->constraints : [$constraint->constraints], + null, + ); + } + + private function applyCount(OA\Schema $schema, Assert\Count $constraint): void + { + if (null !== $constraint->min && Undefined::isDefault($schema->minItems)) { + $schema->minItems = $constraint->min; + } + + if (null !== $constraint->max && Undefined::isDefault($schema->maxItems)) { + $schema->maxItems = $constraint->max; + } + } + + private function applyRange(OA\Schema $schema, Assert\Range $constraint): void + { + if (null !== $constraint->min && is_numeric($constraint->min) && Undefined::isDefault($schema->minimum)) { + $schema->minimum = $constraint->min + 0; + } + + if (null !== $constraint->max && is_numeric($constraint->max) && Undefined::isDefault($schema->maximum)) { + $schema->maximum = $constraint->max + 0; + } + } + + private function applyComparison(OA\Schema $schema, Assert\AbstractComparison $constraint): void + { + if (!is_numeric($constraint->value)) { + return; + } + + $value = $constraint->value + 0; + + match (true) { + $constraint instanceof Assert\Positive, + $constraint instanceof Assert\GreaterThan => Undefined::isDefault($schema->exclusiveMinimum) && $schema->exclusiveMinimum = $value, + $constraint instanceof Assert\PositiveOrZero, + $constraint instanceof Assert\GreaterThanOrEqual => Undefined::isDefault($schema->minimum) && $schema->minimum = $value, + $constraint instanceof Assert\Negative, + $constraint instanceof Assert\LessThan => Undefined::isDefault($schema->exclusiveMaximum) && $schema->exclusiveMaximum = $value, + $constraint instanceof Assert\NegativeOrZero, + $constraint instanceof Assert\LessThanOrEqual => Undefined::isDefault($schema->maximum) && $schema->maximum = $value, + }; + } + + private function applyPattern(OA\Schema $schema, Assert\Regex $constraint): void + { + if (!$constraint->match || !Undefined::isDefault($schema->pattern)) { + return; + } + + $pattern = $constraint->pattern; + + if (null === $pattern || '' === $pattern) { + return; + } + + // Strip PHP regex delimiters (e.g., /^pattern$/ → ^pattern$) + $delimiter = $pattern[0]; + $endPos = strrpos($pattern, $delimiter); + + if ($endPos > 0) { + $pattern = substr($pattern, 1, $endPos - 1); + } + + $schema->pattern = $pattern; + } + + private function applyChoice(OA\Schema $schema, Assert\Choice $constraint): void + { + if ($constraint->multiple || null === $constraint->choices || !Undefined::isDefault($schema->enum)) { + return; + } + + $schema->enum = $constraint->choices; + } +} diff --git a/src/OpenApi/Processor/AugmentEnumSchemas.php b/src/OpenApi/Processor/AugmentEnumSchemas.php new file mode 100644 index 0000000..0ab0570 --- /dev/null +++ b/src/OpenApi/Processor/AugmentEnumSchemas.php @@ -0,0 +1,177 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\OpenApi\Processor; + +use OpenApi\Analysis; +use OpenApi\Annotations as OA; +use OpenApi\GeneratorAwareInterface; +use OpenApi\GeneratorAwareTrait; +use OpenApi\Undefined; +use OpenSolid\OpenApiBundle\OpenApi\Constraint\ConstraintSchemaApplier; + +/** + * Infers `type` and `enum` for schemas and parameters backed by a PHP enum. + * + * swagger-php turns an enum-typed property into a $ref, but only when the enum class + * carries #[OA\Schema] and lives inside the scanned paths. A plain domain enum is left + * without a type. This processor inlines its cases instead, so that + * `public ?ListingFeeAllocation $allocation` renders as: + * + * type: ['string', 'null'] + * enum: [...] + * + * An enum that already resolved to a $ref keeps it. + * + * Finally it cleans up a `type` that still holds a raw PHP class name, which is how the + * swagger-php type resolver reports the value type of a `list`. + */ +class AugmentEnumSchemas implements ProcessorInterface, GeneratorAwareInterface +{ + use GeneratorAwareTrait; + + public function __invoke(Analysis $analysis): void + { + /** @var OA\Parameter[] $parameters */ + $parameters = $analysis->getAnnotationsOfType(OA\Parameter::class); + + // a parameter schema inherits the reflector of its parameter, so it is handled below + $parameterSchemas = new \SplObjectStorage(); + foreach ($parameters as $parameter) { + if (!Undefined::isDefault($parameter->schema)) { + $parameterSchemas->offsetSet($parameter->schema); + } + } + + /** @var OA\Schema[] $schemas */ + $schemas = $analysis->getAnnotationsOfType(OA\Schema::class); + + foreach ($schemas as $schema) { + if ($parameterSchemas->offsetExists($schema)) { + continue; + } + + $this->inferFromReflection($schema, $schema->_context->reflector ?? null, true); + $this->resolveClassStringType($schema); + } + + foreach ($parameters as $parameter) { + if (Undefined::isDefault($parameter->schema)) { + continue; + } + + $this->inferFromReflection($parameter->schema, $parameter->_context->reflector ?? null, false); + $this->resolveClassStringType($parameter->schema); + } + } + + /** + * @param bool $nullable whether a nullable PHP type makes the schema nullable; a query string + * carries no real null, so a parameter schema never is + */ + private function inferFromReflection(OA\Schema $schema, ?\Reflector $reflector, bool $nullable): void + { + if (!Undefined::isDefault($schema->type) + || !Undefined::isDefault($schema->ref) + || !Undefined::isDefault($schema->enum) + ) { + return; + } + + $reflector = ConstraintSchemaApplier::resolveReflector($reflector); + + if (null === $reflector) { + return; + } + + $type = $reflector->getType(); + + if (!$type instanceof \ReflectionNamedType || $type->isBuiltin() || !enum_exists($type->getName())) { + return; + } + + $this->applyEnum($schema, new \ReflectionEnum($type->getName())); + + if ($nullable && $type->allowsNull()) { + if (Undefined::isDefault($schema->nullable)) { + $schema->nullable = true; + } + + // a JSON Schema "enum" rejects any value it does not list, null included + if (true === $schema->nullable) { + $schema->enum[] = null; + } + } + } + + /** + * Rewrites a `type` that still holds a raw PHP class name. + * + * The type resolver writes the value type of a collection into `items.type` and maps + * only the top-level `type` back to a spec type, so `list` leaves the enum + * class name behind. An enum is inlined the same way a scalar enum property is; any + * other class name is dropped, because a PHP class name is no OpenAPI type. + */ + private function resolveClassStringType(OA\Schema $schema): void + { + if ($schema->items instanceof OA\Items) { + $this->resolveClassStringType($schema->items); + } + + $type = $schema->type; + + if (Undefined::isDefault($type) || !\is_string($type) || 'null' === strtolower($type)) { + return; + } + + if ($this->generator->getTypeResolver()->mapNativeType($schema, $type)) { + return; + } + + $class = ltrim($type, '\\'); + + if (enum_exists($class)) { + $this->applyEnum($schema, new \ReflectionEnum($class)); + + return; + } + + // Any other class name is dropped: a schema that never resolved to a $ref carries + // no type a client can read. A type that names no class is left alone, because it + // can be a hand-written one. + if (class_exists($class) || interface_exists($class)) { + $schema->type = Undefined::UNDEFINED; + } + } + + /** + * Inlines the cases of an enum as `enum`, with the type of its backing value. + * + * @param \ReflectionEnum<\UnitEnum> $enum + */ + private function applyEnum(OA\Schema $schema, \ReflectionEnum $enum): void + { + $backingType = $enum->isBacked() ? (string) $enum->getBackingType() : null; + + $schema->enum = array_map( + static fn (\ReflectionEnumUnitCase $case): string|int => $case instanceof \ReflectionEnumBackedCase + ? $case->getBackingValue() + : $case->name, + $enum->getCases(), + ); + + $schema->type = null === $backingType + ? 'string' + : $this->generator->getTypeResolver()->native2spec($backingType); + } +} diff --git a/src/OpenApi/Processor/AugmentParameterConstraints.php b/src/OpenApi/Processor/AugmentParameterConstraints.php new file mode 100644 index 0000000..3f83e55 --- /dev/null +++ b/src/OpenApi/Processor/AugmentParameterConstraints.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\OpenApi\Processor; + +use OpenApi\Analysis; +use OpenApi\Annotations as OA; +use OpenApi\Undefined; +use OpenSolid\OpenApiBundle\OpenApi\Constraint\ConstraintSchemaApplier; + +/** + * Augments query and path parameter schemas from the Symfony Validator constraints declared + * on the backing PHP property or method parameter. + * + * This is the parameter counterpart of AugmentSchemaConstraints. It runs later in the + * pipeline because query parameters do not exist yet when schemas are augmented: + * the operation guessers attach them to the operation. + */ +readonly class AugmentParameterConstraints implements ProcessorInterface +{ + public function __construct( + private ConstraintSchemaApplier $applier = new ConstraintSchemaApplier(), + ) { + } + + public function __invoke(Analysis $analysis): void + { + /** @var OA\Parameter[] $parameters */ + $parameters = $analysis->getAnnotationsOfType(OA\Parameter::class); + + foreach ($parameters as $parameter) { + $reflector = ConstraintSchemaApplier::resolveReflector($parameter->_context->reflector ?? null); + + // Symfony constraints can only target a property, so a plain method parameter + // (anything but a promoted constructor property) never carries one. + if (!$reflector instanceof \ReflectionProperty) { + continue; + } + + if (!Undefined::isDefault($parameter->schema)) { + $this->applier->apply($parameter->schema, $reflector); + } + + if (true !== $parameter->required && $this->applier->isRequired($reflector)) { + $parameter->required = true; + } + } + } +} diff --git a/src/OpenApi/Processor/AugmentPathParameters.php b/src/OpenApi/Processor/AugmentPathParameters.php new file mode 100644 index 0000000..f19e8a3 --- /dev/null +++ b/src/OpenApi/Processor/AugmentPathParameters.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\OpenApi\Processor; + +use OpenApi\Analysis; +use OpenApi\Annotations as OA; +use OpenApi\Context; +use OpenApi\Undefined; +use OpenSolid\OpenApiBundle\Attribute\Path; + +/** + * Moves the "format" and "enum" shortcuts of #[Path] into the parameter schema. + */ +readonly class AugmentPathParameters implements ProcessorInterface +{ + public function __invoke(Analysis $analysis): void + { + /** @var Path[] $parameters */ + $parameters = $analysis->getAnnotationsOfType(Path::class); + + foreach ($parameters as $parameter) { + if (Undefined::isDefault($parameter->format, $parameter->enum)) { + continue; + } + + if (Undefined::isDefault($parameter->schema)) { + $parameter->schema = new OA\Schema(['_context' => new Context(['generated' => true], $parameter->_context)]); + } + + if (Undefined::isDefault($parameter->schema->format) && !Undefined::isDefault($parameter->format)) { + $parameter->schema->format = $parameter->format; + } + + if (Undefined::isDefault($parameter->schema->enum) && !Undefined::isDefault($parameter->enum)) { + $parameter->schema->enum = $this->enumValues($parameter->enum); + } + } + } + + /** + * @param array|class-string $enum + * + * @return array + */ + private function enumValues(array|string $enum): array + { + if (\is_string($enum)) { + $enum = is_subclass_of($enum, \UnitEnum::class) ? $enum::cases() : [$enum]; + } + + return array_map(static fn (mixed $value): mixed => match (true) { + $value instanceof \BackedEnum => $value->value, + $value instanceof \UnitEnum => $value->name, + default => $value, + }, array_values($enum)); + } +} diff --git a/src/OpenApi/Processor/AugmentQueryParameters.php b/src/OpenApi/Processor/AugmentQueryParameters.php new file mode 100644 index 0000000..f2802b3 --- /dev/null +++ b/src/OpenApi/Processor/AugmentQueryParameters.php @@ -0,0 +1,203 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\OpenApi\Processor; + +use OpenApi\Analysis; +use OpenApi\Annotations as OA; +use OpenApi\Context; +use OpenApi\GeneratorAwareInterface; +use OpenApi\GeneratorAwareTrait; +use OpenApi\Processors\Concerns\DocblockTrait; +use OpenApi\Undefined; + +/** + * Fills gaps left by AugmentParameters for parameters backed by a ReflectionParameter or + * ReflectionProperty. + * + * swagger-php only runs its type resolver for parameters backed by a ReflectionParameter, + * so a query parameter expanded from a #[MapQueryString] DTO property gets nothing. This + * processor runs the same resolver for those, giving query parameters the same inference + * as schema properties: enums, `list` items, docblock generics, date formats and + * component refs. + * + * It also infers the description from the property docblock, the default value and the + * required flag from the PHP type, and the serialization style of array parameters. + * + * Nullability is deliberately not copied onto the parameter schema. A query string never + * carries a real null, so optionality is expressed by `required: false` alone. This matches + * what swagger-php does for ReflectionParameter-backed parameters. + */ +class AugmentQueryParameters implements ProcessorInterface, GeneratorAwareInterface +{ + use DocblockTrait; + use GeneratorAwareTrait; + + /** + * Schema keywords copied from the inferred type. Mirrors swagger-php's own AugmentParameters. + */ + private const INFERRED_KEYWORDS = ['type', 'format', 'items', 'enum', 'oneOf', 'allOf', 'anyOf', 'ref']; + + public function __invoke(Analysis $analysis): void + { + /** @var OA\Parameter[] $parameters */ + $parameters = $analysis->getAnnotationsOfType(OA\Parameter::class); + + foreach ($parameters as $parameter) { + $reflector = $parameter->_context->reflector ?? null; + + if (!$reflector instanceof \ReflectionParameter && !$reflector instanceof \ReflectionProperty) { + continue; + } + + if (Undefined::isDefault($parameter->schema)) { + $parameter->schema = new OA\Schema([ + '_context' => new Context(['generated' => true], $parameter->_context), + ]); + } + + $schema = $parameter->schema; + $type = $reflector->getType(); + $hasDefault = $reflector instanceof \ReflectionParameter + ? $reflector->isDefaultValueAvailable() + : $reflector->hasDefaultValue(); + + $this->augmentType($analysis, $parameter, $schema, $reflector, $type); + $this->augmentDescription($parameter, $reflector); + + if (Undefined::isDefault($schema->default) && $hasDefault) { + $default = $reflector->getDefaultValue(); + if (null !== $default) { + $schema->default = $default instanceof \BackedEnum ? $default->value : ($default instanceof \UnitEnum ? $default->name : $default); + } + } + + // swagger-php cannot decide this one for a ReflectionParameter: the parameter + // context is nested, so its type resolver bails out and leaves `required: true` + // on an empty schema. It never sets it for a ReflectionProperty, so there a + // `true` is explicit and is kept. A path parameter is always required, whatever + // the PHP signature says. + $isOptional = $hasDefault || (null !== $type && $type->allowsNull()); + + if ('path' !== $parameter->in + && (Undefined::isDefault($parameter->required) || ($reflector instanceof \ReflectionParameter && $parameter->required && $isOptional)) + ) { + $parameter->required = !$isOptional; + } + + $this->augmentArrayStyle($parameter, $schema); + } + } + + private function augmentType( + Analysis $analysis, + OA\Parameter $parameter, + OA\Schema $schema, + \ReflectionParameter|\ReflectionProperty $reflector, + ?\ReflectionType $type, + ): void { + if (!Undefined::isDefault($schema->type) || !Undefined::isDefault($schema->ref)) { + return; + } + + // A throwaway schema keeps the resolver from writing keywords a parameter must not + // carry, most importantly `nullable`. The context must not be nested: the type + // resolver bails on a nested context, and the operation guessers mark the + // parameter context as nested. + $probe = new OA\Schema([ + '_context' => new Context([ + 'generated' => true, + 'nested' => null, + 'reflector' => $reflector, + ], $parameter->_context), + ]); + + $this->generator->getTypeResolver()->augmentSchemaType($analysis, $probe); + + foreach (self::INFERRED_KEYWORDS as $keyword) { + if (!Undefined::isDefault($probe->{$keyword}) && Undefined::isDefault($schema->{$keyword})) { + $schema->{$keyword} = $probe->{$keyword}; + } + } + } + + /** + * Takes the description from the docblock: the `@var` line or the summary of a property, + * the `@param` line of a method parameter. This matches what AugmentProperties does for + * schema properties. + */ + private function augmentDescription(OA\Parameter $parameter, \ReflectionParameter|\ReflectionProperty $reflector): void + { + if (!Undefined::isDefault($parameter->description)) { + return; + } + + $description = $reflector instanceof \ReflectionProperty + ? $this->propertyDescription($reflector) + : $this->parameterDescription($reflector); + + if (null !== $description && '' !== $description) { + $parameter->description = $description; + } + } + + private function propertyDescription(\ReflectionProperty $reflector): ?string + { + $comment = $reflector->getDocComment(); + + if (false === $comment) { + return null; + } + + $description = $this->parseVarLine($comment)['description'] ?? null; + + if (null === $description || '' === $description) { + $description = $this->parseDocblock($comment); + } + + return Undefined::isDefault($description) ? null : trim((string) $description); + } + + private function parameterDescription(\ReflectionParameter $reflector): ?string + { + $comment = $reflector->getDeclaringFunction()->getDocComment(); + + if (false === $comment) { + return null; + } + + $tags = []; + $this->parseDocblock($comment, $tags); + $description = $tags['param'][$reflector->getName()]['description'] ?? null; + + return null === $description ? null : trim((string) $description); + } + + /** + * Repeated query parameters (?tags=a&tags=b) need form style with explode. + */ + private function augmentArrayStyle(OA\Parameter $parameter, OA\Schema $schema): void + { + if ('query' !== $parameter->in || 'array' !== $schema->type) { + return; + } + + if (Undefined::isDefault($parameter->style)) { + $parameter->style = 'form'; + } + + if (Undefined::isDefault($parameter->explode)) { + $parameter->explode = true; + } + } +} diff --git a/src/OpenApi/Processor/AugmentSchemaConstraints.php b/src/OpenApi/Processor/AugmentSchemaConstraints.php new file mode 100644 index 0000000..070a3e9 --- /dev/null +++ b/src/OpenApi/Processor/AugmentSchemaConstraints.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\OpenApi\Processor; + +use OpenApi\Analysis; +use OpenApi\Annotations as OA; +use OpenApi\Undefined; +use OpenSolid\OpenApiBundle\OpenApi\Constraint\ConstraintSchemaApplier; + +/** + * Augments schema properties from the Symfony Validator constraints declared on the + * backing PHP property. + * + * See ConstraintSchemaApplier for the constraint-to-keyword mapping. Query and path + * parameters get the same treatment from AugmentParameterConstraints. + */ +readonly class AugmentSchemaConstraints implements ProcessorInterface +{ + public function __construct( + private ConstraintSchemaApplier $applier = new ConstraintSchemaApplier(), + ) { + } + + public function __invoke(Analysis $analysis): void + { + /** @var OA\Schema[] $schemas */ + $schemas = $analysis->getAnnotationsOfType(OA\Schema::class); + + foreach ($schemas as $schema) { + if (Undefined::isDefault($schema->properties)) { + continue; + } + + foreach ($schema->properties as $property) { + $reflector = ConstraintSchemaApplier::resolveReflector($property->_context->reflector ?? null); + + if (!$reflector instanceof \ReflectionProperty) { + continue; + } + + $this->applier->apply($property, $reflector); + } + } + } +} diff --git a/src/OpenApi/Processor/AugmentSchemas.php b/src/OpenApi/Processor/AugmentSchemas.php index 6bbefab..6bf3edb 100644 --- a/src/OpenApi/Processor/AugmentSchemas.php +++ b/src/OpenApi/Processor/AugmentSchemas.php @@ -15,45 +15,122 @@ use OpenApi\Analysis; use OpenApi\Annotations as OA; -use OpenApi\Generator; -use OpenApi\Processors\ProcessorInterface; +use OpenApi\Undefined; +use OpenSolid\OpenApiBundle\OpenApi\Constraint\ConstraintSchemaApplier; -class AugmentSchemas implements ProcessorInterface +/** + * Infers the "required" fields of the object schemas, and the "default" value of the + * properties promoted from constructor parameters (swagger-php only reads class properties). + * + * A property backed by PHP reflection is required when it is not nullable and has no + * default value, or when a NotNull / NotBlank constraint asks for it. A property without + * reflection (e.g. defined in a YAML file) is required when it belongs to a write-only + * schema and is not nullable. + */ +readonly class AugmentSchemas implements ProcessorInterface { + public function __construct( + private ?ConstraintSchemaApplier $applier = null, + ) { + } + public function __invoke(Analysis $analysis): void { /** @var OA\Schema[] $schemas */ $schemas = $analysis->getAnnotationsOfType(OA\Schema::class); - $this->augmentRequired($schemas); - } - - /** - * @param OA\Schema[] $schemas - */ - protected function augmentRequired(array $schemas): void - { foreach ($schemas as $schema) { - if ('object' !== $schema->type || Generator::isDefault($schema->properties)) { - continue; - } - - if (true !== $schema->writeOnly || !Generator::isDefault($schema->required)) { + if (!Undefined::isDefault($schema->required) || Undefined::isDefault($schema->properties)) { continue; } $required = []; foreach ($schema->properties as $property) { - if (true !== $property->nullable) { - $required[] = $property->property; + $this->augmentPromotedDefault($property); + + if ($this->isRequired($schema, $property)) { + $required[] = Undefined::isDefault($property->property) ? $property->_context->property : $property->property; } } - if ([] === $required) { - continue; + if ([] !== $required) { + $schema->required = $required; + } + } + } + + private function isRequired(OA\Schema $schema, OA\Property $property): bool + { + $reflector = ConstraintSchemaApplier::resolveReflector($property->_context->reflector ?? null); + + if (!$reflector instanceof \ReflectionProperty) { + return true === $schema->writeOnly && true !== $property->nullable; + } + + $type = $reflector->getType(); + $isNullable = null === $type || $type->allowsNull(); + + // A NotNull or NotBlank constraint makes the field mandatory in the payload + // even when the PHP type is nullable or carries a default. + if (true === $this->applier?->isRequired($reflector)) { + if ($isNullable) { + $this->denyNull($property); } - $schema->required = $required; + return true; + } + + return !$isNullable && !$reflector->hasDefaultValue() && null === $this->promotedParameterWithDefault($reflector); + } + + private function augmentPromotedDefault(OA\Property $property): void + { + $reflector = ConstraintSchemaApplier::resolveReflector($property->_context->reflector ?? null); + + if (!$reflector instanceof \ReflectionProperty || !Undefined::isDefault($property->default)) { + return; + } + + if (null !== ($default = $this->promotedParameterWithDefault($reflector)?->getDefaultValue())) { + $property->default = $default instanceof \BackedEnum ? $default->value : ($default instanceof \UnitEnum ? $default->name : $default); + } + } + + /** + * A promoted property never reports a default value; its constructor parameter does. + */ + private function promotedParameterWithDefault(\ReflectionProperty $reflector): ?\ReflectionParameter + { + if (!$reflector->isPromoted()) { + return null; + } + + $parameter = new \ReflectionParameter([$reflector->getDeclaringClass()->getName(), '__construct'], $reflector->getName()); + + return $parameter->isDefaultValueAvailable() ? $parameter : null; + } + + /** + * Keeps a mandatory field from also advertising null. + * + * The constraint rejects null, so a schema that still allows it would tell a client to + * send a value the request would then reject. "false" rather than undefined: the + * swagger-php type resolver runs later and only fills a nullable flag left undefined. + */ + private function denyNull(OA\Schema $property): void + { + $property->nullable = false; + + if (!\is_array($property->type)) { + return; } + + $types = array_values(array_filter($property->type, static fn (string $type): bool => 'null' !== $type)); + + $property->type = match (\count($types)) { + 0 => Undefined::UNDEFINED, + 1 => $types[0], + default => $types, + }; } } diff --git a/src/OpenApi/Processor/CleanupAnnotations.php b/src/OpenApi/Processor/CleanupAnnotations.php index 170b706..68f0a32 100644 --- a/src/OpenApi/Processor/CleanupAnnotations.php +++ b/src/OpenApi/Processor/CleanupAnnotations.php @@ -14,14 +14,7 @@ namespace OpenSolid\OpenApiBundle\OpenApi\Processor; use OpenApi\Analysis; -use OpenApi\Annotations\Operation; -use OpenApi\Generator; -use OpenApi\Processors\ProcessorInterface; -use OpenSolid\OpenApiBundle\Routing\Attribute\Delete; -use OpenSolid\OpenApiBundle\Routing\Attribute\Get; -use OpenSolid\OpenApiBundle\Routing\Attribute\Patch; -use OpenSolid\OpenApiBundle\Routing\Attribute\Post; -use OpenSolid\OpenApiBundle\Routing\Attribute\Put; +use OpenApi\Undefined; readonly class CleanupAnnotations implements ProcessorInterface { @@ -41,7 +34,7 @@ protected function removeComponentsDuplicatedResponses(Analysis $analysis): void return; } - if (Generator::isDefault($openapi->components)) { + if (Undefined::isDefault($openapi->components) || Undefined::isDefault($openapi->components->responses)) { return; } @@ -66,23 +59,15 @@ protected function removeComponentsUselessResponses(Analysis $analysis): void return; } - if (Generator::isDefault($openapi->components)) { + if (Undefined::isDefault($openapi->components) || Undefined::isDefault($openapi->components->responses)) { return; } foreach ($openapi->components->responses as $i => $response) { - if (!Generator::isDefault($openapi->paths)) { + if (!Undefined::isDefault($openapi->paths)) { foreach ($openapi->paths as $pathItem) { - /** @var Operation[]|Post[]|Get[]|Put[]|Patch[]|Delete[] $methods */ - $methods = [ - $pathItem->post, - $pathItem->get, - $pathItem->put, - $pathItem->patch, - $pathItem->delete, - ]; - foreach ($methods as $method) { - if (Generator::isDefault($method) || Generator::isDefault($method->responses)) { + foreach ($this->operationsOf($pathItem) as $method) { + if (Undefined::isDefault($method->responses)) { continue; } @@ -100,7 +85,7 @@ protected function removeComponentsUselessResponses(Analysis $analysis): void } if ([] === $openapi->components->responses) { - $openapi->components->responses = Generator::UNDEFINED; + $openapi->components->responses = Undefined::UNDEFINED; } } @@ -110,12 +95,12 @@ protected function removeComponentsUselessParameters(Analysis $analysis): void return; } - if (Generator::isDefault($openapi->components) || Generator::isDefault($openapi->components->parameters)) { + if (Undefined::isDefault($openapi->components) || Undefined::isDefault($openapi->components->parameters)) { return; } foreach ($openapi->components->parameters as $i => $parameter) { - if (!Generator::isDefault($parameter->name) && !Generator::isDefault($parameter->parameter)) { + if (!Undefined::isDefault($parameter->name) && !Undefined::isDefault($parameter->parameter)) { continue; } @@ -124,7 +109,7 @@ protected function removeComponentsUselessParameters(Analysis $analysis): void } if ([] === $openapi->components->parameters) { - $openapi->components->parameters = Generator::UNDEFINED; + $openapi->components->parameters = Undefined::UNDEFINED; } } @@ -134,23 +119,47 @@ protected function removeComponentsUselessSchemas(Analysis $analysis): void return; } - if (Generator::isDefault($openapi->components)) { + if (Undefined::isDefault($openapi->components) || Undefined::isDefault($openapi->components->schemas)) { return; } - foreach ($openapi->components->schemas as $i => $schema) { - foreach ($analysis->annotations as $annotation) { - if (property_exists($annotation, 'ref') && (string) $annotation->ref === '#/components/schemas/'.$schema->schema) { - continue 2; + $schemas = []; + foreach ($openapi->components->schemas as $schema) { + $schemas['#/components/schemas/'.$schema->schema] = $schema; + } + + // refs used outside the schemas, then the refs those schemas use in turn + $components = $openapi->components; + $openapi->components = clone $components; + $openapi->components->schemas = Undefined::UNDEFINED; + $refs = $this->collectRefs($openapi); + $openapi->components = $components; + + $pending = array_keys($refs); + while (null !== $ref = array_pop($pending)) { + if (!isset($schemas[$ref])) { + continue; + } + + foreach ($this->collectRefs($schemas[$ref]) as $nestedRef => $_) { + if (!isset($refs[$nestedRef])) { + $refs[$nestedRef] = true; + $pending[] = $nestedRef; } } + } + + foreach ($openapi->components->schemas as $i => $schema) { + if (isset($refs['#/components/schemas/'.$schema->schema])) { + continue; + } $this->detachAnnotationRecursively($schema, $analysis); unset($openapi->components->schemas[$i]); } if ([] === $openapi->components->schemas) { - $openapi->components->schemas = Generator::UNDEFINED; + $openapi->components->schemas = Undefined::UNDEFINED; } } } diff --git a/src/OpenApi/Processor/PathDeciderProcessor.php b/src/OpenApi/Processor/PathDeciderProcessor.php index a27c9fc..a297a53 100644 --- a/src/OpenApi/Processor/PathDeciderProcessor.php +++ b/src/OpenApi/Processor/PathDeciderProcessor.php @@ -14,14 +14,7 @@ namespace OpenSolid\OpenApiBundle\OpenApi\Processor; use OpenApi\Analysis; -use OpenApi\Annotations\Operation; -use OpenApi\Generator; -use OpenApi\Processors\ProcessorInterface; -use OpenSolid\OpenApiBundle\Routing\Attribute\Delete; -use OpenSolid\OpenApiBundle\Routing\Attribute\Get; -use OpenSolid\OpenApiBundle\Routing\Attribute\Patch; -use OpenSolid\OpenApiBundle\Routing\Attribute\Post; -use OpenSolid\OpenApiBundle\Routing\Attribute\Put; +use OpenApi\Undefined; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\RequestContext; @@ -38,28 +31,15 @@ public function __construct( public function __invoke(Analysis $analysis): void { - if (Generator::isDefault($analysis->openapi->paths)) { + if (Undefined::isDefault($analysis->openapi->paths)) { return; } $el = new ExpressionLanguage(null, iterator_to_array($this->expressionLanguageProviders)); foreach ($analysis->openapi->paths as $index => $pathItem) { - /** @var Operation[]|Post[]|Get[]|Put[]|Patch[]|Delete[] $methods */ - $methods = [ - $pathItem->post, - $pathItem->get, - $pathItem->put, - $pathItem->patch, - $pathItem->delete, - ]; - - foreach ($methods as $method) { - if (Generator::isDefault($method) || Generator::isDefault($method->operationId)) { - continue; - } - - if (null === $method->when) { + foreach ($this->operationsOf($pathItem) as $method) { + if (!property_exists($method, 'when') || null === $method->when) { continue; } @@ -68,16 +48,12 @@ public function __invoke(Analysis $analysis): void throw new ResourceNotFoundException(); } } catch (ResourceNotFoundException) { - $analysis->openapi->paths[$index]->{$method->method} = Generator::UNDEFINED; + $analysis->openapi->paths[$index]->{$method->method} = Undefined::UNDEFINED; $this->detachAnnotationRecursively($method, $analysis); } } - if (Generator::isDefault($pathItem->post) - && Generator::isDefault($pathItem->get) - && Generator::isDefault($pathItem->put) - && Generator::isDefault($pathItem->patch) - && Generator::isDefault($pathItem->delete)) { + if ([] === $this->operationsOf($pathItem)) { unset($analysis->openapi->paths[$index]); $this->detachAnnotationRecursively($pathItem, $analysis); } diff --git a/src/OpenApi/Processor/ProcessorInterface.php b/src/OpenApi/Processor/ProcessorInterface.php new file mode 100644 index 0000000..89b4689 --- /dev/null +++ b/src/OpenApi/Processor/ProcessorInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\OpenApi\Processor; + +use OpenApi\Analysis; + +/** + * A step of the OpenAPI processor pipeline. + * + * Services implementing this interface are tagged "openapi.processor" automatically. + * Use the tag "priority" attribute to place the processor in the pipeline. + */ +interface ProcessorInterface +{ + public function __invoke(Analysis $analysis): void; +} diff --git a/src/OpenApi/Processor/ProcessorTrait.php b/src/OpenApi/Processor/ProcessorTrait.php index 6cb3b0e..2099902 100644 --- a/src/OpenApi/Processor/ProcessorTrait.php +++ b/src/OpenApi/Processor/ProcessorTrait.php @@ -15,13 +15,16 @@ use OpenApi\Analysis; use OpenApi\Annotations\AbstractAnnotation; +use OpenApi\Annotations\Operation; +use OpenApi\Annotations\PathItem; +use OpenApi\Undefined; trait ProcessorTrait { protected function detachAnnotationRecursively(object|array $annotation, Analysis $analysis): void { if ($annotation instanceof AbstractAnnotation) { - $analysis->annotations->detach($annotation); + $analysis->annotations->offsetUnset($annotation); } foreach ($annotation as $field) { @@ -30,4 +33,55 @@ protected function detachAnnotationRecursively(object|array $annotation, Analysi } } } + + /** + * Collects every "$ref" reachable from the given annotation. + * + * @param \SplObjectStorage|null $visited + * + * @return array + */ + protected function collectRefs(AbstractAnnotation $annotation, ?\SplObjectStorage $visited = null): array + { + $visited ??= new \SplObjectStorage(); + + if ($visited->offsetExists($annotation)) { + return []; + } + $visited->offsetSet($annotation); + + $refs = []; + if (property_exists($annotation, 'ref') && \is_string($annotation->ref) && !Undefined::isDefault($annotation->ref)) { + $refs[$annotation->ref] = true; + } + + foreach (get_object_vars($annotation) as $property => $value) { + if (\in_array($property, $annotation::$_blacklist, true)) { + continue; + } + + foreach (\is_array($value) ? $value : [$value] as $item) { + if ($item instanceof AbstractAnnotation) { + $refs += $this->collectRefs($item, $visited); + } + } + } + + return $refs; + } + + /** + * @return array the defined operations of the path, indexed by method + */ + protected function operationsOf(PathItem $pathItem): array + { + $operations = []; + foreach (['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as $method) { + if ($pathItem->{$method} instanceof Operation) { + $operations[$method] = $pathItem->{$method}; + } + } + + return $operations; + } } diff --git a/src/OpenApi/Processor/RemoveScannedQueryParameters.php b/src/OpenApi/Processor/RemoveScannedQueryParameters.php new file mode 100644 index 0000000..4236374 --- /dev/null +++ b/src/OpenApi/Processor/RemoveScannedQueryParameters.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\OpenApi\Processor; + +use OpenApi\Analysis; +use OpenApi\Annotations as OA; + +/** + * Removes the query parameters the scanner picks up from the properties of a query DTO. + * + * They are not reusable components: the operation guessers create them again from the + * #[MapQueryString] argument and attach them to the operation. Without this removal, + * MergeIntoComponents moves them into "components.parameters". + */ +readonly class RemoveScannedQueryParameters implements ProcessorInterface +{ + public function __invoke(Analysis $analysis): void + { + foreach ($analysis->getAnnotationsOfType(OA\Parameter::class) as $annotation) { + if ('query' !== $annotation->in || $annotation->_context->nested) { + continue; + } + + if (($annotation->_context->reflector ?? null) instanceof \ReflectionProperty) { + $analysis->annotations->offsetUnset($annotation); + } + } + } +} diff --git a/src/OpenApiBundle.php b/src/OpenApiBundle.php index f7742e4..11cc130 100644 --- a/src/OpenApiBundle.php +++ b/src/OpenApiBundle.php @@ -16,6 +16,7 @@ use OpenSolid\OpenApiBundle\DependencyInjection\Compiler\SerializerMappingPass; use OpenSolid\OpenApiBundle\DependencyInjection\Compiler\TrackPathsPass; use OpenSolid\OpenApiBundle\DependencyInjection\Compiler\ValidatorMappingPass; +use OpenSolid\OpenApiBundle\OpenApi\Processor\ProcessorInterface; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -61,6 +62,9 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ; $container->import('../config/services.php'); + + $builder->registerForAutoconfiguration(ProcessorInterface::class) + ->addTag('openapi.processor'); } public function getPath(): string diff --git a/src/Routing/Attribute/ApiRouteInterface.php b/src/Routing/Attribute/ApiRouteInterface.php new file mode 100644 index 0000000..2f8bdb8 --- /dev/null +++ b/src/Routing/Attribute/ApiRouteInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\Routing\Attribute; + +use Symfony\Component\Routing\Attribute\Route; + +/** + * An attribute that defines both a Symfony route and an OpenAPI operation. + * + * @property Route $route + */ +interface ApiRouteInterface +{ + public function getMethod(): string; +} diff --git a/src/Routing/Attribute/ApiRouteTrait.php b/src/Routing/Attribute/ApiRouteTrait.php index 8165c1c..5e7a7cf 100644 --- a/src/Routing/Attribute/ApiRouteTrait.php +++ b/src/Routing/Attribute/ApiRouteTrait.php @@ -15,6 +15,7 @@ use OpenApi\Attributes\ExternalDocumentation; use OpenApi\Attributes\RequestBody; +use OpenApi\Undefined; use Symfony\Component\Routing\Attribute\Route; trait ApiRouteTrait @@ -54,25 +55,26 @@ public function __construct( // custom properties public ?string $itemsType = null, public ?string $when = null, + public ?int $statusCode = null, ) { - self::$_blacklist = array_unique(array_merge(self::$_blacklist, ['route', 'itemsType', 'when'])); + self::$_blacklist = array_unique(array_merge(self::$_blacklist, ['route', 'itemsType', 'when', 'statusCode'])); parent::__construct( - $path, - $name, - $description, - $summary, - $security, - $servers, - $requestBody, - $tags, - $parameters, - $responses, - $callbacks, - $externalDocs, - $deprecated, - $x, - $attachables, + path: $path, + operationId: $name, + description: $description ?? Undefined::UNDEFINED, + summary: $summary ?? Undefined::UNDEFINED, + security: $security, + servers: $servers, + requestBody: $requestBody, + tags: $tags, + parameters: $parameters, + responses: $responses, + callbacks: $callbacks, + externalDocs: $externalDocs, + deprecated: $deprecated, + x: $x, + attachables: $attachables, ); if ($condition && $when) { @@ -100,18 +102,13 @@ public function __construct( ); } - public function __call(string $name, array $arguments): mixed - { - return $this->route->{$name}(...$arguments); - } - public function __get(string $name): mixed { if (property_exists($this->route, $name)) { return $this->route->{$name}; } - parent::__get($name); + $this->_context->logger->warning(\sprintf('Property "%s" doesn\'t exist in a %s', $name, $this->identity())); return null; } diff --git a/src/Routing/Attribute/Delete.php b/src/Routing/Attribute/Delete.php index fb34981..342f022 100644 --- a/src/Routing/Attribute/Delete.php +++ b/src/Routing/Attribute/Delete.php @@ -14,7 +14,7 @@ namespace OpenSolid\OpenApiBundle\Routing\Attribute; #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] -class Delete extends \OpenApi\Attributes\Delete +class Delete extends \OpenApi\Attributes\Delete implements ApiRouteInterface { use ApiRouteTrait; diff --git a/src/Routing/Attribute/Get.php b/src/Routing/Attribute/Get.php index 160b345..c699a55 100644 --- a/src/Routing/Attribute/Get.php +++ b/src/Routing/Attribute/Get.php @@ -14,7 +14,7 @@ namespace OpenSolid\OpenApiBundle\Routing\Attribute; #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] -class Get extends \OpenApi\Attributes\Get +class Get extends \OpenApi\Attributes\Get implements ApiRouteInterface { use ApiRouteTrait; diff --git a/src/Routing/Attribute/Head.php b/src/Routing/Attribute/Head.php new file mode 100644 index 0000000..f821cb9 --- /dev/null +++ b/src/Routing/Attribute/Head.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\Routing\Attribute; + +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] +class Head extends \OpenApi\Attributes\Head implements ApiRouteInterface +{ + use ApiRouteTrait; + + public function getMethod(): string + { + return 'HEAD'; + } +} diff --git a/src/Routing/Attribute/Options.php b/src/Routing/Attribute/Options.php new file mode 100644 index 0000000..8ad5c69 --- /dev/null +++ b/src/Routing/Attribute/Options.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\OpenApiBundle\Routing\Attribute; + +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] +class Options extends \OpenApi\Attributes\Options implements ApiRouteInterface +{ + use ApiRouteTrait; + + public function getMethod(): string + { + return 'OPTIONS'; + } +} diff --git a/src/Routing/Attribute/Patch.php b/src/Routing/Attribute/Patch.php index 08d61e9..1c5f2b4 100644 --- a/src/Routing/Attribute/Patch.php +++ b/src/Routing/Attribute/Patch.php @@ -14,7 +14,7 @@ namespace OpenSolid\OpenApiBundle\Routing\Attribute; #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] -class Patch extends \OpenApi\Attributes\Patch +class Patch extends \OpenApi\Attributes\Patch implements ApiRouteInterface { use ApiRouteTrait; diff --git a/src/Routing/Attribute/Post.php b/src/Routing/Attribute/Post.php index 750912d..73bd2c8 100644 --- a/src/Routing/Attribute/Post.php +++ b/src/Routing/Attribute/Post.php @@ -14,7 +14,7 @@ namespace OpenSolid\OpenApiBundle\Routing\Attribute; #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] -class Post extends \OpenApi\Attributes\Post +class Post extends \OpenApi\Attributes\Post implements ApiRouteInterface { use ApiRouteTrait; diff --git a/src/Routing/Attribute/Put.php b/src/Routing/Attribute/Put.php index 805fcbb..746f730 100644 --- a/src/Routing/Attribute/Put.php +++ b/src/Routing/Attribute/Put.php @@ -14,7 +14,7 @@ namespace OpenSolid\OpenApiBundle\Routing\Attribute; #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] -class Put extends \OpenApi\Attributes\Put +class Put extends \OpenApi\Attributes\Put implements ApiRouteInterface { use ApiRouteTrait; diff --git a/src/Validator/Mapping/Loader/ValidationMetadataLoader.php b/src/Validator/Mapping/Loader/ValidationMetadataLoader.php index 57ce27f..d9098d2 100644 --- a/src/Validator/Mapping/Loader/ValidationMetadataLoader.php +++ b/src/Validator/Mapping/Loader/ValidationMetadataLoader.php @@ -13,7 +13,7 @@ namespace OpenSolid\OpenApiBundle\Validator\Mapping\Loader; -use OpenApi\Generator; +use OpenApi\Undefined; use OpenSolid\OpenApiBundle\Attribute\Property; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Mapping\ClassMetadata; @@ -25,51 +25,62 @@ public function load(ClassMetadata $metadata, \ReflectionProperty $reflectionPro $groups = $property->groups; $loaded = false; - if (!Generator::isDefault($property->minLength) || !Generator::isDefault($property->maxLength)) { + if (!Undefined::isDefault($property->minLength) || !Undefined::isDefault($property->maxLength)) { $metadata->addPropertyConstraint($reflectionProperty->name, new Assert\Length( - min: Generator::isDefault($property->minLength) ? null : $property->minLength, - max: Generator::isDefault($property->maxLength) ? null : $property->maxLength, + min: Undefined::isDefault($property->minLength) ? null : $property->minLength, + max: Undefined::isDefault($property->maxLength) ? null : $property->maxLength, groups: $groups, )); $loaded = true; } - if (!Generator::isDefault($property->minItems) || !Generator::isDefault($property->maxItems)) { + if (!Undefined::isDefault($property->minItems) || !Undefined::isDefault($property->maxItems)) { $metadata->addPropertyConstraint($reflectionProperty->name, new Assert\Count( - min: Generator::isDefault($property->minItems) ? null : $property->minItems, - max: Generator::isDefault($property->maxItems) ? null : $property->maxItems, + min: Undefined::isDefault($property->minItems) ? null : $property->minItems, + max: Undefined::isDefault($property->maxItems) ? null : $property->maxItems, groups: $groups, )); $loaded = true; } - if (!Generator::isDefault($property->minimum)) { - $constraint = Generator::isDefault($property->exclusiveMinimum) - ? new Assert\GreaterThanOrEqual(value: $property->minimum, groups: $groups) - : new Assert\GreaterThan(value: $property->minimum, groups: $groups); + // OpenAPI 3.0 flags "minimum" as exclusive, OpenAPI 3.1 gives the exclusive bound itself + if (\is_int($property->exclusiveMinimum) || \is_float($property->exclusiveMinimum)) { + $metadata->addPropertyConstraint($reflectionProperty->name, new Assert\GreaterThan(value: $property->exclusiveMinimum, groups: $groups)); + $loaded = true; + } + + if (!Undefined::isDefault($property->minimum)) { + $constraint = true === $property->exclusiveMinimum + ? new Assert\GreaterThan(value: $property->minimum, groups: $groups) + : new Assert\GreaterThanOrEqual(value: $property->minimum, groups: $groups); $metadata->addPropertyConstraint($reflectionProperty->name, $constraint); $loaded = true; } - if (!Generator::isDefault($property->maximum)) { - $constraint = Generator::isDefault($property->exclusiveMaximum) - ? new Assert\LessThanOrEqual(value: $property->maximum, groups: $groups) - : new Assert\LessThan(value: $property->maximum, groups: $groups); + if (\is_int($property->exclusiveMaximum) || \is_float($property->exclusiveMaximum)) { + $metadata->addPropertyConstraint($reflectionProperty->name, new Assert\LessThan(value: $property->exclusiveMaximum, groups: $groups)); + $loaded = true; + } + + if (!Undefined::isDefault($property->maximum)) { + $constraint = true === $property->exclusiveMaximum + ? new Assert\LessThan(value: $property->maximum, groups: $groups) + : new Assert\LessThanOrEqual(value: $property->maximum, groups: $groups); $metadata->addPropertyConstraint($reflectionProperty->name, $constraint); $loaded = true; } - if (!Generator::isDefault($property->pattern)) { + if (!Undefined::isDefault($property->pattern)) { $metadata->addPropertyConstraint($reflectionProperty->name, new Assert\Regex(pattern: $property->pattern, groups: $groups)); $loaded = true; } - if (!Generator::isDefault($property->uniqueItems)) { + if (!Undefined::isDefault($property->uniqueItems)) { $metadata->addPropertyConstraint($reflectionProperty->name, new Assert\Unique(groups: $groups)); $loaded = true; } - if (!Generator::isDefault($property->enum)) { + if (!Undefined::isDefault($property->enum)) { $enum = $property->enum; $choices = []; @@ -94,12 +105,12 @@ public function load(ClassMetadata $metadata, \ReflectionProperty $reflectionPro } } - if (!Generator::isDefault($property->multipleOf)) { + if (!Undefined::isDefault($property->multipleOf)) { $metadata->addPropertyConstraint($reflectionProperty->name, new Assert\DivisibleBy(value: $property->multipleOf, groups: $groups)); $loaded = true; } - if (!Generator::isDefault($property->const)) { + if (!Undefined::isDefault($property->const)) { $metadata->addPropertyConstraint($reflectionProperty->name, new Assert\EqualTo(value: $property->const, groups: $groups)); $loaded = true; } diff --git a/tests/Functional/AbstractWebTestCase.php b/tests/Functional/AbstractWebTestCase.php index c0c3f09..d6bcaeb 100644 --- a/tests/Functional/AbstractWebTestCase.php +++ b/tests/Functional/AbstractWebTestCase.php @@ -96,11 +96,30 @@ protected function getApplicationTester(): ApplicationTester return new ApplicationTester($application); } + /** + * Run the tests with UPDATE_SNAPSHOTS=1 to write the actual content to the snapshot files. + */ protected function assertSameFileResponseContent(string $content, string $filename, bool $save = false): void { - if ($save) { - file_put_contents(__DIR__.'/App/'.self::getTestCase().'/Output/'.$filename, $content); + $this->assertJsonStringEqualsJsonFile($this->snapshot($filename, $content, $save), $content); + } + + protected function assertSameFileContent(string $content, string $filename, bool $save = false): void + { + $this->assertStringEqualsFile($this->snapshot($filename, $content, $save), $content); + } + + private function snapshot(string $filename, string $content, bool $save): string + { + $path = __DIR__.'/App/'.self::getTestCase().'/Output/'.$filename; + + if ($save || getenv('UPDATE_SNAPSHOTS')) { + if (!is_dir(\dirname($path))) { + mkdir(\dirname($path), 0777, true); + } + file_put_contents($path, $content); } - $this->assertJsonStringEqualsJsonFile(__DIR__.'/App/'.self::getTestCase().'/Output/'.$filename, $content); + + return $path; } } diff --git a/tests/Functional/App/ConstraintSchema/Controller/CreateItemAction.php b/tests/Functional/App/ConstraintSchema/Controller/CreateItemAction.php new file mode 100644 index 0000000..5bc9fc9 --- /dev/null +++ b/tests/Functional/App/ConstraintSchema/Controller/CreateItemAction.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\ConstraintSchema\Controller; + +use OpenSolid\OpenApiBundle\Routing\Attribute\Post; +use OpenSolid\Tests\OpenApiBundle\Functional\App\ConstraintSchema\Model\CreateItemPayload; +use OpenSolid\Tests\OpenApiBundle\Functional\App\ConstraintSchema\Model\ItemView; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; + +class CreateItemAction +{ + #[Post('/items')] + public function __invoke(#[MapRequestPayload] CreateItemPayload $payload): ItemView + { + return new ItemView($payload->name, $payload->size); + } +} diff --git a/tests/Functional/App/ConstraintSchema/Model/Color.php b/tests/Functional/App/ConstraintSchema/Model/Color.php new file mode 100644 index 0000000..d121ba2 --- /dev/null +++ b/tests/Functional/App/ConstraintSchema/Model/Color.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\ConstraintSchema\Model; + +enum Color +{ + case Red; + case Green; +} diff --git a/tests/Functional/App/ConstraintSchema/Model/CreateItemPayload.php b/tests/Functional/App/ConstraintSchema/Model/CreateItemPayload.php new file mode 100644 index 0000000..88f35a0 --- /dev/null +++ b/tests/Functional/App/ConstraintSchema/Model/CreateItemPayload.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\ConstraintSchema\Model; + +use OpenApi\Attributes as OA; +use Symfony\Component\Validator\Constraints as Assert; + +#[OA\Schema] +class CreateItemPayload +{ + public function __construct( + #[Assert\NotBlank] + #[Assert\Length(min: 3, max: 50)] + #[OA\Property] + public string $name, + #[OA\Property] + public Size $size, + #[OA\Property] + public string $currency = 'USD', + #[OA\Property] + public ?Color $color = null, + ) { + } + + #[Assert\NotBlank] + #[OA\Property] + public ?string $code = null; + + #[Assert\NotNull] + #[OA\Property] + public ?int $quantity = null; + + #[Assert\NotBlank(allowNull: true)] + #[OA\Property] + public ?string $optionalNotBlank = null; + + #[Assert\NotBlank] + #[OA\Property] + public ?int $notBlankInteger = null; + + #[Assert\Email] + #[OA\Property] + public ?string $email = null; + + #[Assert\Email] + #[OA\Property(format: 'idn-email')] + public ?string $explicitFormat = null; + + #[Assert\Uuid] + #[OA\Property] + public ?string $uuid = null; + + #[Assert\Ulid] + #[OA\Property] + public ?string $ulid = null; + + #[Assert\Url(requireTld: true)] + #[OA\Property] + public ?string $url = null; + + #[Assert\Hostname] + #[OA\Property] + public ?string $hostname = null; + + #[Assert\Ip] + #[OA\Property] + public ?string $ipv4 = null; + + #[Assert\Ip(version: Assert\Ip::V6)] + #[OA\Property] + public ?string $ipv6 = null; + + #[Assert\Date] + #[OA\Property] + public ?string $date = null; + + #[Assert\DateTime] + #[OA\Property] + public ?string $dateTime = null; + + #[Assert\Time] + #[OA\Property] + public ?string $time = null; + + #[Assert\Range(min: 1, max: 10)] + #[OA\Property] + public ?int $range = null; + + #[Assert\Positive] + #[OA\Property] + public ?int $positive = null; + + #[Assert\PositiveOrZero] + #[OA\Property] + public ?int $positiveOrZero = null; + + #[Assert\Negative] + #[OA\Property] + public ?int $negative = null; + + #[Assert\NegativeOrZero] + #[OA\Property] + public ?int $negativeOrZero = null; + + #[Assert\GreaterThan('today')] + #[OA\Property] + public ?string $notNumericComparison = null; + + #[Assert\Regex('/^[a-z]+$/')] + #[OA\Property] + public ?string $slug = null; + + #[Assert\Regex(pattern: '/^[0-9]+$/', match: false)] + #[OA\Property] + public ?string $notDigits = null; + + #[Assert\Choice(choices: ['draft', 'published'])] + #[OA\Property] + public ?string $status = null; + + #[Assert\Choice(choices: ['a', 'b'], multiple: true)] + #[OA\Property] + public array $multiple = []; + + /** + * @var list + */ + #[Assert\Count(min: 1, max: 3)] + #[Assert\All([new Assert\NotBlank(), new Assert\Length(max: 5)])] + #[OA\Property] + public array $tags = []; + + #[Assert\All([new Assert\Positive()])] + #[OA\Property] + public array $untypedItems = []; + + /** + * @var list + */ + #[OA\Property] + public array $sizes = []; + + #[OA\Property] + public ?Size $optionalSize = null; +} diff --git a/tests/Functional/App/ConstraintSchema/Model/ItemView.php b/tests/Functional/App/ConstraintSchema/Model/ItemView.php new file mode 100644 index 0000000..8f3f5a5 --- /dev/null +++ b/tests/Functional/App/ConstraintSchema/Model/ItemView.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\ConstraintSchema\Model; + +use OpenApi\Attributes as OA; + +#[OA\Schema] +readonly class ItemView +{ + public function __construct( + #[OA\Property] + public string $name, + #[OA\Property] + public Size $size, + #[OA\Property] + public ?string $note = null, + ) { + } +} diff --git a/tests/Functional/App/ConstraintSchema/Model/Size.php b/tests/Functional/App/ConstraintSchema/Model/Size.php new file mode 100644 index 0000000..478f320 --- /dev/null +++ b/tests/Functional/App/ConstraintSchema/Model/Size.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\ConstraintSchema\Model; + +enum Size: int +{ + case Small = 1; + case Large = 2; +} diff --git a/tests/Functional/App/ConstraintSchema/Output/doc.json b/tests/Functional/App/ConstraintSchema/Output/doc.json new file mode 100644 index 0000000..6d8e18f --- /dev/null +++ b/tests/Functional/App/ConstraintSchema/Output/doc.json @@ -0,0 +1,414 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "API Documentation", + "version": "1.0.0" + }, + "servers": [ + { + "url": "/" + } + ], + "paths": { + "/items": { + "post": { + "operationId": "01635fa281c70533bc8e7685a27c386d", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateItemPayload" + } + } + } + }, + "responses": { + "201": { + "description": "Successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemView" + } + } + } + }, + "400": { + "$ref": "#/components/responses/400" + }, + "422": { + "$ref": "#/components/responses/422" + } + } + } + } + }, + "components": { + "schemas": { + "CreateItemPayload": { + "required": [ + "name", + "size", + "code", + "quantity", + "notBlankInteger" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 50, + "minLength": 3 + }, + "size": { + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "currency": { + "type": "string", + "default": "USD" + }, + "color": { + "type": [ + "string", + "null" + ], + "enum": [ + "Red", + "Green", + null + ] + }, + "code": { + "type": "string", + "minLength": 1 + }, + "quantity": { + "type": "integer" + }, + "optionalNotBlank": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "notBlankInteger": { + "type": "integer" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "explicitFormat": { + "type": [ + "string", + "null" + ], + "format": "idn-email" + }, + "uuid": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "ulid": { + "type": [ + "string", + "null" + ], + "format": "ulid" + }, + "url": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "hostname": { + "type": [ + "string", + "null" + ], + "format": "hostname" + }, + "ipv4": { + "type": [ + "string", + "null" + ], + "format": "ipv4" + }, + "ipv6": { + "type": [ + "string", + "null" + ], + "format": "ipv6" + }, + "date": { + "type": [ + "string", + "null" + ], + "format": "date" + }, + "dateTime": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "time": { + "type": [ + "string", + "null" + ], + "format": "time" + }, + "range": { + "type": [ + "integer", + "null" + ], + "maximum": 10, + "minimum": 1 + }, + "positive": { + "type": [ + "integer", + "null" + ], + "exclusiveMinimum": 0 + }, + "positiveOrZero": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "negative": { + "type": [ + "integer", + "null" + ], + "exclusiveMaximum": 0 + }, + "negativeOrZero": { + "type": [ + "integer", + "null" + ], + "maximum": 0 + }, + "notNumericComparison": { + "type": [ + "string", + "null" + ] + }, + "slug": { + "type": [ + "string", + "null" + ], + "pattern": "^[a-z]+$" + }, + "notDigits": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ], + "enum": [ + "draft", + "published" + ] + }, + "multiple": { + "type": "array", + "items": {}, + "default": [] + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 5 + }, + "default": [], + "maxItems": 3, + "minItems": 1 + }, + "untypedItems": { + "type": "array", + "items": { + "exclusiveMinimum": 0 + }, + "default": [] + }, + "sizes": { + "type": "array", + "items": { + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "default": [] + }, + "optionalSize": { + "type": [ + "integer", + "null" + ], + "enum": [ + 1, + 2, + null + ] + } + }, + "type": "object" + }, + "ItemView": { + "required": [ + "name", + "size" + ], + "properties": { + "name": { + "type": "string" + }, + "size": { + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "note": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object", + "readOnly": true + }, + "BadRequestErrorView": { + "properties": { + "type": { + "type": "string", + "default": "https://tools.ietf.org/html/rfc2616#section-10" + }, + "title": { + "type": "string", + "default": "Bad Request" + }, + "status": { + "type": "integer", + "default": 400 + }, + "detail": { + "type": "string" + } + }, + "type": "object", + "readOnly": true + }, + "ValidationErrorView": { + "properties": { + "type": { + "type": "string", + "default": "https://symfony.com/errors/validation" + }, + "title": { + "type": "string", + "default": "Validation Failed" + }, + "status": { + "type": "integer", + "default": 422 + }, + "detail": { + "type": "string" + }, + "violations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationViolationErrorView" + } + } + }, + "type": "object", + "readOnly": true + }, + "ValidationViolationErrorView": { + "properties": { + "propertyPath": { + "type": "string" + }, + "title": { + "type": "string" + }, + "template": { + "type": "string" + }, + "parameters": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "type": { + "type": "string", + "example": "urn:uuid:9ff3fdc4-b214-49db-8718-39c315e33d45" + } + }, + "type": "object", + "readOnly": true + } + }, + "responses": { + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestErrorView" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorView" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/Functional/App/ConstraintSchema/Output/response.json b/tests/Functional/App/ConstraintSchema/Output/response.json new file mode 100644 index 0000000..4b767bf --- /dev/null +++ b/tests/Functional/App/ConstraintSchema/Output/response.json @@ -0,0 +1 @@ +{"name":"foo","size":1,"note":null} \ No newline at end of file diff --git a/tests/Functional/App/ConstraintSchema/Output/validation_error.json b/tests/Functional/App/ConstraintSchema/Output/validation_error.json new file mode 100644 index 0000000..ced5982 --- /dev/null +++ b/tests/Functional/App/ConstraintSchema/Output/validation_error.json @@ -0,0 +1 @@ +{"type":"https:\/\/symfony.com\/errors\/validation","title":"Validation Failed","status":422,"detail":"name: This value is too short. It should have 3 characters or more.\ncode: This value should not be blank.\nquantity: This value should not be null.\ntags: This collection should contain 1 element or more.","violations":[{"propertyPath":"name","title":"This value is too short. It should have 3 characters or more.","template":"This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more.","parameters":{"{{ max }}":"50","{{ value }}":"\"fo\"","{{ limit }}":"3","{{ min }}":"3","{{ value_length }}":"2"},"type":"urn:uuid:9ff3fdc4-b214-49db-8718-39c315e33d45"},{"propertyPath":"code","title":"This value should not be blank.","template":"This value should not be blank.","parameters":{"{{ value }}":"\"\""},"type":"urn:uuid:c1051bb4-d103-4f74-8988-acbcafc7fdc3"},{"propertyPath":"quantity","title":"This value should not be null.","template":"This value should not be null.","parameters":{"{{ value }}":"null"},"type":"urn:uuid:ad32d13f-c3d4-423b-909a-857b961eb720"},{"propertyPath":"tags","title":"This collection should contain 1 element or more.","template":"This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more.","parameters":{"{{ count }}":"0","{{ limit }}":"1"},"type":"urn:uuid:bef8e338-6ae5-4caf-b8e2-50e7b0579e69"}]} \ No newline at end of file diff --git a/tests/Functional/App/ConstraintSchema/routes.yaml b/tests/Functional/App/ConstraintSchema/routes.yaml new file mode 100644 index 0000000..9be3af5 --- /dev/null +++ b/tests/Functional/App/ConstraintSchema/routes.yaml @@ -0,0 +1,8 @@ +controllers: + resource: + path: ./Controller/ + namespace: OpenSolid\Tests\OpenApiBundle\Functional\App\ConstraintSchema\Controller + type: attribute + +openapi: + resource: '@OpenApiBundle/config/routes.php' diff --git a/tests/Functional/App/CustomResponseSpec/Output/doc.json b/tests/Functional/App/CustomResponseSpec/Output/doc.json index d6928b2..b2bfda4 100644 --- a/tests/Functional/App/CustomResponseSpec/Output/doc.json +++ b/tests/Functional/App/CustomResponseSpec/Output/doc.json @@ -149,4 +149,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/Functional/App/DeleteResourceAction/Output/doc.json b/tests/Functional/App/DeleteResourceAction/Output/doc.json index f0100a9..7d696b6 100644 --- a/tests/Functional/App/DeleteResourceAction/Output/doc.json +++ b/tests/Functional/App/DeleteResourceAction/Output/doc.json @@ -19,9 +19,9 @@ "in": "path", "required": true, "schema": { - "type": "string" - }, - "format": "uuid" + "type": "string", + "format": "uuid" + } } ], "responses": { diff --git a/tests/Functional/App/ExportOpenApiCommand/Output/doc.json b/tests/Functional/App/ExportOpenApiCommand/Output/doc.json index 8b3fc73..875ebf2 100644 --- a/tests/Functional/App/ExportOpenApiCommand/Output/doc.json +++ b/tests/Functional/App/ExportOpenApiCommand/Output/doc.json @@ -19,9 +19,9 @@ "in": "path", "required": true, "schema": { - "type": "string" - }, - "format": "uuid" + "type": "string", + "format": "uuid" + } } ], "responses": { diff --git a/tests/Functional/App/ExportOpenApiCommand/Output/doc.yaml b/tests/Functional/App/ExportOpenApiCommand/Output/doc.yaml index f4e290a..4a8f8d6 100644 --- a/tests/Functional/App/ExportOpenApiCommand/Output/doc.yaml +++ b/tests/Functional/App/ExportOpenApiCommand/Output/doc.yaml @@ -16,7 +16,7 @@ paths: required: true schema: type: string - format: uuid + format: uuid responses: '204': description: Successful @@ -68,4 +68,4 @@ components: content: application/json: schema: - $ref: '#/components/schemas/NotFoundErrorView' \ No newline at end of file + $ref: '#/components/schemas/NotFoundErrorView' diff --git a/tests/Functional/App/GetResourceAction/Output/doc.json b/tests/Functional/App/GetResourceAction/Output/doc.json index e64dae2..750fbbe 100644 --- a/tests/Functional/App/GetResourceAction/Output/doc.json +++ b/tests/Functional/App/GetResourceAction/Output/doc.json @@ -19,9 +19,9 @@ "in": "path", "required": true, "schema": { - "type": "string" - }, - "format": "uuid" + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -48,6 +48,10 @@ "components": { "schemas": { "ResourceView": { + "required": [ + "id", + "name" + ], "properties": { "id": { "type": "string", diff --git a/tests/Functional/App/GetResourceCustomAttributesAction/Output/doc.json b/tests/Functional/App/GetResourceCustomAttributesAction/Output/doc.json index 237b14f..6426b0d 100644 --- a/tests/Functional/App/GetResourceCustomAttributesAction/Output/doc.json +++ b/tests/Functional/App/GetResourceCustomAttributesAction/Output/doc.json @@ -19,9 +19,9 @@ "in": "path", "required": true, "schema": { - "type": "string" - }, - "format": "uuid" + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -48,6 +48,10 @@ "components": { "schemas": { "ResourceView": { + "required": [ + "id", + "name" + ], "properties": { "id": { "type": "string", diff --git a/tests/Functional/App/GetResourcesAction/Output/doc.json b/tests/Functional/App/GetResourcesAction/Output/doc.json index b2bcb9c..cc3d4da 100644 --- a/tests/Functional/App/GetResourcesAction/Output/doc.json +++ b/tests/Functional/App/GetResourcesAction/Output/doc.json @@ -15,26 +15,38 @@ "operationId": "54dc66cace0345df01577de4f43a2910", "parameters": [ { - "parameter": "sort", "name": "sort", - "in": "query" + "in": "query", + "required": false, + "schema": { + "type": "string" + } }, { - "parameter": "page[offset]", "name": "page[offset]", "in": "query", - "example": 0 + "required": false, + "schema": { + "type": "integer", + "default": 0 + } }, { - "parameter": "page[limit]", "name": "page[limit]", "in": "query", - "example": 10 + "required": false, + "schema": { + "type": "integer", + "default": 10 + } }, { - "parameter": "filter[name]", "name": "filter[name]", - "in": "query" + "in": "query", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -61,6 +73,10 @@ "components": { "schemas": { "ResourceView": { + "required": [ + "id", + "name" + ], "properties": { "id": { "type": "string", diff --git a/tests/Functional/App/ImportRoutes/Output/schema.json b/tests/Functional/App/ImportRoutes/Output/schema.json index 31f815b..b3f889d 100644 --- a/tests/Functional/App/ImportRoutes/Output/schema.json +++ b/tests/Functional/App/ImportRoutes/Output/schema.json @@ -1 +1 @@ -{"$schema":"https:\/\/json-schema.org\/draft\/2020-12\/schema","$id":"http:\/\/localhost\/schema\/ResourceView","properties":{"id":{"type":"string","format":"uuid","readOnly":true},"name":{"type":"string","readOnly":true}},"type":"object","readOnly":true} \ No newline at end of file +{"$schema":"https:\/\/json-schema.org\/draft\/2020-12\/schema","$id":"http:\/\/localhost\/schema\/ResourceView","required":["id","name"],"properties":{"id":{"type":"string","format":"uuid","readOnly":true},"name":{"type":"string","readOnly":true}},"type":"object","readOnly":true} \ No newline at end of file diff --git a/tests/Functional/App/NativeQuery/Controller/FindItemsAction.php b/tests/Functional/App/NativeQuery/Controller/FindItemsAction.php new file mode 100644 index 0000000..92bb3a6 --- /dev/null +++ b/tests/Functional/App/NativeQuery/Controller/FindItemsAction.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\NativeQuery\Controller; + +use OpenApi\Attributes as OA; +use OpenSolid\OpenApiBundle\Attribute\Path; +use OpenSolid\OpenApiBundle\Routing\Attribute\Get; +use OpenSolid\Tests\OpenApiBundle\Functional\App\NativeQuery\Model\ItemFilter; +use OpenSolid\Tests\OpenApiBundle\Functional\App\NativeQuery\Model\ItemView; +use OpenSolid\Tests\OpenApiBundle\Functional\App\NativeQuery\Model\Status; +use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; +use Symfony\Component\HttpKernel\Attribute\MapQueryString; + +class FindItemsAction +{ + /** + * @param int $page The page number + * @param Status|null $status Filter by status + * + * @return list + */ + #[Get('/stores/{store}/items')] + public function __invoke( + #[Path] string $store, + #[MapQueryString(key: 'filter')] ?ItemFilter $filter = null, + #[MapQueryParameter] int $page = 1, + #[MapQueryParameter] ?Status $status = null, + #[MapQueryParameter(name: 'q')] ?string $search = null, + #[MapQueryParameter, OA\QueryParameter(description: 'Explicit description')] ?string $sort = null, + #[MapQueryParameter(name: 'filter[name]')] ?string $duplicated = null, + ): array { + if ('empty' === $search) { + return []; + } + + return [new ItemView($store, $filter?->name ?? 'none', $page, $status)]; + } +} diff --git a/tests/Functional/App/NativeQuery/Model/ItemFilter.php b/tests/Functional/App/NativeQuery/Model/ItemFilter.php new file mode 100644 index 0000000..d42dbac --- /dev/null +++ b/tests/Functional/App/NativeQuery/Model/ItemFilter.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\NativeQuery\Model; + +use OpenApi\Attributes as OA; +use Symfony\Component\Validator\Constraints as Assert; + +class ItemFilter +{ + /** + * Filter by name. + */ + #[Assert\Length(max: 20)] + #[OA\QueryParameter] + public ?string $name = null; + + /** + * @var list Filter by tags + */ + #[OA\QueryParameter] + public array $tags = []; + + #[OA\QueryParameter] + public ?Kind $kind = null; + + #[OA\QueryParameter(schema: new OA\Schema(enum: Status::class))] + public ?string $status = null; + + #[Assert\NotBlank] + #[OA\QueryParameter(required: true)] + public string $currency = 'USD'; + + #[OA\QueryParameter] + public ?\DateTimeImmutable $since = null; + + #[OA\QueryParameter(name: 'min_price')] + public ?float $minPrice = null; + + public ?int $ignored = null; +} diff --git a/tests/Functional/App/NativeQuery/Model/ItemView.php b/tests/Functional/App/NativeQuery/Model/ItemView.php new file mode 100644 index 0000000..0d24d4c --- /dev/null +++ b/tests/Functional/App/NativeQuery/Model/ItemView.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\NativeQuery\Model; + +use OpenApi\Attributes as OA; + +#[OA\Schema] +readonly class ItemView +{ + public function __construct( + #[OA\Property] + public string $store, + #[OA\Property] + public string $name, + #[OA\Property] + public int $page, + #[OA\Property] + public ?Status $status = null, + ) { + } +} diff --git a/tests/Functional/App/NativeQuery/Model/Kind.php b/tests/Functional/App/NativeQuery/Model/Kind.php new file mode 100644 index 0000000..de2dbd6 --- /dev/null +++ b/tests/Functional/App/NativeQuery/Model/Kind.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\NativeQuery\Model; + +enum Kind +{ + case Physical; + case Digital; +} diff --git a/tests/Functional/App/NativeQuery/Model/Status.php b/tests/Functional/App/NativeQuery/Model/Status.php new file mode 100644 index 0000000..2a56e0e --- /dev/null +++ b/tests/Functional/App/NativeQuery/Model/Status.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\NativeQuery\Model; + +enum Status: string +{ + case Active = 'active'; + case Archived = 'archived'; +} diff --git a/tests/Functional/App/NativeQuery/Output/doc.json b/tests/Functional/App/NativeQuery/Output/doc.json new file mode 100644 index 0000000..ad3a01d --- /dev/null +++ b/tests/Functional/App/NativeQuery/Output/doc.json @@ -0,0 +1,232 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "API Documentation", + "version": "1.0.0" + }, + "servers": [ + { + "url": "/" + } + ], + "paths": { + "/stores/{store}/items": { + "get": { + "operationId": "42758c239168d79097123ff861a6d770", + "parameters": [ + { + "name": "store", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "description": "Explicit description", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "filter[name]", + "in": "query", + "description": "Filter by name.", + "required": false, + "schema": { + "type": "string", + "maxLength": 20 + } + }, + { + "name": "filter[tags]", + "in": "query", + "description": "Filter by tags", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + { + "name": "filter[kind]", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "Physical", + "Digital" + ] + } + }, + { + "name": "filter[status]", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "active", + "archived" + ] + } + }, + { + "name": "filter[currency]", + "in": "query", + "required": true, + "schema": { + "type": "string", + "default": "USD", + "minLength": 1 + } + }, + { + "name": "filter[since]", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "min_price", + "in": "query", + "required": false, + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "page", + "in": "query", + "description": "The page number", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "status", + "in": "query", + "description": "Filter by status", + "required": false, + "schema": { + "type": "string", + "enum": [ + "active", + "archived" + ] + } + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemView" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/400" + } + } + } + } + }, + "components": { + "schemas": { + "ItemView": { + "required": [ + "store", + "name", + "page" + ], + "properties": { + "store": { + "type": "string" + }, + "name": { + "type": "string" + }, + "page": { + "type": "integer" + }, + "status": { + "type": [ + "string", + "null" + ], + "enum": [ + "active", + "archived", + null + ] + } + }, + "type": "object", + "readOnly": true + }, + "BadRequestErrorView": { + "properties": { + "type": { + "type": "string", + "default": "https://tools.ietf.org/html/rfc2616#section-10" + }, + "title": { + "type": "string", + "default": "Bad Request" + }, + "status": { + "type": "integer", + "default": 400 + }, + "detail": { + "type": "string" + } + }, + "type": "object", + "readOnly": true + } + }, + "responses": { + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestErrorView" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/Functional/App/NativeQuery/routes.yaml b/tests/Functional/App/NativeQuery/routes.yaml new file mode 100644 index 0000000..5784963 --- /dev/null +++ b/tests/Functional/App/NativeQuery/routes.yaml @@ -0,0 +1,8 @@ +controllers: + resource: + path: ./Controller/ + namespace: OpenSolid\Tests\OpenApiBundle\Functional\App\NativeQuery\Controller + type: attribute + +openapi: + resource: '@OpenApiBundle/config/routes.php' diff --git a/tests/Functional/App/PatchResourceAction/Output/doc.json b/tests/Functional/App/PatchResourceAction/Output/doc.json index 77b971b..79d2dce 100644 --- a/tests/Functional/App/PatchResourceAction/Output/doc.json +++ b/tests/Functional/App/PatchResourceAction/Output/doc.json @@ -62,6 +62,10 @@ "writeOnly": true }, "ResourceView": { + "required": [ + "id", + "name" + ], "properties": { "id": { "type": "string", @@ -204,4 +208,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/Functional/App/PostResourceAction/Output/doc.json b/tests/Functional/App/PostResourceAction/Output/doc.json index c89da51..c3009ff 100644 --- a/tests/Functional/App/PostResourceAction/Output/doc.json +++ b/tests/Functional/App/PostResourceAction/Output/doc.json @@ -68,6 +68,10 @@ "writeOnly": true }, "ResourceView": { + "required": [ + "id", + "name" + ], "properties": { "id": { "type": "string", @@ -179,4 +183,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/Functional/App/PostResourceAction/Output/schema.json b/tests/Functional/App/PostResourceAction/Output/schema.json index 6a59fa2..03b08b1 100644 --- a/tests/Functional/App/PostResourceAction/Output/schema.json +++ b/tests/Functional/App/PostResourceAction/Output/schema.json @@ -1 +1 @@ -{"$schema":"https:\/\/json-schema.org\/draft\/2020-12\/schema","$id":"http:\/\/localhost\/schema\/PostResourcePayload","required":["name","status"],"properties":{"name":{"type":"string","minLength":3},"status":{"type":"string","enum":["draft","published"]}},"type":"object","writeOnly":true} +{"$schema":"https:\/\/json-schema.org\/draft\/2020-12\/schema","$id":"http:\/\/localhost\/schema\/PostResourcePayload","required":["name","status"],"properties":{"name":{"type":"string","minLength":3},"status":{"type":"string","enum":["draft","published"]}},"type":"object","writeOnly":true} \ No newline at end of file diff --git a/tests/Functional/App/PostResourcesAction/Output/doc.json b/tests/Functional/App/PostResourcesAction/Output/doc.json index 273c250..9d2e19c 100644 --- a/tests/Functional/App/PostResourcesAction/Output/doc.json +++ b/tests/Functional/App/PostResourcesAction/Output/doc.json @@ -71,6 +71,10 @@ "writeOnly": true }, "ResourceView": { + "required": [ + "id", + "name" + ], "properties": { "id": { "type": "string", @@ -182,4 +186,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/Functional/App/PostResourcesAction/Output/schema.json b/tests/Functional/App/PostResourcesAction/Output/schema.json index 6a59fa2..03b08b1 100644 --- a/tests/Functional/App/PostResourcesAction/Output/schema.json +++ b/tests/Functional/App/PostResourcesAction/Output/schema.json @@ -1 +1 @@ -{"$schema":"https:\/\/json-schema.org\/draft\/2020-12\/schema","$id":"http:\/\/localhost\/schema\/PostResourcePayload","required":["name","status"],"properties":{"name":{"type":"string","minLength":3},"status":{"type":"string","enum":["draft","published"]}},"type":"object","writeOnly":true} +{"$schema":"https:\/\/json-schema.org\/draft\/2020-12\/schema","$id":"http:\/\/localhost\/schema\/PostResourcePayload","required":["name","status"],"properties":{"name":{"type":"string","minLength":3},"status":{"type":"string","enum":["draft","published"]}},"type":"object","writeOnly":true} \ No newline at end of file diff --git a/tests/Functional/App/PutResourceAction/Output/doc.json b/tests/Functional/App/PutResourceAction/Output/doc.json index bc57034..8ea7a85 100644 --- a/tests/Functional/App/PutResourceAction/Output/doc.json +++ b/tests/Functional/App/PutResourceAction/Output/doc.json @@ -62,6 +62,10 @@ "writeOnly": true }, "ResourceView": { + "required": [ + "id", + "name" + ], "properties": { "id": { "type": "string", @@ -204,4 +208,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/Functional/App/ResponseInference/Controller/ItemsController.php b/tests/Functional/App/ResponseInference/Controller/ItemsController.php new file mode 100644 index 0000000..2dca01a --- /dev/null +++ b/tests/Functional/App/ResponseInference/Controller/ItemsController.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\ResponseInference\Controller; + +use OpenApi\Attributes as OA; +use OpenSolid\OpenApiBundle\Attribute\Path; +use OpenSolid\OpenApiBundle\Routing\Attribute\Delete; +use OpenSolid\OpenApiBundle\Routing\Attribute\Get; +use OpenSolid\OpenApiBundle\Routing\Attribute\Head; +use OpenSolid\OpenApiBundle\Routing\Attribute\Options; +use OpenSolid\OpenApiBundle\Routing\Attribute\Patch; +use OpenSolid\OpenApiBundle\Routing\Attribute\Post; +use OpenSolid\OpenApiBundle\Routing\Attribute\Put; +use OpenSolid\Tests\OpenApiBundle\Functional\App\ResponseInference\Model\ItemPayload; +use OpenSolid\Tests\OpenApiBundle\Functional\App\ResponseInference\Model\ItemView; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; + +#[OA\SecurityScheme(securityScheme: 'bearer', type: 'http', scheme: 'bearer')] +class ItemsController +{ + #[Post( + path: '/items', + security: [['bearer' => []]], + responses: [ + new OA\Response(response: 201, description: 'The created item'), + new OA\Response(response: 409, description: 'The item already exists'), + ], + )] + public function create(#[MapRequestPayload] ItemPayload $payload): ItemView + { + return new ItemView($payload->name); + } + + #[Put( + path: '/items/{id}', + responses: [ + new OA\Response(response: 200, description: 'Custom body', content: new OA\JsonContent(type: 'object')), + ], + )] + public function replace(#[Path] string $id, #[MapRequestPayload] ItemPayload $payload): ItemView + { + return new ItemView($payload->name); + } + + #[Delete(path: '/items/{id}', statusCode: 202)] + public function delete(#[Path] string $id): void + { + } + + #[Head(path: '/items/{id}')] + public function exists(#[Path] string $id): ItemView + { + return new ItemView($id); + } + + #[Options(path: '/items')] + public function options(): Response + { + return new Response(headers: ['Allow' => 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS']); + } + + /** + * @return list + */ + #[Put(path: '/items')] + public function replaceAll(#[MapRequestPayload(type: ItemPayload::class)] array $items): array + { + return array_map(static fn (ItemPayload $item) => new ItemView($item->name), $items); + } + + /** + * @param list $items + */ + #[Patch(path: '/items')] + public function patchAll(#[MapRequestPayload] array $items): void + { + } + + /** + * @return list + */ + #[Get(path: '/items/ids', security: [])] + public function ids(): array + { + return [1, 2]; + } +} diff --git a/tests/Functional/App/ResponseInference/Model/ItemPayload.php b/tests/Functional/App/ResponseInference/Model/ItemPayload.php new file mode 100644 index 0000000..527b68e --- /dev/null +++ b/tests/Functional/App/ResponseInference/Model/ItemPayload.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\ResponseInference\Model; + +use OpenApi\Attributes as OA; +use Symfony\Component\Validator\Constraints as Assert; + +#[OA\Schema] +class ItemPayload +{ + #[Assert\NotBlank] + #[OA\Property] + public string $name; +} diff --git a/tests/Functional/App/ResponseInference/Model/ItemView.php b/tests/Functional/App/ResponseInference/Model/ItemView.php new file mode 100644 index 0000000..5754abc --- /dev/null +++ b/tests/Functional/App/ResponseInference/Model/ItemView.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\ResponseInference\Model; + +use OpenApi\Attributes as OA; + +#[OA\Schema] +readonly class ItemView +{ + public function __construct( + #[OA\Property] + public string $name, + ) { + } +} diff --git a/tests/Functional/App/ResponseInference/Output/doc.json b/tests/Functional/App/ResponseInference/Output/doc.json new file mode 100644 index 0000000..e2b0f04 --- /dev/null +++ b/tests/Functional/App/ResponseInference/Output/doc.json @@ -0,0 +1,464 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "API Documentation", + "version": "1.0.0" + }, + "servers": [ + { + "url": "/" + } + ], + "paths": { + "/items": { + "put": { + "operationId": "cb46801d854efa87933a7452bbce36ad", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemPayload" + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemView" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/400" + }, + "422": { + "$ref": "#/components/responses/422" + } + } + }, + "post": { + "operationId": "27773390bb75f53b3dba8cf5506bf376", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemPayload" + } + } + } + }, + "responses": { + "201": { + "description": "The created item", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "409": { + "description": "The item already exists" + } + }, + "security": [ + { + "bearer": [] + } + ] + }, + "options": { + "operationId": "d59f0b30543ddb1ae4929ec67e26398a", + "responses": { + "200": { + "description": "Successful" + }, + "400": { + "$ref": "#/components/responses/400" + } + } + }, + "patch": { + "operationId": "98f5743ff15ab7928509fb9057bcc990", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemPayload" + } + } + } + } + }, + "responses": { + "204": { + "description": "Successful" + }, + "400": { + "$ref": "#/components/responses/400" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "422": { + "$ref": "#/components/responses/422" + } + } + } + }, + "/items/{id}": { + "put": { + "operationId": "09cc88ea392d33afa7d8c921a355d9c9", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemPayload" + } + } + } + }, + "responses": { + "200": { + "description": "Custom body", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "delete": { + "operationId": "9a0963f4e6b521c6931451ee70612dce", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Successful" + }, + "400": { + "$ref": "#/components/responses/400" + }, + "404": { + "$ref": "#/components/responses/404" + } + } + }, + "head": { + "operationId": "d951d83665a40899ca528787d7f3a933", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful" + }, + "400": { + "$ref": "#/components/responses/400" + }, + "404": { + "$ref": "#/components/responses/404" + } + } + } + }, + "/items/ids": { + "get": { + "operationId": "0108cf8b354cfdadfc063c467684047a", + "responses": { + "200": { + "description": "Successful", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/400" + } + }, + "security": [] + } + } + }, + "components": { + "schemas": { + "ItemPayload": { + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + }, + "type": "object" + }, + "ItemView": { + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "type": "object", + "readOnly": true + }, + "BadRequestErrorView": { + "properties": { + "type": { + "type": "string", + "default": "https://tools.ietf.org/html/rfc2616#section-10" + }, + "title": { + "type": "string", + "default": "Bad Request" + }, + "status": { + "type": "integer", + "default": 400 + }, + "detail": { + "type": "string" + } + }, + "type": "object", + "readOnly": true + }, + "UnauthorizedErrorView": { + "properties": { + "type": { + "type": "string", + "default": "https://tools.ietf.org/html/rfc2616#section-10" + }, + "title": { + "type": "string", + "default": "Unauthorized" + }, + "status": { + "type": "integer", + "default": 401 + }, + "detail": { + "type": "string" + } + }, + "type": "object", + "readOnly": true + }, + "ForbiddenErrorView": { + "properties": { + "type": { + "type": "string", + "default": "https://tools.ietf.org/html/rfc2616#section-10" + }, + "title": { + "type": "string", + "default": "Forbidden" + }, + "status": { + "type": "integer", + "default": 403 + }, + "detail": { + "type": "string" + } + }, + "type": "object", + "readOnly": true + }, + "NotFoundErrorView": { + "properties": { + "type": { + "type": "string", + "default": "https://tools.ietf.org/html/rfc2616#section-10" + }, + "title": { + "type": "string", + "default": "Not Found" + }, + "status": { + "type": "integer", + "default": 404 + }, + "detail": { + "type": "string" + } + }, + "type": "object", + "readOnly": true + }, + "ValidationErrorView": { + "properties": { + "type": { + "type": "string", + "default": "https://symfony.com/errors/validation" + }, + "title": { + "type": "string", + "default": "Validation Failed" + }, + "status": { + "type": "integer", + "default": 422 + }, + "detail": { + "type": "string" + }, + "violations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationViolationErrorView" + } + } + }, + "type": "object", + "readOnly": true + }, + "ValidationViolationErrorView": { + "properties": { + "propertyPath": { + "type": "string" + }, + "title": { + "type": "string" + }, + "template": { + "type": "string" + }, + "parameters": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "type": { + "type": "string", + "example": "urn:uuid:9ff3fdc4-b214-49db-8718-39c315e33d45" + } + }, + "type": "object", + "readOnly": true + } + }, + "responses": { + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestErrorView" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorView" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorView" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorView" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorView" + } + } + } + } + }, + "securitySchemes": { + "bearer": { + "type": "http", + "scheme": "bearer" + } + } + } +} \ No newline at end of file diff --git a/tests/Functional/App/ResponseInference/routes.yaml b/tests/Functional/App/ResponseInference/routes.yaml new file mode 100644 index 0000000..2541882 --- /dev/null +++ b/tests/Functional/App/ResponseInference/routes.yaml @@ -0,0 +1,8 @@ +controllers: + resource: + path: ./Controller/ + namespace: OpenSolid\Tests\OpenApiBundle\Functional\App\ResponseInference\Controller + type: attribute + +openapi: + resource: '@OpenApiBundle/config/routes.php' diff --git a/tests/Functional/App/config.yaml b/tests/Functional/App/config.yaml index 6d5a3da..d0fa04e 100644 --- a/tests/Functional/App/config.yaml +++ b/tests/Functional/App/config.yaml @@ -20,6 +20,8 @@ framework: php_errors: log: true throw: true + property_info: + with_constructor_extractor: true openapi: default_path: '%kernel.project_dir%/%kernel.test_case%/' diff --git a/tests/Functional/ConstraintSchemaTest.php b/tests/Functional/ConstraintSchemaTest.php new file mode 100644 index 0000000..813365b --- /dev/null +++ b/tests/Functional/ConstraintSchemaTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional; + +class ConstraintSchemaTest extends AbstractWebTestCase +{ + public function testDoc(): void + { + $client = self::createClient(); + $client->jsonRequest('GET', '/openapi.json'); + $content = $client->getResponse()->getContent(); + + self::assertResponseIsSuccessful(); + $this->assertSameFileResponseContent($content, 'doc.json'); + } + + public function testEndpoint(): void + { + $client = self::createClient(); + $client->jsonRequest('POST', '/items', [ + 'name' => 'foo', + 'size' => 1, + 'code' => 'X1', + 'quantity' => 1, + 'notBlankInteger' => 1, + 'tags' => ['a'], + ]); + + self::assertResponseStatusCodeSame(201); + $this->assertSameFileResponseContent($client->getResponse()->getContent(), 'response.json'); + } + + public function testValidation(): void + { + $client = self::createClient(); + $client->jsonRequest('POST', '/items', [ + 'name' => 'fo', + 'size' => 1, + 'code' => '', + 'quantity' => null, + 'notBlankInteger' => 1, + ]); + + self::assertResponseIsUnprocessable(); + $this->assertSameFileResponseContent($client->getResponse()->getContent(), 'validation_error.json'); + } +} diff --git a/tests/Functional/ExportOpenApiCommandTest.php b/tests/Functional/ExportOpenApiCommandTest.php index 38d7908..1c0a37d 100644 --- a/tests/Functional/ExportOpenApiCommandTest.php +++ b/tests/Functional/ExportOpenApiCommandTest.php @@ -41,9 +41,6 @@ public function testExportToYaml(): void $tester->assertCommandIsSuccessful(); $this->assertStringContainsString('OpenAPI spec has been exported successfully.', $tester->getDisplay()); - $actual = trim(file_get_contents($filename)); - // file_put_contents(__DIR__.'/App/ExportOpenApiCommand/Output/doc.yaml', $actual); - $expected = trim(file_get_contents(__DIR__.'/App/ExportOpenApiCommand/Output/doc.yaml')); - $this->assertSame($expected, $actual); + $this->assertSameFileContent(file_get_contents($filename), 'doc.yaml'); } } diff --git a/tests/Functional/NativeQueryTest.php b/tests/Functional/NativeQueryTest.php new file mode 100644 index 0000000..eb7386f --- /dev/null +++ b/tests/Functional/NativeQueryTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional; + +class NativeQueryTest extends AbstractWebTestCase +{ + public function testDoc(): void + { + $client = self::createClient(); + $client->jsonRequest('GET', '/openapi.json'); + + self::assertResponseIsSuccessful(); + $this->assertSameFileResponseContent($client->getResponse()->getContent(), 'doc.json'); + } + + public function testEndpoint(): void + { + $client = self::createClient(); + $client->request('GET', '/stores/main/items?filter[name]=foo&filter[currency]=EUR&page=2&status=active'); + + self::assertResponseStatusCodeSame(200); + $this->assertSame([['store' => 'main', 'name' => 'foo', 'page' => 2, 'status' => 'active']], json_decode($client->getResponse()->getContent(), true)); + } + + public function testEmptyCollection(): void + { + $client = self::createClient(); + $client->request('GET', '/stores/main/items?q=empty'); + + self::assertResponseStatusCodeSame(200); + $this->assertSame('[]', $client->getResponse()->getContent()); + } +} diff --git a/tests/Functional/ResponseInferenceTest.php b/tests/Functional/ResponseInferenceTest.php new file mode 100644 index 0000000..1c62fd1 --- /dev/null +++ b/tests/Functional/ResponseInferenceTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional; + +class ResponseInferenceTest extends AbstractWebTestCase +{ + public function testDoc(): void + { + $client = self::createClient(); + $client->jsonRequest('GET', '/openapi.json'); + + self::assertResponseIsSuccessful(); + $this->assertSameFileResponseContent($client->getResponse()->getContent(), 'doc.json'); + } + + public function testDeclaredSuccessResponseStatusCode(): void + { + $client = self::createClient(); + $client->jsonRequest('POST', '/items', ['name' => 'foo']); + + self::assertResponseStatusCodeSame(201); + $this->assertSame('{"name":"foo"}', $client->getResponse()->getContent()); + } + + public function testCustomStatusCodeWithoutContent(): void + { + $client = self::createClient(); + $client->request('DELETE', '/items/1'); + + self::assertResponseStatusCodeSame(202); + $this->assertSame('', $client->getResponse()->getContent()); + } + + public function testHead(): void + { + $client = self::createClient(); + $client->request('HEAD', '/items/1'); + + self::assertResponseStatusCodeSame(200); + } + + public function testOptionsReturnsTheControllerResponse(): void + { + $client = self::createClient(); + $client->request('OPTIONS', '/items'); + + self::assertResponseStatusCodeSame(200); + self::assertResponseHeaderSame('Allow', 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS'); + } + + public function testCollectionPayloadWithNativeType(): void + { + $client = self::createClient(); + $client->jsonRequest('PUT', '/items', [['name' => 'foo'], ['name' => 'bar']]); + + self::assertResponseStatusCodeSame(200); + $this->assertSame('[{"name":"foo"},{"name":"bar"}]', $client->getResponse()->getContent()); + } + + public function testCollectionPayloadWithNativeTypeIsValidated(): void + { + $client = self::createClient(); + $client->jsonRequest('PUT', '/items', [['name' => '']]); + + self::assertResponseIsUnprocessable(); + } + + public function testScalarCollection(): void + { + $client = self::createClient(); + $client->request('GET', '/items/ids'); + + self::assertResponseStatusCodeSame(200); + $this->assertSame('[1,2]', $client->getResponse()->getContent()); + } +} diff --git a/tests/Unit/Attribute/DefaultsTest.php b/tests/Unit/Attribute/DefaultsTest.php new file mode 100644 index 0000000..436e4db --- /dev/null +++ b/tests/Unit/Attribute/DefaultsTest.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\Attribute; + +use OpenApi\Attributes\Schema; +use OpenApi\Undefined; +use OpenSolid\OpenApiBundle\Attribute\Path; +use OpenSolid\OpenApiBundle\Attribute\PathDefaults; +use OpenSolid\OpenApiBundle\Attribute\Property; +use OpenSolid\OpenApiBundle\Attribute\PropertyDefaults; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +class DefaultsTest extends TestCase +{ + public static function provideDefaults(): iterable + { + yield [PathDefaults::class]; + yield [PropertyDefaults::class]; + } + + /** + * @param class-string $class + */ + #[DataProvider('provideDefaults')] + public function testSetters(string $class): void + { + $defaults = $class::create(); + + foreach ((new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isStatic()) { + continue; + } + + $value = $this->sampleOf($method->getParameters()[0]); + $this->assertSame($defaults, $method->invoke($defaults, $value), $method->name); + $this->assertSame($value, $defaults->{$method->name}, $method->name); + } + } + + public function testBooleanSettersDefaultToTrue(): void + { + $this->assertTrue(PathDefaults::create()->required()->required); + $this->assertTrue(PropertyDefaults::create()->exclusiveMinimum()->exclusiveMinimum); + } + + public function testPathUsesItsDefaults(): void + { + $path = new IdPath(); + + $this->assertSame('id', $path->name); + $this->assertSame('The identifier', $path->description); + $this->assertTrue($path->required); + $this->assertSame('uuid', $path->format); + $this->assertSame(['a'], $path->enum); + + $path = new IdPath(name: 'other', required: false, format: 'ulid', enum: ['b']); + + $this->assertSame('other', $path->name); + $this->assertFalse($path->required); + $this->assertSame('ulid', $path->format); + $this->assertSame(['b'], $path->enum); + } + + public function testPathWithoutDefaults(): void + { + $path = new Path(); + + $this->assertTrue(Undefined::isDefault($path->format, $path->enum, $path->description)); + } + + public function testPropertyUsesItsDefaults(): void + { + $property = new IdProperty(); + + $this->assertSame('uuid', $property->format); + $this->assertSame(['read'], $property->groups); + $this->assertSame(3, $property->multipleOf); + $this->assertInstanceOf(Schema::class, $property->not); + + $property = new IdProperty(format: 'ulid', groups: ['write'], multipleOf: 2); + + $this->assertSame('ulid', $property->format); + $this->assertSame(['write'], $property->groups); + $this->assertSame(2, $property->multipleOf); + } + + public function testPropertyWithoutDefaults(): void + { + $property = new Property(); + + $this->assertNull($property->groups); + $this->assertTrue(Undefined::isDefault($property->multipleOf, $property->description, $property->example, $property->default)); + $this->assertContains('groups', Property::$_blacklist); + } + + private function sampleOf(\ReflectionParameter $parameter): mixed + { + $types = explode('|', (string) $parameter->getType()); + + return match (true) { + \in_array('mixed', $types, true) => 'mixed', + \in_array('string', $types, true) => 'foo', + \in_array('bool', $types, true) => false, + \in_array('int', $types, true) => 1, + \in_array('array', $types, true) => ['foo'], + default => (new \ReflectionClass($types[0]))->newInstanceWithoutConstructor(), + }; + } +} + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class IdPath extends Path +{ + public static function defaults(): PathDefaults + { + return PathDefaults::create() + ->name('id') + ->description('The identifier') + ->required() + ->format('uuid') + ->enum(['a']); + } +} + +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class IdProperty extends Property +{ + public static function defaults(): PropertyDefaults + { + return PropertyDefaults::create() + ->format('uuid') + ->groups(['read']) + ->multipleOf(3) + ->not(new Schema()); + } +} diff --git a/tests/Unit/Command/ExportOpenApiCommandTest.php b/tests/Unit/Command/ExportOpenApiCommandTest.php new file mode 100644 index 0000000..7567eeb --- /dev/null +++ b/tests/Unit/Command/ExportOpenApiCommandTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\Command; + +use OpenApi\Analysers\AttributeAnnotationFactory; +use OpenApi\Analysers\ReflectionAnalyser; +use OpenSolid\OpenApiBundle\Command\ExportOpenApiCommand; +use OpenSolid\OpenApiBundle\Generator; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; + +class ExportOpenApiCommandTest extends TestCase +{ + public function testSpecNotFound(): void + { + $generator = new Generator(new ReflectionAnalyser([new AttributeAnnotationFactory()]), [], [dirname(__DIR__).'/Controller/Fixtures/Empty']); + $tester = new CommandTester(new ExportOpenApiCommand($generator)); + + $this->assertSame(Command::FAILURE, $tester->execute([])); + $this->assertStringContainsString('OpenAPI spec not found.', $tester->getDisplay()); + } +} diff --git a/tests/Unit/Controller/Fixtures/Empty/NoSpec.php b/tests/Unit/Controller/Fixtures/Empty/NoSpec.php new file mode 100644 index 0000000..71e78bf --- /dev/null +++ b/tests/Unit/Controller/Fixtures/Empty/NoSpec.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\Controller\Fixtures\Empty; + +class NoSpec +{ +} diff --git a/tests/Unit/Controller/Fixtures/Invalid/InvalidSpec.php b/tests/Unit/Controller/Fixtures/Invalid/InvalidSpec.php new file mode 100644 index 0000000..af8dd35 --- /dev/null +++ b/tests/Unit/Controller/Fixtures/Invalid/InvalidSpec.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\Controller\Fixtures\Invalid; + +use OpenApi\Attributes as OA; + +#[OA\OpenApi] +class InvalidSpec +{ +} diff --git a/tests/Unit/Controller/OpenApiControllerTest.php b/tests/Unit/Controller/OpenApiControllerTest.php new file mode 100644 index 0000000..6450039 --- /dev/null +++ b/tests/Unit/Controller/OpenApiControllerTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\Controller; + +use OpenApi\Analysers\AttributeAnnotationFactory; +use OpenApi\Analysers\ReflectionAnalyser; +use OpenSolid\OpenApiBundle\Controller\OpenApiController; +use OpenSolid\OpenApiBundle\Generator; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + +class OpenApiControllerTest extends TestCase +{ + public static function provideActions(): iterable + { + yield ['index']; + yield ['yaml']; + yield ['json']; + yield ['jsonSchema']; + } + + #[DataProvider('provideActions')] + public function testSpecNotFound(string $action): void + { + $controller = new OpenApiController($this->createGenerator(__DIR__.'/Fixtures/Empty')); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('OpenAPI spec not found.'); + + match ($action) { + 'index' => $controller->index($this->createStub(UrlGeneratorInterface::class)), + 'jsonSchema' => $controller->jsonSchema(new Request(), 'Foo'), + default => $controller->{$action}(), + }; + } + + public function testSchemaNotFound(): void + { + $controller = new OpenApiController($this->createGenerator(__DIR__.'/Fixtures/Invalid')); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Schema "Foo" not found.'); + + $controller->jsonSchema(new Request(), 'Foo'); + } + + public function testValidationErrorsAreShown(): void + { + $controller = new OpenApiController($this->createGenerator(__DIR__.'/Fixtures/Invalid')); + $content = $controller->index($this->createStub(UrlGeneratorInterface::class))->getContent(); + + $this->assertStringContainsString('Required @OA\Info() not found', $content); + } + + public function testValidationWithoutMessages(): void + { + $controller = new OpenApiController($this->createGenerator(__DIR__.'/Fixtures/Invalid', new NullLogger())); + $content = $controller->index($this->createStub(UrlGeneratorInterface::class))->getContent(); + + $this->assertStringContainsString('OpenAPI spec is invalid.', $content); + } + + private function createGenerator(string $path, ?NullLogger $logger = null): Generator + { + return new Generator(new ReflectionAnalyser([new AttributeAnnotationFactory()]), [], [$path], $logger); + } +} diff --git a/tests/Unit/DependencyInjection/CompilerPassTest.php b/tests/Unit/DependencyInjection/CompilerPassTest.php new file mode 100644 index 0000000..4187d5b --- /dev/null +++ b/tests/Unit/DependencyInjection/CompilerPassTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\DependencyInjection; + +use OpenSolid\OpenApiBundle\DependencyInjection\Compiler\SerializerMappingPass; +use OpenSolid\OpenApiBundle\DependencyInjection\Compiler\TrackPathsPass; +use OpenSolid\OpenApiBundle\DependencyInjection\Compiler\ValidatorMappingPass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class CompilerPassTest extends TestCase +{ + public function testPassesWithoutSerializerAndValidator(): void + { + $container = new ContainerBuilder(); + + (new SerializerMappingPass())->process($container); + (new ValidatorMappingPass())->process($container); + + $this->assertFalse($container->has('serializer.mapping.chain_loader')); + $this->assertFalse($container->has('validator.builder')); + } + + public function testMissingPath(): void + { + $container = new ContainerBuilder(); + $container->setParameter('openapi_paths', [__DIR__.'/missing']); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('OpenAPI path "'.__DIR__.'/missing" not found.'); + + (new TrackPathsPass())->process($container); + } +} diff --git a/tests/Unit/HttpKernel/ControllerResultSubscriberTest.php b/tests/Unit/HttpKernel/ControllerResultSubscriberTest.php new file mode 100644 index 0000000..02db6db --- /dev/null +++ b/tests/Unit/HttpKernel/ControllerResultSubscriberTest.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\HttpKernel; + +use OpenApi\Attributes as OA; +use OpenSolid\OpenApiBundle\HttpKernel\Controller\ControllerResultSubscriber; +use OpenSolid\OpenApiBundle\Routing\Attribute\Get; +use OpenSolid\OpenApiBundle\Routing\Attribute\Post; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsMetadata; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; + +class ControllerResultSubscriberTest extends TestCase +{ + public function testSubscribedEvents(): void + { + $this->assertSame([KernelEvents::VIEW => 'onKernelView'], ControllerResultSubscriber::getSubscribedEvents()); + } + + public function testSubRequestIsIgnored(): void + { + $event = $this->createEvent(['foo'], type: HttpKernelInterface::SUB_REQUEST); + $this->createSubscriber()->onKernelView($event); + + $this->assertNull($event->getResponse()); + } + + public function testResponseResultIsIgnored(): void + { + $event = $this->createEvent(new Response()); + $this->createSubscriber()->onKernelView($event); + + $this->assertNull($event->getResponse()); + } + + public function testResultWithoutControllerMetadataIsIgnored(): void + { + $event = $this->createEvent(['foo'], withMetadata: false); + $this->createSubscriber()->onKernelView($event); + + $this->assertNull($event->getResponse()); + } + + public function testNullResultWithoutControllerMetadata(): void + { + $event = $this->createEvent(null, withMetadata: false); + $this->createSubscriber()->onKernelView($event); + + $this->assertSame(204, $event->getResponse()->getStatusCode()); + } + + public function testStatusCodes(): void + { + $this->assertSame(200, $this->statusCodeOf([new \stdClass()])); + $this->assertSame(201, $this->statusCodeOf([new Post('/foo')])); + $this->assertSame(202, $this->statusCodeOf([new Post('/foo', statusCode: 202)])); + $this->assertSame(203, $this->statusCodeOf([new Get('/foo', responses: [new OA\Response(response: 'default'), new OA\Response(response: 400), new OA\Response(response: 203)])])); + $this->assertSame(200, $this->statusCodeOf([new Get('/foo')])); + $this->assertSame(204, $this->statusCodeOf([new Get('/foo')], null)); + $this->assertSame(205, $this->statusCodeOf([new Get('/foo', statusCode: 205)], null)); + } + + public function testResponseContent(): void + { + $event = $this->createEvent(['foo' => 'bar'], [new Get('/foo')], request: new Request(server: ['HTTP_ACCEPT' => 'application/json'])); + $this->createSubscriber()->onKernelView($event); + + $this->assertSame('{"foo":"bar"}', $event->getResponse()->getContent()); + $this->assertSame('application/json', $event->getResponse()->headers->get('Content-Type')); + } + + private function statusCodeOf(array $attributes, mixed $result = ['foo']): int + { + $event = $this->createEvent($result, $attributes); + $this->createSubscriber()->onKernelView($event); + + return $event->getResponse()->getStatusCode(); + } + + private function createEvent(mixed $result, array $attributes = [], int $type = HttpKernelInterface::MAIN_REQUEST, bool $withMetadata = true, ?Request $request = null): ViewEvent + { + $kernel = $this->createStub(HttpKernelInterface::class); + $request ??= new Request(); + $metadata = null; + + if ($withMetadata) { + $controllerEvent = new ControllerEvent($kernel, static fn () => null, $request, $type); + + if (class_exists(ControllerArgumentsMetadata::class)) { + $controllerEvent->setController(static fn () => null, $attributes); + $metadata = new ControllerArgumentsMetadata($controllerEvent, new ControllerArgumentsEvent($kernel, $controllerEvent, [], $request, $type)); + } else { + $grouped = []; + foreach ($attributes as $attribute) { + $grouped[$attribute::class][] = $attribute; + } + $controllerEvent->setController(static fn () => null, $grouped); + $metadata = new ControllerArgumentsEvent($kernel, $controllerEvent, [], $request, $type); + } + } + + return new ViewEvent($kernel, $request, $type, $result, $metadata); + } + + private function createSubscriber(): ControllerResultSubscriber + { + return new ControllerResultSubscriber(new Serializer([new ObjectNormalizer()], [new JsonEncoder()])); + } +} diff --git a/tests/Unit/HttpKernel/Fixtures/Dummy.php b/tests/Unit/HttpKernel/Fixtures/Dummy.php new file mode 100644 index 0000000..bad28b0 --- /dev/null +++ b/tests/Unit/HttpKernel/Fixtures/Dummy.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\HttpKernel\Fixtures; + +use Symfony\Component\Validator\Constraints as Assert; + +class Dummy +{ + #[Assert\Length(min: 3)] + public string $name = ''; + + public ?DummyStatus $status = null; +} diff --git a/tests/Unit/HttpKernel/Fixtures/DummyStatus.php b/tests/Unit/HttpKernel/Fixtures/DummyStatus.php new file mode 100644 index 0000000..3bd81db --- /dev/null +++ b/tests/Unit/HttpKernel/Fixtures/DummyStatus.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\HttpKernel\Fixtures; + +enum DummyStatus: string +{ + case Draft = 'draft'; +} diff --git a/tests/Unit/HttpKernel/NativeConstraintGuesserTest.php b/tests/Unit/HttpKernel/NativeConstraintGuesserTest.php new file mode 100644 index 0000000..393cbef --- /dev/null +++ b/tests/Unit/HttpKernel/NativeConstraintGuesserTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\HttpKernel; + +use OpenSolid\OpenApiBundle\Attribute\Path; +use OpenSolid\OpenApiBundle\HttpKernel\Controller\ValueResolver\ConstraintGuesser\NativeConstraintGuesser; +use OpenSolid\Tests\OpenApiBundle\Unit\HttpKernel\Fixtures\DummyStatus; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\Validator\Constraints as Assert; + +class NativeConstraintGuesserTest extends TestCase +{ + public static function provideFormats(): iterable + { + yield ['uuid', Assert\Uuid::class]; + yield ['date', Assert\Date::class]; + yield ['date-time', Assert\DateTime::class]; + yield ['datetime', Assert\DateTime::class]; + yield ['locale', Assert\Locale::class]; + yield ['currency', Assert\Currency::class]; + yield ['numeric', Assert\Type::class]; + } + + #[DataProvider('provideFormats')] + public function testFormat(string $format, string $constraint): void + { + $constraints = (new NativeConstraintGuesser())->guess($this->argument(), new Path(format: $format)); + + $this->assertCount(1, $constraints); + $this->assertInstanceOf($constraint, array_values($constraints)[0]); + } + + public function testUnknownFormat(): void + { + $this->assertSame([], (new NativeConstraintGuesser())->guess($this->argument(), new Path(format: 'unknown'))); + } + + public function testEnumClass(): void + { + $constraints = array_values((new NativeConstraintGuesser())->guess($this->argument(), new Path(enum: DummyStatus::class))); + + $this->assertInstanceOf(Assert\Choice::class, $constraints[0]); + $this->assertSame(['draft'], $constraints[0]->choices); + $this->assertSame('This is not a valid id value.', $constraints[0]->message); + } + + public function testEnumValues(): void + { + $constraints = array_values((new NativeConstraintGuesser())->guess($this->argument(), new Path(enum: [DummyStatus::Draft, Pure::Foo, 'bar']))); + + $this->assertSame(['draft', 'Foo', 'bar'], $constraints[0]->choices); + } + + private function argument(): ArgumentMetadata + { + return new ArgumentMetadata('id', 'string', false, false, null); + } +} + +enum Pure +{ + case Foo; +} diff --git a/tests/Unit/HttpKernel/PathValueResolverTest.php b/tests/Unit/HttpKernel/PathValueResolverTest.php new file mode 100644 index 0000000..2239771 --- /dev/null +++ b/tests/Unit/HttpKernel/PathValueResolverTest.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\HttpKernel; + +use OpenSolid\OpenApiBundle\Attribute\Path; +use OpenSolid\OpenApiBundle\HttpKernel\Controller\ValueResolver\ConstraintGuesser\NativeConstraintGuesser; +use OpenSolid\OpenApiBundle\HttpKernel\Controller\ValueResolver\PathValueResolver; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\Validator\Exception\ValidationFailedException; +use Symfony\Component\Validator\Validation; + +class PathValueResolverTest extends TestCase +{ + public function testVariadicArgumentIsRejected(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Mapping variadic argument "$id" is not supported.'); + + $this->createResolver()->resolve(new Request(), new ArgumentMetadata('id', 'string', true, false, null)); + } + + public function testMissingValue(): void + { + $this->assertSame([], $this->createResolver()->resolve(new Request(), new ArgumentMetadata('id', 'string', false, false, null))); + } + + public function testValueWithoutPathAttribute(): void + { + $request = new Request(attributes: ['id' => 'foo']); + + $this->assertSame(['foo'], $this->createResolver()->resolve($request, new ArgumentMetadata('id', 'string', false, false, null))); + } + + public function testValueWithoutConstraints(): void + { + $request = new Request(attributes: ['id' => 'foo']); + $argument = new ArgumentMetadata('id', 'string', false, false, null, attributes: [new Path()]); + + $this->assertSame(['foo'], $this->createResolver()->resolve($request, $argument)); + } + + public function testValidValue(): void + { + $request = new Request(attributes: ['id' => '4f09d694-446a-4769-9929-dad96a071cad']); + $argument = new ArgumentMetadata('id', 'string', false, false, null, attributes: [new Path(format: 'uuid')]); + + $this->assertSame(['4f09d694-446a-4769-9929-dad96a071cad'], $this->createResolver()->resolve($request, $argument)); + } + + public function testInvalidValue(): void + { + $this->expectException(ValidationFailedException::class); + + $request = new Request(attributes: ['id' => 'foo']); + $argument = new ArgumentMetadata('id', 'string', false, false, null, attributes: [new Path(format: 'uuid')]); + + $this->createResolver()->resolve($request, $argument); + } + + private function createResolver(): PathValueResolver + { + return new PathValueResolver(new RequestAttributeValueResolver(), Validation::createValidator(), [new NativeConstraintGuesser()]); + } +} diff --git a/tests/Unit/HttpKernel/RequestPayloadArrayResolverTest.php b/tests/Unit/HttpKernel/RequestPayloadArrayResolverTest.php new file mode 100644 index 0000000..832592f --- /dev/null +++ b/tests/Unit/HttpKernel/RequestPayloadArrayResolverTest.php @@ -0,0 +1,269 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\HttpKernel; + +use OpenSolid\OpenApiBundle\Attribute\Payload; +use OpenSolid\OpenApiBundle\Attribute\Query; +use OpenSolid\OpenApiBundle\HttpKernel\Controller\ArgumentResolver\RequestPayloadArrayResolver; +use OpenSolid\Tests\OpenApiBundle\Unit\HttpKernel\Fixtures\Dummy; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Validator\Exception\ValidationFailedException; +use Symfony\Component\Validator\Validation; + +class RequestPayloadArrayResolverTest extends TestCase +{ + public function testResolveDelegatesArgumentsWithoutMappingAttribute(): void + { + $resolver = $this->createResolver(); + + $this->assertSame([], $resolver->resolve(new Request(), new ArgumentMetadata('foo', 'string', false, false, null))); + } + + public function testResolveRejectsVariadicArguments(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Mapping variadic argument "$foo" is not supported.'); + + $this->createResolver()->resolve(new Request(), new ArgumentMetadata('foo', Dummy::class, true, false, null, attributes: [new Payload()])); + } + + public function testResolveKeepsArrayTypeWithoutItemsType(): void + { + $attribute = new MapRequestPayload(); + $result = $this->createResolver()->resolve(new Request(), new ArgumentMetadata('foo', 'array', false, false, null, attributes: [$attribute])); + + $this->assertSame([$attribute], $result); + $this->assertSame('array', $attribute->metadata->getType()); + } + + public function testResolveUsesTheNativeTypeAsItemsType(): void + { + $attribute = new MapRequestPayload(type: Dummy::class); + $this->createResolver()->resolve(new Request(), new ArgumentMetadata('foo', 'array', false, true, [], attributes: [$attribute])); + + $this->assertSame(Dummy::class.'[]', $attribute->metadata->getType()); + $this->assertSame([], $attribute->metadata->getDefaultValue()); + } + + public function testSubscribedEvents(): void + { + $this->assertArrayHasKey(KernelEvents::CONTROLLER_ARGUMENTS, RequestPayloadArrayResolver::getSubscribedEvents()); + } + + public function testArgumentsWithoutMappingAttributeAreKept(): void + { + $event = $this->createEvent(new Request(), ['foo']); + $this->createResolver()->onKernelControllerArguments($event); + + $this->assertSame(['foo'], $event->getArguments()); + } + + public function testUntypedArgumentIsRejected(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Could not resolve the "$foo" controller argument: argument should be typed.'); + + $this->resolve(new Request(), new Payload(), new ArgumentMetadata('foo', null, false, false, null)); + } + + public function testMapRequestPayload(): void + { + $payload = $this->resolve($this->jsonRequest('{"name": "foo"}'), new Payload(), $this->metadata()); + + $this->assertInstanceOf(Dummy::class, $payload); + $this->assertSame('foo', $payload->name); + } + + public function testMapRequestPayloadFromFormData(): void + { + $request = new Request(request: ['name' => 'foo'], server: ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $payload = $this->resolve($request, new Payload(), $this->metadata()); + + $this->assertSame('foo', $payload->name); + } + + public function testMapQueryStringWithKey(): void + { + $request = new Request(query: ['filter' => ['name' => 'foo']]); + $payload = $this->resolve($request, new Query(key: 'filter'), $this->metadata()); + + $this->assertSame('foo', $payload->name); + } + + public function testEmptyQueryStringUsesTheDefaultValue(): void + { + $default = new Dummy(); + $payload = $this->resolve(new Request(), new Query(), new ArgumentMetadata('foo', Dummy::class, false, true, $default)); + + $this->assertSame($default, $payload); + } + + public function testEmptyPayloadOfNullableArgumentIsNull(): void + { + $payload = $this->resolve($this->jsonRequest(''), new Payload(), new ArgumentMetadata('foo', Dummy::class, false, false, null, true)); + + $this->assertNull($payload); + } + + public function testEmptyPayloadOfRequiredArgumentIsRejected(): void + { + $this->assertHttpException(422, null, fn () => $this->resolve($this->jsonRequest(''), new Payload(), $this->metadata())); + } + + public function testEmptyPayloadIsMappedWhenMapWhenEmptyIsEnabled(): void + { + if (!property_exists(MapRequestPayload::class, 'mapWhenEmpty')) { + $this->markTestSkipped('The "mapWhenEmpty" option requires Symfony 8.1 or later.'); + } + + $payload = $this->resolve($this->jsonRequest(''), new Payload(mapWhenEmpty: true), new ArgumentMetadata('foo', Dummy::class.'[]', false, false, null, true)); + + $this->assertSame([], $payload); + } + + public function testEmptyQueryStringIsMappedWhenMapWhenEmptyIsEnabled(): void + { + if (!property_exists(MapRequestPayload::class, 'mapWhenEmpty')) { + $this->markTestSkipped('The "mapWhenEmpty" option requires Symfony 8.1 or later.'); + } + + $payload = $this->resolve(new Request(), new Query(mapWhenEmpty: true), new ArgumentMetadata('foo', \stdClass::class, false, true, null)); + + $this->assertInstanceOf(\stdClass::class, $payload); + } + + public function testValidationFailure(): void + { + try { + $this->resolve($this->jsonRequest('{"name": "fo"}'), new Payload(), $this->metadata()); + $this->fail('An HttpException was expected.'); + } catch (HttpException $e) { + $this->assertSame(422, $e->getStatusCode()); + $this->assertInstanceOf(ValidationFailedException::class, $e->getPrevious()); + } + } + + public function testDenormalizationErrorsBecomeViolations(): void + { + try { + $this->resolve($this->jsonRequest('{"name": 1, "status": "unknown"}'), new Payload(), $this->metadata()); + $this->fail('An HttpException was expected.'); + } catch (HttpException $e) { + $this->assertSame(422, $e->getStatusCode()); + $violations = $e->getPrevious()->getViolations(); + $this->assertCount(2, $violations); + $this->assertSame('name', $violations[0]->getPropertyPath()); + $this->assertSame('This value should be of type string.', $violations[0]->getMessage()); + $this->assertSame('status', $violations[1]->getPropertyPath()); + $this->assertArrayHasKey('hint', $violations[1]->getParameters()); + } + } + + public function testDenormalizationErrorsWithoutValidator(): void + { + $this->assertHttpException(422, null, fn () => $this->resolve($this->jsonRequest('{"name": 1}'), new Payload(), $this->metadata(), $this->createResolver(validator: false))); + } + + public function testPayloadWithoutValidator(): void + { + $payload = $this->resolve($this->jsonRequest('{"name": "fo"}'), new Payload(), $this->metadata(), $this->createResolver(validator: false)); + + $this->assertSame('fo', $payload->name); + } + + public function testMissingContentTypeIsUnsupported(): void + { + $this->assertHttpException(415, 'Unsupported format.', fn () => $this->resolve(new Request(content: '{}'), new Payload(), $this->metadata())); + } + + public function testNotAcceptedFormatIsUnsupported(): void + { + $this->assertHttpException(415, 'Unsupported format, expects "xml", but "json" given.', fn () => $this->resolve($this->jsonRequest('{}'), new Payload(acceptFormat: 'xml'), $this->metadata())); + } + + public function testFormatWithoutDecoderIsUnsupported(): void + { + $this->assertHttpException(415, 'Unsupported format: "xml".', fn () => $this->resolve(new Request(server: ['CONTENT_TYPE' => 'application/xml'], content: ''), new Payload(), $this->metadata())); + } + + public function testInvalidFormData(): void + { + $this->assertHttpException(400, 'Request payload contains invalid "form" data.', fn () => $this->resolve(new Request(server: ['CONTENT_TYPE' => 'application/x-www-form-urlencoded'], content: 'name=foo'), new Payload(), $this->metadata())); + } + + public function testInvalidJson(): void + { + $this->assertHttpException(400, 'Request payload contains invalid "json" data.', fn () => $this->resolve($this->jsonRequest('{"name": '), new Payload(), $this->metadata())); + } + + private function resolve(Request $request, MapRequestPayload|Query $attribute, ArgumentMetadata $metadata, ?RequestPayloadArrayResolver $resolver = null): mixed + { + $resolver ??= $this->createResolver(); + $attribute->metadata = $metadata; + $event = $this->createEvent($request, [$attribute]); + $resolver->onKernelControllerArguments($event); + + return $event->getArguments()[0]; + } + + private function metadata(): ArgumentMetadata + { + return new ArgumentMetadata('foo', Dummy::class, false, false, null); + } + + private function jsonRequest(string $content): Request + { + return new Request(server: ['CONTENT_TYPE' => 'application/json'], content: $content); + } + + private function assertHttpException(int $statusCode, ?string $message, \Closure $resolve): void + { + try { + $resolve(); + $this->fail('An HttpException was expected.'); + } catch (HttpException $e) { + $this->assertSame($statusCode, $e->getStatusCode()); + if (null !== $message) { + $this->assertSame($message, $e->getMessage()); + } + } + } + + private function createEvent(Request $request, array $arguments): ControllerArgumentsEvent + { + return new ControllerArgumentsEvent($this->createStub(HttpKernelInterface::class), static fn () => null, $arguments, $request, HttpKernelInterface::MAIN_REQUEST); + } + + private function createResolver(bool $validator = true): RequestPayloadArrayResolver + { + $serializer = new Serializer([new BackedEnumNormalizer(), new ArrayDenormalizer(), new ObjectNormalizer(propertyTypeExtractor: new ReflectionExtractor())], [new JsonEncoder()]); + $validatorService = $validator ? Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator() : null; + + return new RequestPayloadArrayResolver($serializer, $validatorService, null, new RequestPayloadValueResolver($serializer, $validatorService)); + } +} diff --git a/tests/Unit/OpenApi/AnalyserTest.php b/tests/Unit/OpenApi/AnalyserTest.php new file mode 100644 index 0000000..13e762a --- /dev/null +++ b/tests/Unit/OpenApi/AnalyserTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\OpenApi; + +use OpenApi\Analysers\AttributeAnnotationFactory; +use OpenApi\Analysers\ReflectionAnalyser; +use OpenApi\Context; +use OpenApi\Generator; +use OpenSolid\OpenApiBundle\OpenApi\Analyser\ResolvableAnalyser; +use OpenSolid\OpenApiBundle\OpenApi\Analyser\Resolver\PhpAnalyserResolver; +use OpenSolid\OpenApiBundle\OpenApi\Analyser\Resolver\SerializedAnalyserResolver; +use OpenSolid\OpenApiBundle\OpenApi\Analyser\SerializedAnalyser; +use PHPUnit\Framework\TestCase; + +class AnalyserTest extends TestCase +{ + public function testUnsupportedFileHasNoAnalyser(): void + { + $analyser = new ResolvableAnalyser([ + new PhpAnalyserResolver(new ReflectionAnalyser([new AttributeAnnotationFactory()])), + new SerializedAnalyserResolver(new SerializedAnalyser()), + ]); + $analyser->setGenerator(new Generator()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No analyser found for file "'.__DIR__.'/Fixtures/spec.txt".'); + + $analyser->fromFile(__DIR__.'/Fixtures/spec.txt', new Context()); + } + + public function testSerializedAnalyserOnlySupportsJsonAndYaml(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Only JSON or YAML files are supported.'); + + (new SerializedAnalyser())->fromFile(__DIR__.'/Fixtures/spec.txt', new Context()); + } + + public function testSerializedAnalyserRejectsPaths(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Only OpenAPI files with no paths are supported.'); + + (new SerializedAnalyser())->fromFile(__DIR__.'/Fixtures/with_paths.yaml', new Context()); + } + + public function testSerializedAnalyserIgnoresTheGenerator(): void + { + $analyser = new SerializedAnalyser(); + + $this->assertSame($analyser, $analyser->setGenerator(new Generator())); + } +} diff --git a/tests/Unit/OpenApi/CollectionItemsTypeGuesserTest.php b/tests/Unit/OpenApi/CollectionItemsTypeGuesserTest.php new file mode 100644 index 0000000..0244127 --- /dev/null +++ b/tests/Unit/OpenApi/CollectionItemsTypeGuesserTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\OpenApi; + +use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\CollectionItemsTypeGuesser; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +class CollectionItemsTypeGuesserTest extends TestCase +{ + public static function provideReturnTypes(): iterable + { + yield ['objects', \stdClass::class]; + yield ['nullableObjects', \stdClass::class]; + yield ['integers', 'integer']; + yield ['floats', 'number']; + yield ['booleans', 'boolean']; + yield ['strings', 'string']; + yield ['mixed', null]; + yield ['unions', null]; + yield ['notACollection', null]; + yield ['invalidDocblock', null]; + } + + #[DataProvider('provideReturnTypes')] + public function testGuessReturnType(string $method, ?string $expected): void + { + $this->assertSame($expected, CollectionItemsTypeGuesser::guess(new \ReflectionMethod(CollectionFixture::class, $method))); + } + + public function testGuessParameterType(): void + { + $this->assertSame(\stdClass::class, CollectionItemsTypeGuesser::guess(new \ReflectionParameter([CollectionFixture::class, 'parameter'], 'items'))); + } +} + +class CollectionFixture +{ + /** @return list<\stdClass> */ + public function objects(): array + { + return []; + } + + /** @return list<\stdClass>|null */ + public function nullableObjects(): ?array + { + return null; + } + + /** @return list */ + public function integers(): array + { + return []; + } + + /** @return list */ + public function floats(): array + { + return []; + } + + /** @return list */ + public function booleans(): array + { + return []; + } + + /** @return array */ + public function strings(): array + { + return []; + } + + public function mixed(): array + { + return []; + } + + /** @return list */ + public function unions(): array + { + return []; + } + + public function notACollection(): string + { + return ''; + } + + /** @return list */ + public function invalidDocblock(): array + { + return []; + } + + /** @param \stdClass[] $items */ + public function parameter(array $items): void + { + } +} diff --git a/tests/Unit/OpenApi/Fixtures/spec.txt b/tests/Unit/OpenApi/Fixtures/spec.txt new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/Unit/OpenApi/Fixtures/spec.txt @@ -0,0 +1 @@ +{} diff --git a/tests/Unit/OpenApi/Fixtures/with_paths.yaml b/tests/Unit/OpenApi/Fixtures/with_paths.yaml new file mode 100644 index 0000000..ff89bc6 --- /dev/null +++ b/tests/Unit/OpenApi/Fixtures/with_paths.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: + title: Foo + version: 1.0.0 +paths: + /foo: + get: + responses: + '200': + description: OK diff --git a/tests/Unit/OpenApi/GuessersTest.php b/tests/Unit/OpenApi/GuessersTest.php new file mode 100644 index 0000000..9bfe7d5 --- /dev/null +++ b/tests/Unit/OpenApi/GuessersTest.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\OpenApi; + +use OpenApi\Annotations as OA; +use OpenApi\Attributes as OAT; +use OpenApi\Context; +use OpenApi\Undefined; +use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\Operation\OperationQueryParameterGuesser; +use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\Operation\OperationRequestBodyGuesser; +use OpenSolid\OpenApiBundle\OpenApi\Analyser\Guesser\Operation\OperationResponseGuesser; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; +use Symfony\Component\HttpKernel\Attribute\MapQueryString; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; + +class GuessersTest extends TestCase +{ + public function testGuessersIgnoreOtherReflectorsAndAnnotations(): void + { + $schema = new OAT\Schema(); + + foreach ([new OperationQueryParameterGuesser(), new OperationRequestBodyGuesser(), new OperationResponseGuesser()] as $guesser) { + $guesser->guess(new \ReflectionClass(GuesserFixture::class), new OAT\Get(), new Context()); + $guesser->guess(new \ReflectionMethod(GuesserFixture::class, 'scalarQuery'), $schema, new Context()); + } + + $this->assertTrue(Undefined::isDefault($schema->properties)); + } + + public function testQueryStringOfScalarTypeIsIgnored(): void + { + $operation = new OAT\Get(); + (new OperationQueryParameterGuesser())->guess(new \ReflectionMethod(GuesserFixture::class, 'scalarQuery'), $operation, new Context()); + + $this->assertTrue(Undefined::isDefault($operation->parameters)); + } + + public function testDuplicatedQueryParametersAreIgnored(): void + { + $operation = new OAT\Get(); + (new OperationQueryParameterGuesser())->guess(new \ReflectionMethod(GuesserFixture::class, 'duplicatedQuery'), $operation, new Context()); + + $this->assertSame(['name', 'untyped'], array_map(static fn (OA\Parameter $p) => $p->name, $operation->parameters)); + } + + public function testDeclaredRequestBodyIsKept(): void + { + $requestBody = new OAT\RequestBody(description: 'Declared'); + $operation = new OAT\Post(requestBody: $requestBody); + (new OperationRequestBodyGuesser())->guess(new \ReflectionMethod(GuesserFixture::class, 'unionPayload'), $operation, new Context()); + + $this->assertSame($requestBody, $operation->requestBody); + } + + public function testRequestBodyIsOnlyGuessedForWriteOperations(): void + { + $operation = new OAT\Get(); + (new OperationRequestBodyGuesser())->guess(new \ReflectionMethod(GuesserFixture::class, 'unionPayload'), $operation, new Context()); + + $this->assertTrue(Undefined::isDefault($operation->requestBody)); + } + + public function testUnionTypedPayloadIsIgnored(): void + { + $operation = new OAT\Post(); + (new OperationRequestBodyGuesser())->guess(new \ReflectionMethod(GuesserFixture::class, 'unionPayload'), $operation, new Context()); + + $this->assertTrue(Undefined::isDefault($operation->requestBody)); + } + + public function testDeclaredResponseWithBodyIsKept(): void + { + $operation = new OAT\Get(responses: [ + $ok = new OAT\Response(response: 200, ref: '#/components/responses/Ok'), + $text = new OAT\Response(response: 'default', description: 'Text', content: new OAT\MediaType(mediaType: 'text/plain')), + ]); + (new OperationResponseGuesser())->guess(new \ReflectionMethod(GuesserFixture::class, 'object'), $operation, new Context()); + + $this->assertSame([$ok, $text], $operation->responses); + $this->assertSame([], $ok->_unmerged); + } + + public function testInferredBodyIsMergedIntoDeclaredSuccessResponse(): void + { + $operation = new OAT\Get(responses: [ + $accepted = new OAT\Response(response: 202, description: 'Accepted'), + ]); + (new OperationResponseGuesser())->guess(new \ReflectionMethod(GuesserFixture::class, 'object'), $operation, new Context()); + + $this->assertSame([$accepted], $operation->responses); + $this->assertCount(1, $accepted->_unmerged); + $this->assertInstanceOf(OA\JsonContent::class, $accepted->_unmerged[0]); + } + + public function testDefaultSuccessResponseIsAddedWhenNoneIsDeclared(): void + { + $operation = new OAT\Get(responses: [ + $notFound = new OAT\Response(response: 404, description: 'Not found'), + ]); + (new OperationResponseGuesser())->guess(new \ReflectionMethod(GuesserFixture::class, 'object'), $operation, new Context()); + + $this->assertSame(['200', '404'], array_map(static fn (OA\Response $r) => (string) $r->response, $operation->responses)); + $this->assertSame($notFound, $operation->responses[1]); + } +} + +class GuesserFixture +{ + public function scalarQuery(#[MapQueryString] string $query): void + { + } + + public function duplicatedQuery(#[MapQueryParameter] ?string $name = null, #[MapQueryString] ?GuesserQuery $query = null, #[MapQueryParameter(name: 'name')] ?string $other = null): void + { + } + + public function unionPayload(#[MapRequestPayload] GuesserQuery|\stdClass $payload): void + { + } + + public function object(): \stdClass + { + return new \stdClass(); + } +} + +class GuesserQuery +{ + #[OAT\QueryParameter] + public ?string $name = null; + + #[OAT\QueryParameter] + public $untyped; + + public $ignored; +} diff --git a/tests/Unit/OpenApi/ProcessorsTest.php b/tests/Unit/OpenApi/ProcessorsTest.php new file mode 100644 index 0000000..4a5d2b1 --- /dev/null +++ b/tests/Unit/OpenApi/ProcessorsTest.php @@ -0,0 +1,312 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\OpenApi; + +use OpenApi\Analysis; +use OpenApi\Annotations as OA; +use OpenApi\Attributes as OAT; +use OpenApi\Context; +use OpenApi\Generator; +use OpenApi\Serializer; +use OpenApi\Undefined; +use OpenSolid\OpenApiBundle\Attribute\Path; +use OpenSolid\OpenApiBundle\OpenApi\Constraint\ConstraintSchemaApplier; +use OpenSolid\OpenApiBundle\OpenApi\Processor\AugmentEnumSchemas; +use OpenSolid\OpenApiBundle\OpenApi\Processor\AugmentParameterConstraints; +use OpenSolid\OpenApiBundle\OpenApi\Processor\AugmentPathParameters; +use OpenSolid\OpenApiBundle\OpenApi\Processor\AugmentQueryParameters; +use OpenSolid\OpenApiBundle\OpenApi\Processor\AugmentSchemas; +use OpenSolid\OpenApiBundle\OpenApi\Processor\CleanupAnnotations; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\Validator\Constraints as Assert; + +class ProcessorsTest extends TestCase +{ + public function testCleanupWithoutOpenApi(): void + { + $analysis = new Analysis([], $this->context()); + (new CleanupAnnotations())($analysis); + + $this->assertNull($analysis->openapi); + } + + public function testCleanupWithoutComponents(): void + { + $analysis = $this->analysis('{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}}'); + (new CleanupAnnotations())($analysis); + + $this->assertTrue(Undefined::isDefault($analysis->openapi->components)); + } + + public function testCleanupWithEmptyComponents(): void + { + $analysis = $this->analysis('{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "components": {"securitySchemes": {"bearer": {"type": "http", "scheme": "bearer"}}}}'); + (new CleanupAnnotations())($analysis); + + $this->assertTrue(Undefined::isDefault($analysis->openapi->components->schemas, $analysis->openapi->components->responses, $analysis->openapi->components->parameters)); + } + + public function testCleanupRemovesUnusedComponents(): void + { + $analysis = $this->analysis(<<<'JSON' + { + "openapi": "3.1.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/foo": { + "get": { + "responses": { + "200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Used"}}}}, + "400": {"$ref": "#/components/responses/400"} + } + }, + "post": {} + } + }, + "components": { + "schemas": { + "Used": {"type": "object", "properties": {"nested": {"$ref": "#/components/schemas/Nested"}}}, + "Nested": {"type": "string"}, + "Unused": {"type": "object", "properties": {"other": {"$ref": "#/components/schemas/OnlyUsedByUnused"}}}, + "OnlyUsedByUnused": {"type": "string"}, + "CycleA": {"type": "object", "properties": {"b": {"$ref": "#/components/schemas/CycleB"}}}, + "CycleB": {"type": "object", "properties": {"a": {"$ref": "#/components/schemas/CycleA"}}} + }, + "responses": { + "400": {"description": "Bad request"}, + "418": {"description": "Teapot"} + }, + "parameters": { + "named": {"name": "named", "in": "query"} + } + } + } + JSON); + $components = $analysis->openapi->components; + + // a duplicated response, a nameless parameter and a schema shared twice + $components->responses[] = $duplicated = new OA\Response(['response' => 400, 'description' => 'Duplicated', '_context' => $this->context()]); + $components->parameters[] = $nameless = new OA\Parameter(['in' => 'query', '_context' => $this->context()]); + $shared = new OA\Schema(['ref' => '#/components/schemas/Nested', '_context' => $this->context()]); + $used = array_values(array_filter($components->schemas, static fn (OA\Schema $s) => 'Used' === $s->schema))[0]; + $used->allOf = [$shared, $shared]; + $analysis->addAnnotations([$duplicated, $nameless, $shared], $this->context()); + + (new CleanupAnnotations())($analysis); + + $this->assertSame(['Used', 'Nested'], array_values(array_map(static fn (OA\Schema $s) => $s->schema, $components->schemas))); + $this->assertSame(['Bad request'], array_values(array_map(static fn (OA\Response $r) => $r->description, $components->responses))); + $this->assertSame(['named'], array_values(array_map(static fn (OA\Parameter $p) => $p->name, $components->parameters))); + $this->assertFalse($analysis->annotations->offsetExists($duplicated)); + $this->assertFalse($analysis->annotations->offsetExists($nameless)); + } + + public function testCleanupRemovesAllUnusedComponents(): void + { + $analysis = $this->analysis('{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "components": {"schemas": {"Unused": {"type": "string"}}, "responses": {"400": {"description": "Bad"}}}}'); + $analysis->openapi->components->parameters = [$nameless = new OA\Parameter(['in' => 'query', '_context' => $this->context()])]; + $analysis->addAnnotation($nameless, $this->context()); + + (new CleanupAnnotations())($analysis); + + $components = $analysis->openapi->components; + $this->assertTrue(Undefined::isDefault($components->schemas, $components->responses, $components->parameters)); + } + + public function testAugmentPathParameters(): void + { + $parameters = [ + 'class' => new Path(name: 'class', enum: ProcessorStatus::class), + 'values' => new Path(name: 'values', enum: ['x', ProcessorStatus::Draft, ProcessorPure::Foo]), + 'string' => new Path(name: 'string', enum: 'single'), + 'explicit' => new Path(name: 'explicit', schema: new OAT\Schema(format: 'ulid', enum: ['a']), format: 'uuid', enum: ['b']), + 'none' => new Path(name: 'none'), + ]; + (new AugmentPathParameters())(new Analysis(array_values($parameters), $this->context())); + + $this->assertSame(['draft'], $parameters['class']->schema->enum); + $this->assertSame(['x', 'draft', 'Foo'], $parameters['values']->schema->enum); + $this->assertSame(['single'], $parameters['string']->schema->enum); + $this->assertSame('ulid', $parameters['explicit']->schema->format); + $this->assertSame(['a'], $parameters['explicit']->schema->enum); + $this->assertTrue(Undefined::isDefault($parameters['none']->schema)); + } + + public function testAugmentQueryParametersIgnoresParametersWithoutReflector(): void + { + $parameter = new OAT\QueryParameter(name: 'q'); + $parameter->_context = new Context(['reflector' => new \ReflectionMethod(self::class, 'context')], $this->context()); + (new AugmentQueryParameters())->setGenerator(new Generator())(new Analysis([$parameter], $this->context())); + + $this->assertTrue(Undefined::isDefault($parameter->schema)); + } + + public function testAugmentQueryParametersNormalizesEnumDefaults(): void + { + $pure = $this->queryParameterOf('pure'); + $backed = $this->queryParameterOf('backed'); + (new AugmentQueryParameters())->setGenerator(new Generator())(new Analysis([$pure, $backed], $this->context())); + + $this->assertSame('Foo', $pure->schema->default); + $this->assertSame('draft', $backed->schema->default); + } + + public function testAugmentQueryParametersKeepsExplicitRequiredOnProperties(): void + { + $explicit = $this->queryParameterOf('optional', required: true); + $inferred = $this->queryParameterOf('optional'); + (new AugmentQueryParameters())->setGenerator(new Generator())(new Analysis([$explicit, $inferred], $this->context())); + + $this->assertTrue($explicit->required); + $this->assertFalse($inferred->required); + } + + public function testAugmentParameterConstraintsMarksConstrainedParametersAsRequired(): void + { + $parameter = new OAT\QueryParameter(name: 'single', required: false); + $parameter->_context = new Context(['reflector' => new \ReflectionProperty(ProcessorFixture::class, 'single')], $this->context()); + (new AugmentParameterConstraints())(new Analysis([$parameter], $this->context())); + + $this->assertTrue($parameter->required); + } + + public function testAugmentEnumSchemas(): void + { + $withoutSchema = new OAT\QueryParameter(name: 'q'); + $withoutReflector = new OA\Schema(['_context' => $this->context()]); + $nullableEnum = $this->schemaOf('size'); + $className = new OA\Schema(['type' => \stdClass::class, '_context' => $this->context()]); + $customType = new OA\Schema(['type' => 'custom', '_context' => $this->context()]); + $enumItems = new OA\Schema(['type' => 'array', 'items' => new OA\Items(['type' => ProcessorStatus::class, '_context' => $this->context()]), '_context' => $this->context()]); + + (new AugmentEnumSchemas())->setGenerator(new Generator())(new Analysis([$withoutSchema, $withoutReflector, $nullableEnum, $className, $customType, $enumItems], $this->context())); + + $this->assertTrue(Undefined::isDefault($withoutSchema->schema, $withoutReflector->type)); + $this->assertTrue($nullableEnum->nullable); + $this->assertSame([1, 2, null], $nullableEnum->enum); + $this->assertSame('integer', $nullableEnum->type); + $this->assertTrue(Undefined::isDefault($className->type)); + $this->assertSame('custom', $customType->type); + $this->assertSame(['draft'], $enumItems->items->enum); + $this->assertSame('string', $enumItems->items->type); + } + + public function testAugmentSchemasDeniesNullForMandatoryFields(): void + { + $schema = new OA\Schema(['properties' => [ + $single = $this->propertyOf('single', ['string', 'null']), + $onlyNull = $this->propertyOf('onlyNull', ['null']), + $many = $this->propertyOf('many', ['string', 'integer', 'null']), + ], '_context' => $this->context()]); + + (new AugmentSchemas(new ConstraintSchemaApplier()))(new Analysis([$schema], $this->context())); + + $this->assertSame(['single', 'onlyNull', 'many'], $schema->required); + $this->assertSame('string', $single->type); + $this->assertTrue(Undefined::isDefault($onlyNull->type)); + $this->assertSame(['string', 'integer'], $many->type); + $this->assertFalse($single->nullable); + } + + public function testConstraintApplierEdgeCases(): void + { + $applier = new ConstraintSchemaApplier(); + + $schema = new OA\Schema(['type' => 'string', '_context' => $this->context()]); + $applier->apply($schema, new \ReflectionProperty(ProcessorFixture::class, 'notAnArray')); + $this->assertTrue(Undefined::isDefault($schema->items)); + + $schema = new OA\Schema(['_context' => $this->context()]); + $applier->apply($schema, new \ReflectionProperty(ProcessorFixture::class, 'emptyPattern')); + $this->assertTrue(Undefined::isDefault($schema->pattern)); + + $this->assertNull(ConstraintSchemaApplier::resolveReflector(new \ReflectionClass(self::class))); + } + + private function queryParameterOf(string $property, ?bool $required = null): OAT\QueryParameter + { + $parameter = new OAT\QueryParameter(name: $property, required: $required); + $parameter->_context = new Context(['reflector' => new \ReflectionProperty(ProcessorQuery::class, $property)], $this->context()); + + return $parameter; + } + + private function schemaOf(string $property): OA\Schema + { + return new OA\Schema(['_context' => new Context(['reflector' => new \ReflectionProperty(ProcessorFixture::class, $property)], $this->context())]); + } + + private function propertyOf(string $property, array $type): OA\Property + { + return new OA\Property(['property' => $property, 'type' => $type, '_context' => new Context(['reflector' => new \ReflectionProperty(ProcessorFixture::class, $property)], $this->context())]); + } + + private function analysis(string $json): Analysis + { + $openapi = (new Serializer())->deserialize($json, OA\OpenApi::class); + + return new Analysis([$openapi], $this->context()); + } + + private function context(): Context + { + return new Context(['logger' => new NullLogger()]); + } +} + +enum ProcessorStatus: string +{ + case Draft = 'draft'; +} + +enum ProcessorSize: int +{ + case Small = 1; + case Large = 2; +} + +enum ProcessorPure +{ + case Foo; +} + +class ProcessorFixture +{ + public ?ProcessorSize $size = null; + + #[Assert\NotNull] + public ?string $single = null; + + #[Assert\NotNull] + public ?string $onlyNull = null; + + #[Assert\NotNull] + public string|int|null $many = null; + + #[Assert\All([new Assert\Positive()])] + public mixed $notAnArray = null; + + #[Assert\Regex('')] + public ?string $emptyPattern = null; +} + +class ProcessorQuery +{ + public ProcessorPure $pure = ProcessorPure::Foo; + + public ProcessorStatus $backed = ProcessorStatus::Draft; + + public ?string $optional = null; +} diff --git a/tests/Unit/Routing/ApiRouteTraitTest.php b/tests/Unit/Routing/ApiRouteTraitTest.php new file mode 100644 index 0000000..fb5c87b --- /dev/null +++ b/tests/Unit/Routing/ApiRouteTraitTest.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\Routing; + +use OpenSolid\OpenApiBundle\Routing\Attribute\Delete; +use OpenSolid\OpenApiBundle\Routing\Attribute\Get; +use OpenSolid\OpenApiBundle\Routing\Attribute\Head; +use OpenSolid\OpenApiBundle\Routing\Attribute\Options; +use OpenSolid\OpenApiBundle\Routing\Attribute\Patch; +use OpenSolid\OpenApiBundle\Routing\Attribute\Post; +use OpenSolid\OpenApiBundle\Routing\Attribute\Put; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; + +class ApiRouteTraitTest extends TestCase +{ + public static function provideAttributes(): iterable + { + yield [Get::class, 'GET']; + yield [Post::class, 'POST']; + yield [Put::class, 'PUT']; + yield [Patch::class, 'PATCH']; + yield [Delete::class, 'DELETE']; + yield [Head::class, 'HEAD']; + yield [Options::class, 'OPTIONS']; + } + + #[DataProvider('provideAttributes')] + public function testRoute(string $class, string $method): void + { + $attribute = new $class('/foo', name: 'foo', requirements: ['id' => '\d+'], statusCode: 299); + + $this->assertSame($method, $attribute->getMethod()); + $this->assertSame([$method], $attribute->route->methods); + $this->assertSame('/foo', $attribute->route->path); + $this->assertSame('foo', $attribute->operationId); + $this->assertSame(['id' => '\d+'], $attribute->requirements); + $this->assertSame(299, $attribute->statusCode); + } + + public function testConditions(): void + { + $this->assertNull((new Get('/foo'))->route->condition); + $this->assertSame('a', (new Get('/foo', when: 'a'))->route->condition); + $this->assertSame('a and b', (new Get('/foo', condition: 'a', when: 'b'))->route->condition); + } + + public function testUnknownPropertyIsNull(): void + { + $attribute = new Get('/foo'); + $logger = new class extends AbstractLogger { + public array $messages = []; + + public function log($level, $message, array $context = []): void + { + $this->messages[] = (string) $message; + } + }; + $attribute->_context->logger = $logger; + + $this->assertNull($attribute->unknown); + $this->assertSame(['Property "unknown" doesn\'t exist in a @'.Get::class.'()'], $logger->messages); + } + + public function testCustomPropertiesAreNotSerialized(): void + { + $data = json_decode(json_encode(new Get('/foo', name: 'foo', itemsType: 'Foo', when: 'true', statusCode: 299)), true); + + $this->assertSame(['operationId' => 'foo'], $data); + } +} diff --git a/tests/Unit/Serializer/OpenApiSerializerMetadataLoaderTest.php b/tests/Unit/Serializer/OpenApiSerializerMetadataLoaderTest.php new file mode 100644 index 0000000..3e9f98a --- /dev/null +++ b/tests/Unit/Serializer/OpenApiSerializerMetadataLoaderTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\Serializer; + +use OpenSolid\OpenApiBundle\Attribute\Property; +use OpenSolid\OpenApiBundle\Serializer\Mapping\Loader\OpenApiSerializerMetadataLoader; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Mapping\AttributeMetadata; +use Symfony\Component\Serializer\Mapping\ClassMetadata; + +class OpenApiSerializerMetadataLoaderTest extends TestCase +{ + public function testGroups(): void + { + $metadata = new ClassMetadata(GroupsFixture::class); + $metadata->addAttributeMetadata(new AttributeMetadata('name')); + + $this->assertTrue((new OpenApiSerializerMetadataLoader())->loadClassMetadata($metadata)); + + $attributes = $metadata->getAttributesMetadata(); + $this->assertSame(['read', 'write'], $attributes['name']->getGroups()); + $this->assertSame(['read'], $attributes['id']->getGroups()); + $this->assertSame([], $attributes['other']->getGroups()); + } + + public function testWithoutGroups(): void + { + $metadata = new ClassMetadata(\stdClass::class); + + $this->assertFalse((new OpenApiSerializerMetadataLoader())->loadClassMetadata($metadata)); + } +} + +class GroupsFixture +{ + #[Property(groups: ['read', 'write'])] + public string $name; + + #[Property(groups: ['read'])] + public string $id; + + #[Property] + public string $other; +} diff --git a/tests/Unit/Validator/OpenApiValidatorMetadataLoaderTest.php b/tests/Unit/Validator/OpenApiValidatorMetadataLoaderTest.php new file mode 100644 index 0000000..aaf6fd8 --- /dev/null +++ b/tests/Unit/Validator/OpenApiValidatorMetadataLoaderTest.php @@ -0,0 +1,213 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Unit\Validator; + +use OpenSolid\OpenApiBundle\Attribute\Property; +use OpenSolid\OpenApiBundle\Validator\Mapping\Loader\FormatMetadataLoader; +use OpenSolid\OpenApiBundle\Validator\Mapping\Loader\OpenApiValidatorMetadataLoader; +use OpenSolid\OpenApiBundle\Validator\Mapping\Loader\ValidationMetadataLoader; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Mapping\CascadingStrategy; +use Symfony\Component\Validator\Mapping\ClassMetadata; + +class OpenApiValidatorMetadataLoaderTest extends TestCase +{ + public function testClassWithoutPropertyAttributes(): void + { + $metadata = new ClassMetadata(\stdClass::class); + + $this->assertFalse($this->createLoader()->loadClassMetadata($metadata)); + } + + public function testFormats(): void + { + $this->assertConstraints([ + 'uuid' => [Assert\Uuid::class, Assert\NotNull::class], + 'email' => [Assert\Email::class, Assert\NotNull::class], + 'password' => [Assert\PasswordStrength::class, Assert\NotNull::class], + 'date' => [Assert\Date::class, Assert\NotNull::class], + 'dateTime' => [Assert\DateTime::class, Assert\NotNull::class], + 'currency' => [Assert\Currency::class, Assert\NotNull::class], + 'other' => [Assert\NotNull::class], + ], FormatFixture::class); + } + + public function testKeywords(): void + { + $this->assertConstraints([ + 'length' => [Assert\Length::class], + 'minLength' => [Assert\Length::class], + 'count' => [Assert\Count::class], + 'maxItems' => [Assert\Count::class], + 'minimum' => [Assert\GreaterThanOrEqual::class], + 'exclusiveMinimumFlag' => [Assert\GreaterThan::class], + 'exclusiveMinimumValue' => [Assert\GreaterThan::class], + 'maximum' => [Assert\LessThanOrEqual::class], + 'exclusiveMaximumFlag' => [Assert\LessThan::class], + 'exclusiveMaximumValue' => [Assert\LessThan::class], + 'pattern' => [Assert\Regex::class], + 'unique' => [Assert\Unique::class], + 'enumClass' => [Assert\Choice::class], + 'enumValues' => [Assert\Choice::class], + 'emptyEnum' => [], + 'multipleOf' => [Assert\DivisibleBy::class], + 'const' => [Assert\EqualTo::class], + 'object' => [Assert\NotNull::class], + 'untyped' => [], + ], KeywordFixture::class); + } + + public function testConstraintValues(): void + { + $metadata = new ClassMetadata(KeywordFixture::class); + $this->createLoader()->loadClassMetadata($metadata); + + $this->assertSame(['draft', 'Foo', 'bar'], $this->constraintsOf($metadata, 'enumValues')[0]->choices); + $this->assertSame(['draft'], $this->constraintsOf($metadata, 'enumClass')[0]->choices); + $this->assertSame(5, $this->constraintsOf($metadata, 'exclusiveMinimumValue')[0]->value); + $this->assertSame(1, $this->constraintsOf($metadata, 'exclusiveMinimumFlag')[0]->value); + $this->assertSame(['Default', 'KeywordFixture'], $this->constraintsOf($metadata, 'length')[0]->groups); + $this->assertSame(['create'], $this->constraintsOf($metadata, 'minLength')[0]->groups); + $this->assertSame(CascadingStrategy::CASCADE, $metadata->getPropertyMetadata('object')[0]->getCascadingStrategy()); + } + + private function assertConstraints(array $expected, string $class): void + { + $metadata = new ClassMetadata($class); + + $this->assertTrue($this->createLoader()->loadClassMetadata($metadata)); + + foreach ($expected as $property => $constraints) { + $this->assertSame($constraints, array_map(static fn (Constraint $c): string => $c::class, $this->constraintsOf($metadata, $property)), $property); + } + } + + /** + * @return list + */ + private function constraintsOf(ClassMetadata $metadata, string $property): array + { + $constraints = []; + foreach ($metadata->getPropertyMetadata($property) as $propertyMetadata) { + array_push($constraints, ...$propertyMetadata->getConstraints()); + } + + return $constraints; + } + + private function createLoader(): OpenApiValidatorMetadataLoader + { + return new OpenApiValidatorMetadataLoader([new FormatMetadataLoader(), new ValidationMetadataLoader()]); + } +} + +enum FixtureStatus: string +{ + case Draft = 'draft'; +} + +enum FixturePure +{ + case Foo; +} + +class FormatFixture +{ + #[Property(format: 'uuid')] + public string $uuid; + + #[Property(format: 'email')] + public string $email; + + #[Property(format: 'password')] + public string $password; + + #[Property(format: 'date')] + public string $date; + + #[Property(format: 'date-time')] + public string $dateTime; + + #[Property(format: 'currency')] + public string $currency; + + #[Property(format: 'other')] + public string $other; +} + +class KeywordFixture +{ + #[Property(maxLength: 10)] + public ?string $length = null; + + #[Property(minLength: 1, groups: ['create'])] + public ?string $minLength = null; + + #[Property(minItems: 1)] + public ?array $count = null; + + #[Property(maxItems: 1)] + public ?array $maxItems = null; + + #[Property(minimum: 1, exclusiveMinimum: false)] + public ?int $minimum = null; + + #[Property(minimum: 1, exclusiveMinimum: true)] + public ?int $exclusiveMinimumFlag = null; + + #[Property(exclusiveMinimum: 5)] + public ?int $exclusiveMinimumValue = null; + + #[Property(maximum: 1)] + public ?int $maximum = null; + + #[Property(maximum: 1, exclusiveMaximum: true)] + public ?int $exclusiveMaximumFlag = null; + + #[Property(exclusiveMaximum: 5.5)] + public ?float $exclusiveMaximumValue = null; + + #[Property(pattern: '/^a/')] + public ?string $pattern = null; + + #[Property(uniqueItems: true)] + public ?array $unique = null; + + #[Property(enum: FixtureStatus::class)] + public ?string $enumClass = null; + + #[Property(enum: [FixtureStatus::Draft, FixturePure::Foo, 'bar'])] + public ?string $enumValues = null; + + #[Property(enum: [])] + public ?string $emptyEnum = null; + + #[Property(multipleOf: 5)] + public ?int $multipleOf = null; + + #[Property(const: 'foo')] + public ?string $const = null; + + #[Property] + public FixtureObject $object; + + #[Property] + public $untyped; +} + +class FixtureObject +{ +}