diff --git a/CHANGELOG.md b/CHANGELOG.md index 6086ab0fa81..d0fd67b3f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v4.0.6 + +### Bug fixes + +* [195c4e788](https://github.com/api-platform/core/commit/195c4e7883520416e042ac78143b18652a216fbf) fix(hydra): hydra context changed (#6710) +* [4f65ef2d0](https://github.com/api-platform/core/commit/4f65ef2d061215df348e3505856f0f41c7c909ed) fix(metadata): providing parameter constraints skips automatic ones (#6756) +* [5a8ef115a](https://github.com/api-platform/core/commit/5a8ef115a90791992a6c1325fb6d1ac458b22153) fix(symfony): ECMA-262 pattern with RegExp validator (#6733) +* [67c5a2a24](https://github.com/api-platform/core/commit/67c5a2a2463bca94f0997b4fab1248a08994465b) fix(laravel): jsonapi error serialization (#6755) +* [ac6f667f3](https://github.com/api-platform/core/commit/ac6f667f301f6c4c399a707faf00567239bd98d8) fix(laravel): collection relations other than HasMany (#6737) + +### Features + +* [cecd77149](https://github.com/api-platform/core/commit/cecd77149795c1a455ac72bc3ed0606413e69900) feat(laravel): use laravel cache setting (#6751) + ## v4.0.5 ### Bug fixes @@ -152,6 +166,12 @@ Notes: * [0d5f35683](https://github.com/api-platform/core/commit/0d5f356839eb6aa9f536044abe4affa736553e76) feat(laravel): laravel component (#5882) +## v3.4.5 + +### Bug fixes + +* [fc8fa00a1](https://github.com/api-platform/core/commit/fc8fa00a19320b65547a60537261959c11f8e6a8) fix(hydra): iri template when using query parameter (#6742) + ## v3.4.4 ### Bug fixes diff --git a/docs/guides/doctrine-search-filter.php b/docs/guides/doctrine-search-filter.php index 14287554fb4..bcf94c57336 100644 --- a/docs/guides/doctrine-search-filter.php +++ b/docs/guides/doctrine-search-filter.php @@ -120,24 +120,21 @@ public function testGetDocumentation(): void $this->assertJsonContains([ 'search' => [ '@type' => 'IriTemplate', - 'template' => '/books.jsonld{?id,title,author}', + 'template' => '/books.jsonld{?title,author}', 'variableRepresentation' => 'BasicRepresentation', 'mapping' => [ - [ - '@type' => 'IriTemplateMapping', - 'variable' => 'id', - 'property' => 'id', - ], [ '@type' => 'IriTemplateMapping', 'variable' => 'title', 'property' => 'title', + 'required' => false, ], [ '@type' => 'IriTemplateMapping', 'variable' => 'author', 'property' => 'author', - ] + 'required' => false, + ], ], ], ]); diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index 0786c42e65e..b6595a4fde1 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -17,7 +17,6 @@ use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; use ApiPlatform\Metadata\FilterInterface; -use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\QueryParameterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -133,10 +132,9 @@ public function setNormalizer(NormalizerInterface $normalizer): void /** * Returns the content of the Hydra search property. * - * @param FilterInterface[] $filters - * @param array $parameters + * @param FilterInterface[] $filters */ - private function getSearch(string $resourceClass, array $parts, array $filters, array|Parameters|null $parameters, string $hydraPrefix): array + private function getSearch(string $resourceClass, array $parts, array $filters, ?Parameters $parameters, string $hydraPrefix): array { $variables = []; $mapping = []; @@ -153,13 +151,19 @@ private function getSearch(string $resourceClass, array $parts, array $filters, continue; } - if (!($property = $parameter->getProperty()) && ($filterId = $parameter->getFilter()) && ($filter = $this->getFilter($filterId))) { - foreach ($filter->getDescription($resourceClass) as $variable => $description) { - // This is a practice induced by PHP and is not necessary when implementing URI template + if (($filterId = $parameter->getFilter()) && \is_string($filterId) && ($filter = $this->getFilter($filterId))) { + $filterDescription = $filter->getDescription($resourceClass); + + foreach ($filterDescription as $variable => $description) { + // // This is a practice induced by PHP and is not necessary when implementing URI template if (str_ends_with((string) $variable, '[]')) { continue; } + if (($prop = $parameter->getProperty()) && ($description['property'] ?? null) !== $prop) { + continue; + } + // :property is a pattern allowed when defining parameters $k = str_replace(':property', $description['property'], $key); $variable = str_replace($description['property'], $k, $variable); @@ -171,10 +175,12 @@ private function getSearch(string $resourceClass, array $parts, array $filters, $mapping[] = $m; } - continue; + if ($filterDescription) { + continue; + } } - if (!$property) { + if (!($property = $parameter->getProperty())) { continue; } diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index c6a5997283b..af619b81e46 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -37,9 +37,34 @@ public function normalize(mixed $object, ?string $format = null, array $context $jsonApiObject = $this->itemNormalizer->normalize($object, $format, $context); $error = $jsonApiObject['data']['attributes']; $error['id'] = $jsonApiObject['data']['id']; - $error['type'] = $jsonApiObject['data']['id']; + if (isset($error['type'])) { + $error['links'] = ['type' => $error['type']]; + } + + if (!isset($error['code']) && method_exists($object, 'getId')) { + $error['code'] = $object->getId(); + } + + if (!isset($error['violations'])) { + return ['errors' => [$error]]; + } + + $errors = []; + foreach ($error['violations'] as $violation) { + $e = ['detail' => $violation['message']] + $error; + if (isset($error['links']['type'])) { + $type = $error['links']['type']; + $e['links']['type'] = \sprintf('%s/%s', $type, $violation['propertyPath']); + $e['id'] = str_replace($type, $e['links']['type'], $e['id']); + } + if (isset($e['code'])) { + $e['code'] = \sprintf('%s/%s', $error['code'], $violation['propertyPath']); + } + unset($e['violations']); + $errors[] = $e; + } - return ['errors' => [$error]]; + return ['errors' => $errors]; } /** diff --git a/src/JsonApi/Serializer/ReservedAttributeNameConverter.php b/src/JsonApi/Serializer/ReservedAttributeNameConverter.php index e25ad566dc9..1c9e5239e8b 100644 --- a/src/JsonApi/Serializer/ReservedAttributeNameConverter.php +++ b/src/JsonApi/Serializer/ReservedAttributeNameConverter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\JsonApi\Serializer; +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -44,6 +45,10 @@ public function normalize(string $propertyName, ?string $class = null, ?string $ $propertyName = $this->nameConverter->normalize($propertyName, $class, $format, $context); } + if ($class && is_a($class, ProblemExceptionInterface::class, true)) { + return $propertyName; + } + if (isset(self::JSON_API_RESERVED_ATTRIBUTES[$propertyName])) { $propertyName = self::JSON_API_RESERVED_ATTRIBUTES[$propertyName]; } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 0a9272b5572..a71a43ebb92 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -58,6 +58,7 @@ use ApiPlatform\JsonApi\JsonSchema\SchemaFactory as JsonApiSchemaFactory; use ApiPlatform\JsonApi\Serializer\CollectionNormalizer as JsonApiCollectionNormalizer; use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer as JsonApiEntrypointNormalizer; +use ApiPlatform\JsonApi\Serializer\ErrorNormalizer as JsonApiErrorNormalizer; use ApiPlatform\JsonApi\Serializer\ItemNormalizer as JsonApiItemNormalizer; use ApiPlatform\JsonApi\Serializer\ObjectNormalizer as JsonApiObjectNormalizer; use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; @@ -297,7 +298,7 @@ public function register(): void }); $this->app->extend(PropertyMetadataFactoryInterface::class, function (PropertyInfoPropertyMetadataFactory $inner, Application $app) { - /** @var ConfigRepository */ + /** @var ConfigRepository $config */ $config = $app['config']; return new CachePropertyMetadataFactory( @@ -313,12 +314,12 @@ public function register(): void $app->make(ResourceClassResolverInterface::class) ), ), - true === $config->get('app.debug') ? 'array' : 'file' + true === $config->get('app.debug') ? 'array' : $config->get('cache.default', 'file') ); }); $this->app->singleton(PropertyNameCollectionFactoryInterface::class, function (Application $app) { - /** @var ConfigRepository */ + /** @var ConfigRepository $config */ $config = $app['config']; return new CachePropertyNameCollectionMetadataFactory( @@ -331,7 +332,7 @@ public function register(): void ) ) ), - true === $config->get('app.debug') ? 'array' : 'file' + true === $config->get('app.debug') ? 'array' : $config->get('cache.default', 'file') ); }); @@ -345,7 +346,7 @@ public function register(): void // TODO: add cached metadata factories $this->app->singleton(ResourceMetadataCollectionFactoryInterface::class, function (Application $app) { - /** @var ConfigRepository */ + /** @var ConfigRepository $config */ $config = $app['config']; $formats = $config->get('api-platform.formats'); @@ -401,7 +402,7 @@ public function register(): void $app->make('filters') ) ), - true === $config->get('app.debug') ? 'array' : 'file' + true === $config->get('app.debug') ? 'array' : $config->get('cache.default', 'file') ); }); @@ -907,6 +908,10 @@ public function register(): void return new ReservedAttributeNameConverter($app->make(NameConverterInterface::class)); }); + if (interface_exists(FieldsBuilderEnumInterface::class)) { + $this->registerGraphQl($this->app); + } + $this->app->singleton(JsonApiEntrypointNormalizer::class, function (Application $app) { return new JsonApiEntrypointNormalizer( $app->make(ResourceMetadataCollectionFactoryInterface::class), @@ -946,9 +951,11 @@ public function register(): void ); }); - if (interface_exists(FieldsBuilderEnumInterface::class)) { - $this->registerGraphQl($this->app); - } + $this->app->singleton(JsonApiErrorNormalizer::class, function (Application $app) { + return new JsonApiErrorNormalizer( + $app->make(JsonApiItemNormalizer::class), + ); + }); $this->app->singleton(JsonApiObjectNormalizer::class, function (Application $app) { return new JsonApiObjectNormalizer( @@ -985,6 +992,7 @@ public function register(): void $list->insert($app->make(JsonApiEntrypointNormalizer::class), -800); $list->insert($app->make(JsonApiCollectionNormalizer::class), -985); $list->insert($app->make(JsonApiItemNormalizer::class), -890); + $list->insert($app->make(JsonApiErrorNormalizer::class), -790); $list->insert($app->make(JsonApiObjectNormalizer::class), -995); if (interface_exists(FieldsBuilderEnumInterface::class)) { diff --git a/src/Laravel/ApiResource/Error.php b/src/Laravel/ApiResource/Error.php index fc4a2963a48..69e0bbc4e1c 100644 --- a/src/Laravel/ApiResource/Error.php +++ b/src/Laravel/ApiResource/Error.php @@ -52,7 +52,7 @@ name: '_api_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: ['groups' => ['jsonapi'], 'skip_null_values' => true], - uriTemplate: '/errros/{status}.jsonapi' + uriTemplate: '/errors/{status}.jsonapi' ), ], graphQlOperations: [] @@ -124,6 +124,12 @@ public function getStatusCode(): int return $this->status; } + #[Groups(['jsonapi'])] + public function getId(): string + { + return (string) $this->status; + } + /** * @param array $headers */ @@ -132,7 +138,7 @@ public function setHeaders(array $headers): void $this->headers = $headers; } - #[Groups(['jsonld', 'jsonproblem'])] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] public function getType(): string { return $this->type; @@ -149,7 +155,7 @@ public function setType(string $type): void $this->type = $type; } - #[Groups(['jsonld', 'jsonproblem'])] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] public function getStatus(): ?int { return $this->status; @@ -160,13 +166,13 @@ public function setStatus(int $status): void $this->status = $status; } - #[Groups(['jsonld', 'jsonproblem'])] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] public function getDetail(): ?string { return $this->detail; } - #[Groups(['jsonld', 'jsonproblem'])] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] public function getInstance(): ?string { return $this->instance; diff --git a/src/Laravel/ApiResource/ValidationError.php b/src/Laravel/ApiResource/ValidationError.php index 0eb36413462..92197a74e2b 100644 --- a/src/Laravel/ApiResource/ValidationError.php +++ b/src/Laravel/ApiResource/ValidationError.php @@ -86,25 +86,25 @@ public function getId(): string } #[SerializedName('description')] - #[Groups(['jsonapi', 'jsonld', 'json'])] + #[Groups(['jsonld', 'json'])] public function getDescription(): string { return $this->detail; } - #[Groups(['jsonld', 'json'])] + #[Groups(['jsonld', 'json', 'jsonapi'])] public function getType(): string { return '/validation_errors/'.$this->id; } - #[Groups(['jsonld', 'json'])] + #[Groups(['jsonld', 'json', 'jsonapi'])] public function getTitle(): ?string { return 'Validation Error'; } - #[Groups(['jsonld', 'json'])] + #[Groups(['jsonld', 'json', 'jsonapi'])] private string $detail; public function getDetail(): ?string @@ -117,7 +117,7 @@ public function setDetail(string $detail): void $this->detail = $detail; } - #[Groups(['jsonld', 'json'])] + #[Groups(['jsonld', 'json', 'jsonapi'])] public function getStatus(): ?int { return $this->status; @@ -128,7 +128,7 @@ public function setStatus(int $status): void $this->status = $status; } - #[Groups(['jsonld', 'json'])] + #[Groups(['jsonld', 'json', 'jsonapi'])] public function getInstance(): ?string { return null; @@ -138,7 +138,7 @@ public function getInstance(): ?string * @return array */ #[SerializedName('violations')] - #[Groups(['json', 'jsonld'])] + #[Groups(['json', 'jsonld', 'jsonapi'])] public function getViolations(): array { return $this->violations; diff --git a/src/Laravel/State/ValidateProvider.php b/src/Laravel/State/ValidateProvider.php index 499c4a0e7ef..5e065ffd1b4 100644 --- a/src/Laravel/State/ValidateProvider.php +++ b/src/Laravel/State/ValidateProvider.php @@ -74,7 +74,14 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $body; } - $validator = Validator::make($request->request->all(), $rules); + // In Symfony, validation is done on the Resource object (here $body) using Deserialization before Validation + // Here, we did not deserialize yet, we validate on the raw body before. + $validationBody = $request->request->all(); + if ('jsonapi' === $request->getRequestFormat()) { + $validationBody = $validationBody['data']['attributes']; + } + + $validator = Validator::make($validationBody, $rules); if ($validator->fails()) { throw $this->getValidationError($validator, new ValidationException($validator)); } diff --git a/src/Laravel/Tests/EloquentTest.php b/src/Laravel/Tests/EloquentTest.php index 23960b7ad20..ad847eeced9 100644 --- a/src/Laravel/Tests/EloquentTest.php +++ b/src/Laravel/Tests/EloquentTest.php @@ -386,11 +386,11 @@ public function testRangeGreaterThanEqualFilter(): void 'Content-Type' => ['application/merge-patch+json'], ] ); - $response = $this->get('api/books?isbn_range[gte]='.$updated['isbn'], ['Accept' => ['application/ld+json']]); - $this->assertSame($response->json()['member'][0]['@id'], $bookBefore['@id']); - $this->assertSame($response->json()['member'][1]['@id'], $bookAfter['@id']); - $this->assertSame($response->json()['totalItems'], 2); + $json = $response->json(); + $this->assertSame($json['member'][0]['@id'], $bookBefore['@id']); + $this->assertSame($json['member'][1]['@id'], $bookAfter['@id']); + $this->assertSame($json['totalItems'], 2); } public function testWrongOrderFilter(): void diff --git a/src/Laravel/Tests/JsonApiTest.php b/src/Laravel/Tests/JsonApiTest.php index 74833c3ebc3..95492a9b0c6 100644 --- a/src/Laravel/Tests/JsonApiTest.php +++ b/src/Laravel/Tests/JsonApiTest.php @@ -39,6 +39,7 @@ protected function defineEnvironment($app): void tap($app['config'], function (Repository $config): void { $config->set('api-platform.formats', ['jsonapi' => ['application/vnd.api+json']]); $config->set('api-platform.docs_formats', ['jsonapi' => ['application/vnd.api+json']]); + $config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]); $config->set('app.debug', true); }); } @@ -48,13 +49,15 @@ public function testGetEntrypoint(): void $response = $this->get('/api/', ['accept' => ['application/vnd.api+json']]); $response->assertStatus(200); $response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8'); - $this->assertJsonContains([ - 'links' => [ - 'self' => 'http://localhost/api', - 'book' => 'http://localhost/api/books', + $this->assertJsonContains( + [ + 'links' => [ + 'self' => 'http://localhost/api', + 'book' => 'http://localhost/api/books', + ], ], - ], - $response->json()); + $response->json() + ); } public function testGetCollection(): void @@ -209,4 +212,77 @@ public function testRelationWithGroups(): void $this->assertArrayHasKey('relation', $content['data']['relationships']); $this->assertArrayHasKey('data', $content['data']['relationships']['relation']); } + + public function testValidateJsonApi(): void + { + $response = $this->postJson( + '/api/issue6745/rule_validations', + [ + 'data' => [ + 'type' => 'string', + 'attributes' => ['max' => 3], + ], + ], + [ + 'accept' => 'application/vnd.api+json', + 'content_type' => 'application/vnd.api+json', + ] + ); + + $response->assertStatus(422); + $response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8'); + $json = $response->json(); + $this->assertJsonContains([ + 'errors' => [ + [ + 'detail' => 'The prop field is required.', + 'title' => 'Validation Error', + 'status' => 422, + 'code' => '58350900e0fc6b8e/prop', + ], + [ + 'detail' => 'The max field must be less than 2.', + 'title' => 'Validation Error', + 'status' => 422, + 'code' => '58350900e0fc6b8e/max', + ], + ], + ], $json); + + $this->assertArrayHasKey('id', $json['errors'][0]); + $this->assertArrayHasKey('links', $json['errors'][0]); + $this->assertArrayHasKey('type', $json['errors'][0]['links']); + + $response = $this->postJson( + '/api/issue6745/rule_validations', + [ + 'data' => [ + 'type' => 'string', + 'attributes' => [ + 'prop' => 1, + 'max' => 1, + ], + ], + ], + [ + 'accept' => 'application/vnd.api+json', + 'content_type' => 'application/vnd.api+json', + ] + ); + $response->assertStatus(201); + } + + public function testNotFound(): void + { + $response = $this->get('/api/books/notfound', headers: ['accept' => 'application/vnd.api+json']); + $response->assertStatus(404); + $response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8'); + + $this->assertJsonContains([ + 'links' => ['type' => '/errors/404'], + 'title' => 'An error occurred', + 'status' => 404, + 'detail' => 'Not Found', + ], $response->json()['errors'][0]); + } } diff --git a/src/Laravel/workbench/app/ApiResource/RuleValidation.php b/src/Laravel/workbench/app/ApiResource/RuleValidation.php new file mode 100644 index 00000000000..2922b5d66cd --- /dev/null +++ b/src/Laravel/workbench/app/ApiResource/RuleValidation.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + uriTemplate: '/issue6745/rule_validations', + operations: [new Post()], + rules: ['prop' => 'required', 'max' => 'lt:2'] +)] +class RuleValidation +{ + public function __construct(public int $prop, public ?int $max = null) + { + } +} diff --git a/src/Laravel/workbench/app/Providers/WorkbenchServiceProvider.php b/src/Laravel/workbench/app/Providers/WorkbenchServiceProvider.php index a6237450d1c..eb0d9f4d5ce 100644 --- a/src/Laravel/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/src/Laravel/workbench/app/Providers/WorkbenchServiceProvider.php @@ -32,6 +32,7 @@ public function register(): void { $config = $this->app['config']; $config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]); + $config->set('cache.default', 'null'); } /** diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 9e13fb2121a..066244428d1 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -155,16 +155,12 @@ private function addFilterMetadata(Parameter $parameter): Parameter return $parameter; } - if (null === $parameter->getSchema() && $filter instanceof JsonSchemaFilterInterface) { - if ($schema = $filter->getSchema($parameter)) { - $parameter = $parameter->withSchema($schema); - } + if (null === $parameter->getSchema() && $filter instanceof JsonSchemaFilterInterface && $schema = $filter->getSchema($parameter)) { + $parameter = $parameter->withSchema($schema); } - if (null === $parameter->getOpenApi() && $filter instanceof OpenApiParameterFilterInterface) { - if ($openApiParameter = $filter->getOpenApiParameters($parameter)) { - $parameter = $parameter->withOpenApi($openApiParameter); - } + if (null === $parameter->getOpenApi() && $filter instanceof OpenApiParameterFilterInterface && ($openApiParameter = $filter->getOpenApiParameters($parameter)) && $openApiParameter instanceof OpenApiParameter) { + $parameter = $parameter->withOpenApi($openApiParameter); } return $parameter; @@ -194,10 +190,6 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withSchema($schema); } - if (null === $parameter->getProperty() && ($property = $description[$key]['property'] ?? null)) { - $parameter = $parameter->withProperty($property); - } - $currentKey = $key; if (null === $parameter->getProperty() && isset($properties[$key])) { $parameter = $parameter->withProperty($key); @@ -216,10 +208,6 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withRequired($required); } - if (null === $parameter->getOpenApi() && ($openApi = $description[$key]['openapi'] ?? null) && $openApi instanceof OpenApiParameter) { - $parameter = $parameter->withOpenApi($openApi); - } - return $this->addFilterMetadata($parameter); } diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index d5c4e2f6edb..d83e83064b7 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -261,12 +261,26 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } + $entityClass = $this->getFilterClass($operation); $openapiParameters = $openapiOperation->getParameters(); foreach ($operation->getParameters() ?? [] as $key => $p) { if (false === $p->getOpenApi()) { continue; } + if (($f = $p->getFilter()) && \is_string($f) && $this->filterLocator && $this->filterLocator->has($f)) { + $filter = $this->filterLocator->get($f); + foreach ($filter->getDescription($entityClass) as $name => $description) { + if ($prop = $p->getProperty()) { + $name = str_replace($prop, $key, $name); + } + + $openapiParameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $f); + } + + continue; + } + $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; $defaultParameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); @@ -557,57 +571,98 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection private function getFiltersParameters(CollectionOperationInterface|HttpOperation $operation): array { $parameters = []; - $resourceFilters = $operation->getFilters(); + $entityClass = $this->getFilterClass($operation); + foreach ($resourceFilters ?? [] as $filterId) { if (!$this->filterLocator->has($filterId)) { continue; } $filter = $this->filterLocator->get($filterId); - $entityClass = $operation->getClass(); - if ($options = $operation->getStateOptions()) { - if ($options instanceof DoctrineOptions && $options->getEntityClass()) { - $entityClass = $options->getEntityClass(); - } + foreach ($filter->getDescription($entityClass) as $name => $description) { + $parameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $filterId); + } + } - if ($options instanceof DoctrineODMOptions && $options->getDocumentClass()) { - $entityClass = $options->getDocumentClass(); - } + return $parameters; + } + + private function getFilterClass(HttpOperation $operation): ?string + { + $entityClass = $operation->getClass(); + if ($options = $operation->getStateOptions()) { + if ($options instanceof DoctrineOptions && $options->getEntityClass()) { + return $options->getEntityClass(); } - foreach ($filter->getDescription($entityClass) as $name => $data) { - $schema = $data['schema'] ?? []; + if ($options instanceof DoctrineODMOptions && $options->getDocumentClass()) { + return $options->getDocumentClass(); + } + } - if (isset($data['type']) && \in_array($data['type'] ?? null, Type::$builtinTypes, true) && !isset($schema['type'])) { - $schema += $this->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)); - } + return $entityClass; + } - if (!isset($schema['type'])) { - $schema['type'] = 'string'; - } + private function getFilterParameter(string $name, array $description, string $shortName, string $filter): Parameter + { + if (isset($description['swagger'])) { + trigger_deprecation('api-platform/core', '4.0', \sprintf('Using the "swagger" field of the %s::getDescription() (%s) is deprecated.', $filter, $shortName)); + } - $style = 'array' === ($schema['type'] ?? null) && \in_array( - $data['type'], - [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], - true - ) ? 'deepObject' : 'form'; + if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) { + $schema = $description['schema'] ?? []; - $parameter = isset($data['openapi']) && $data['openapi'] instanceof Parameter ? $data['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $data['is_collection'] ?? false); + if (isset($description['type']) && \in_array($description['type'], Type::$builtinTypes, true) && !isset($schema['type'])) { + $schema += $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)); + } - if ('' === $parameter->getDescription() && ($description = $data['description'] ?? '')) { - $parameter = $parameter->withDescription($description); - } + if (!isset($schema['type'])) { + $schema['type'] = 'string'; + } - if (false === $parameter->getRequired() && false !== ($required = $data['required'] ?? false)) { - $parameter = $parameter->withRequired($required); - } + $style = 'array' === ($schema['type'] ?? null) && \in_array( + $description['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + true + ) ? 'deepObject' : 'form'; + + $parameter = isset($description['openapi']) && $description['openapi'] instanceof Parameter ? $description['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $description['is_collection'] ?? false); + + if ('' === $parameter->getDescription() && ($str = $description['description'] ?? '')) { + $parameter = $parameter->withDescription($str); + } - $parameters[] = $parameter->withSchema($schema); + if (false === $parameter->getRequired() && false !== ($required = $description['required'] ?? false)) { + $parameter = $parameter->withRequired($required); } + + return $parameter->withSchema($schema); } - return $parameters; + trigger_deprecation('api-platform/core', '4.0', \sprintf('Not using "%s" on the "openapi" field of the %s::getDescription() (%s) is deprecated.', Parameter::class, $filter, $shortName)); + $schema = $description['schema'] ?? (\in_array($description['type'], Type::$builtinTypes, true) ? $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)) : ['type' => 'string']); + + return new Parameter( + $name, + 'query', + $description['description'] ?? '', + $description['required'] ?? false, + $description['openapi']['deprecated'] ?? false, + $description['openapi']['allowEmptyValue'] ?? true, + $schema, + 'array' === $schema['type'] && \in_array( + $description['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + true + ) ? 'deepObject' : 'form', + $description['openapi']['explode'] ?? ('array' === $schema['type']), + $description['openapi']['allowReserved'] ?? false, + $description['openapi']['example'] ?? null, + isset( + $description['openapi']['examples'] + ) ? new \ArrayObject($description['openapi']['examples']) : null + ); } private function getPaginationParameters(CollectionOperationInterface|HttpOperation $operation): array diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index d37b1ddd392..d95b74b1973 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -879,7 +879,8 @@ public function testInvoke(): void 'type' => 'string', 'enum' => ['asc', 'desc'], ]), - ] + ], + deprecated: false ), $filteredPath->getGet()); $paginatedPath = $paths->getPath('/paginated'); diff --git a/src/State/ApiResource/Error.php b/src/State/ApiResource/Error.php index 4a5061a65a1..ec7e816824f 100644 --- a/src/State/ApiResource/Error.php +++ b/src/State/ApiResource/Error.php @@ -90,6 +90,12 @@ public function __construct( } } + #[Groups(['jsonapi'])] + public function getId(): string + { + return (string) $this->status; + } + #[SerializedName('trace')] #[Groups(['trace'])] public ?array $originalTrace = null; @@ -129,7 +135,7 @@ public function setHeaders(array $headers): void $this->headers = $headers; } - #[Groups(['jsonld', 'jsonproblem'])] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] public function getType(): string { return $this->type; diff --git a/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php b/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php index 84dfebe8ea4..e34124f08ec 100644 --- a/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php +++ b/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php @@ -91,6 +91,10 @@ public function create(string $resourceClass): ResourceMetadataCollection private function addSchemaValidation(Parameter $parameter, ?array $schema = null, ?bool $required = null, ?OpenApiParameter $openApi = null): Parameter { + if (null !== $parameter->getConstraints()) { + return $parameter; + } + $schema ??= $parameter->getSchema(); $required ??= $parameter->getRequired() ?? false; $openApi ??= $parameter->getOpenApi(); @@ -130,7 +134,7 @@ private function addSchemaValidation(Parameter $parameter, ?array $schema = null } if (isset($schema['pattern'])) { - $assertions[] = new Regex($schema['pattern']); + $assertions[] = new Regex('#'.$schema['pattern'].'#'); } if (isset($schema['maxLength']) || isset($schema['minLength'])) { diff --git a/tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptions.php b/tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptions.php new file mode 100644 index 00000000000..ab812e30227 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptions.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\State\CollectionProvider; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsEntity; + +#[GetCollection( + uriTemplate: 'filter_with_state_options', + stateOptions: new Options(entityClass: FilterWithStateOptionsEntity::class), + parameters: ['date' => new QueryParameter(filter: 'filter_with_state_options_date', property: 'dummyDate')], + provider: CollectionProvider::class +)] +#[ApiFilter(DateFilter::class, alias: 'filter_with_state_options_date', properties: ['dummyDate' => DateFilter::EXCLUDE_NULL])] +final class FilterWithStateOptions +{ + public function __construct(public readonly string $id, public readonly \DateTimeImmutable $dummyDate, public readonly string $name) + { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index 9b828885a17..be6b288fba9 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -62,7 +62,7 @@ 'length' => new QueryParameter(schema: ['maxLength' => 1, 'minLength' => 3]), 'array' => new QueryParameter(schema: ['minItems' => 2, 'maxItems' => 3]), 'multipleOf' => new QueryParameter(schema: ['multipleOf' => 2]), - 'pattern' => new QueryParameter(schema: ['pattern' => '/\d/']), + 'pattern' => new QueryParameter(schema: ['pattern' => '\d']), ], provider: [self::class, 'collectionProvider'] )] diff --git a/tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsEntity.php b/tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsEntity.php new file mode 100644 index 00000000000..45f05f491bd --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsEntity.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class FilterWithStateOptionsEntity +{ + public function __construct( + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + #[ORM\Column(type: 'date_immutable', nullable: true)] + public ?\DateTimeImmutable $dummyDate = null, + #[ORM\Column(type: 'string', nullable: true)] + public ?string $name = null, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/Filter/PatternFilter.php b/tests/Fixtures/TestBundle/Filter/PatternFilter.php index cd601059b30..6a1940fe8a4 100644 --- a/tests/Fixtures/TestBundle/Filter/PatternFilter.php +++ b/tests/Fixtures/TestBundle/Filter/PatternFilter.php @@ -36,7 +36,7 @@ public function getDescription(string $resourceClass): array 'type' => 'string', 'required' => false, 'schema' => [ - 'pattern' => '/^(pattern|nrettap)$/', + 'pattern' => '^(pattern|nrettap)$', ], ], ]; diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index ba12b748a76..c0ba47b3fd1 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -14,7 +14,9 @@ namespace ApiPlatform\Tests\Functional\Parameters; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FilterWithStateOptions; use ApiPlatform\Tests\Fixtures\TestBundle\Document\SearchFilterParameter as SearchFilterParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; @@ -29,7 +31,7 @@ final class DoctrineTest extends ApiTestCase */ public static function getResources(): array { - return [SearchFilterParameter::class]; + return [SearchFilterParameter::class, FilterWithStateOptions::class]; } public function testDoctrineEntitySearchFilter(): void @@ -46,11 +48,10 @@ public function testDoctrineEntitySearchFilter(): void $this->assertArraySubset(['hydra:search' => [ 'hydra:template' => \sprintf('/%s{?foo,fooAlias,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],q,id,createdAt}', $route), - 'hydra:mapping' => [ - ['@type' => 'IriTemplateMapping', 'variable' => 'foo', 'property' => 'foo'], - ], ]], $a); + $this->assertArraySubset(['@type' => 'IriTemplateMapping', 'variable' => 'fooAlias', 'property' => 'foo'], $a['hydra:search']['hydra:mapping'][1]); + $response = self::createClient()->request('GET', $route.'?fooAlias=baz'); $a = $response->toArray(); $this->assertCount(1, $a['hydra:member']); @@ -74,8 +75,13 @@ public function testDoctrineEntitySearchFilter(): void public function testGraphQl(): void { - $this->recreateSchema([SearchFilterParameter::class]); - $this->loadFixtures($this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class); + if ($_SERVER['EVENT_LISTENERS_BACKWARD_COMPATIBILITY_LAYER'] ?? false) { + $this->markTestSkipped('Parameters are not supported in BC mode.'); + } + + $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); $object = 'searchFilterParameters'; $response = self::createClient()->request('POST', '/graphql', ['json' => [ 'query' => \sprintf('{ %s(foo: "bar") { edges { node { id foo createdAt } } } }', $object), @@ -100,6 +106,7 @@ public function testGraphQl(): void public function testPropertyPlaceholderFilter(): void { + static::bootKernel(); $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; $this->recreateSchema([$resource]); $this->loadFixtures($resource); @@ -109,15 +116,41 @@ public function testPropertyPlaceholderFilter(): void $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); } - /** - * @param class-string $resource - */ - private function loadFixtures(string $resource): void + public function testStateOptions(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + static::bootKernel(); + $container = static::$kernel->getContainer(); + $this->recreateSchema([FilterWithStateOptionsEntity::class]); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + $d = new \DateTimeImmutable(); + $manager->persist(new FilterWithStateOptionsEntity(dummyDate: $d, name: 'current')); + $manager->persist(new FilterWithStateOptionsEntity(name: 'null')); + $manager->persist(new FilterWithStateOptionsEntity(dummyDate: $d->add(\DateInterval::createFromDateString('1 day')), name: 'after')); + $manager->flush(); + $response = self::createClient()->request('GET', 'filter_with_state_options?date[before]='.$d->format('Y-m-d')); + $a = $response->toArray(); + $this->assertEquals('/filter_with_state_options{?date[before],date[strictly_before],date[after],date[strictly_after]}', $a['hydra:search']['hydra:template']); + $this->assertCount(1, $a['hydra:member']); + $this->assertEquals('current', $a['hydra:member'][0]['name']); + $response = self::createClient()->request('GET', 'filter_with_state_options?date[strictly_after]='.$d->format('Y-m-d')); + $a = $response->toArray(); + $this->assertCount(1, $a['hydra:member']); + $this->assertEquals('after', $a['hydra:member'][0]['name']); + } + + public function loadFixtures(string $resourceClass): void { - $manager = $this->getManager(); + $container = static::$kernel->getContainer(); + $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); + $manager = $registry->getManager(); $date = new \DateTimeImmutable('2024-01-21'); foreach (['foo', 'foo', 'foo', 'bar', 'bar', 'baz'] as $t) { - $s = new $resource(); + $s = new $resourceClass(); $s->setFoo($t); if ('bar' === $t) { $s->setCreatedAt($date); diff --git a/tests/Functional/Parameters/ValidationTest.php b/tests/Functional/Parameters/ValidationTest.php index 655e145c1f9..7ae22b9a00d 100644 --- a/tests/Functional/Parameters/ValidationTest.php +++ b/tests/Functional/Parameters/ValidationTest.php @@ -113,7 +113,7 @@ public static function provideQueryStrings(): array public function testBlank(): void { - $response = self::createClient()->request('GET', 'validate_parameters?blank=f'); + self::createClient()->request('GET', 'validate_parameters?blank=f'); $this->assertResponseIsSuccessful(); } @@ -146,4 +146,10 @@ public function testValidatePropertyPlaceholder(): void ], ], $response->toArray(false)); } + + public function testValidatePattern(): void + { + self::createClient()->request('GET', 'validate_parameters?pattern=2'); + $this->assertResponseIsSuccessful(); + } }