diff --git a/CHANGELOG.md b/CHANGELOG.md index e582cb82501..4e223feed83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## v5.0.3 + +API Platform 5.0.3 contains every change shipped in [v4.4.4](#v444) except the revert of #8600 ([#8637](https://github.com/api-platform/core/pull/8637)): 5.0 keeps documenting filterless array query parameters as both `key` and `key[]`, now guarded against `key[][]`, plus the changes below. + +### Bug fixes + +* [13a9c11db](https://github.com/api-platform/core/commit/13a9c11dbd7529bf792411ec002b45dca5c3c29b) fix(doctrine): avoid more deprecation from doctrine +* [37798944c](https://github.com/api-platform/core/commit/37798944c6dee10cdc6c7e4d05f008b3adb71660) fix(hal): use spl_object_id() for circular reference counters (#8657) +* [ff1bfbdde](https://github.com/api-platform/core/commit/ff1bfbddedc18fd582c855fc90da2c8660a24c68) fix(metadata): do not mutate parameters shared between operations (#8622) +* [88fab3bab](https://github.com/api-platform/core/commit/88fab3babb64e7befd44045d71ef423e97b00f66) fix(openapi): don't suffix [] on bracketed array keys (#8636) +* [ce647452b](https://github.com/api-platform/core/commit/ce647452bbc8e3ad340950f745e8c95f9fa1b75d) fix(serializer): catch MissingConstructorArgumentsException (#8659) +* [b10e80f61](https://github.com/api-platform/core/commit/b10e80f611cb0c3a9e7164a0a2156e846e456a14) fix(symfony): check HttpClientTrait before loading the test client (#8613) +* [c3eabbb9a](https://github.com/api-platform/core/commit/c3eabbb9a6bf63f7796759afd5db334b78c3d880) fix(symfony): hide the "Other API docs" label when there is only a single option (#8631) + +### Dependencies + +* [40711a61c](https://github.com/api-platform/core/commit/40711a61c99cee7db83256342bf6220c8925543c) chore(openapi): require json-schema ^5.0.2 (#8630) +* [5fcc64d0b](https://github.com/api-platform/core/commit/5fcc64d0b719d5cf1f725e5550615817f28b9c8d) chore: suggest ext-deepclone (#8658) + ## v5.0.2 API Platform 5.0.2 contains every change shipped in [v4.4.3](#v443), plus the changes below. @@ -74,6 +93,15 @@ API Platform 5.0 contains every change shipped in [v4.4.0](#v440), plus the remo * `ApiPlatform\Symfony\Bundle\Test\ApiTestCase` and its helpers now live in the new `api-platform/test` package; the classes in the old namespace are deprecated and will be removed in 6.0 ([#7887](https://github.com/api-platform/core/pull/7887)). * Symfony `^7.4 || ^8.0`; support for `6.4` is dropped. +## v4.4.4 + +### Bug fixes + +* [1335871be](https://github.com/api-platform/core/commit/1335871beff7aa5d5505879732890f44b1ba2e8e) fix(hydra): handle string and null serialization groups in DocumentationNormalizer (#7890) +* [b6555c846](https://github.com/api-platform/core/commit/b6555c846a472a82099e3d7fcf6f7ec6b71aaba8) fix(laravel): ignore invalid range filter values (#8632) +* [56c0be24b](https://github.com/api-platform/core/commit/56c0be24b74366e88bdf16721b479b4cfb43877f) revert(openapi): array query params bracket notation (#8637) +* [454262ba8](https://github.com/api-platform/core/commit/454262ba8a3614d2cced01e092dcc7e14d4bb778) fix(symfony): avoid deprecated translation command probe (#8627) + ## v4.4.3 ### Bug fixes diff --git a/composer.json b/composer.json index 2a203be68de..5be44f7275e 100644 --- a/composer.json +++ b/composer.json @@ -195,6 +195,7 @@ "webonyx/graphql-php": "^15.0" }, "suggest": { + "ext-deepclone": "To speed up the PHP files metadata cache with symfony/var-exporter 8.1+ (install symfony/deepclone with PIE).", "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", "elasticsearch/elasticsearch": "To support Elasticsearch.", "opensearch-project/opensearch-php": "To support OpenSearch (^2.5).", diff --git a/src/Doctrine/Orm/Extension/OrderExtension.php b/src/Doctrine/Orm/Extension/OrderExtension.php index 1d3deaf0b5b..f99b70705f3 100644 --- a/src/Doctrine/Orm/Extension/OrderExtension.php +++ b/src/Doctrine/Orm/Extension/OrderExtension.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Extension; +use ApiPlatform\Doctrine\Orm\Util\BCHelper; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; @@ -69,7 +70,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $rootAlias, substr($field, 0, $pos)); $field = \sprintf('%s.%s', $alias, substr($field, $pos + 1)); } - $queryBuilder->addOrderBy($field, $order); + $queryBuilder->addOrderBy($field, BCHelper::sortDirection($order)); } return; @@ -82,7 +83,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator } foreach ($identifiers as $identifier) { - $queryBuilder->addOrderBy("{$rootAlias}.{$identifier}", $this->order); + $queryBuilder->addOrderBy("{$rootAlias}.{$identifier}", BCHelper::sortDirection($this->order)); } } } diff --git a/src/Doctrine/Orm/Filter/OrderFilter.php b/src/Doctrine/Orm/Filter/OrderFilter.php index bdf59d6b283..b7f7020c993 100644 --- a/src/Doctrine/Orm/Filter/OrderFilter.php +++ b/src/Doctrine/Orm/Filter/OrderFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; use ApiPlatform\Doctrine\Common\Filter\OrderFilterTrait; use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait; +use ApiPlatform\Doctrine\Orm\Util\BCHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; @@ -278,10 +279,10 @@ protected function filterProperty(string $property, mixed $value, QueryBuilder $ $nullRankHiddenField = \sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); $queryBuilder->addSelect(\sprintf('CASE WHEN %s.%s IS NULL THEN 0 ELSE 1 END AS HIDDEN %s', $alias, $field, $nullRankHiddenField)); - $queryBuilder->addOrderBy($nullRankHiddenField, $nullsDirection); + $queryBuilder->addOrderBy($nullRankHiddenField, BCHelper::sortDirection($nullsDirection)); } - $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), $direction); + $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), BCHelper::sortDirection($direction)); } /** diff --git a/src/Doctrine/Orm/Filter/SortFilter.php b/src/Doctrine/Orm/Filter/SortFilter.php index cf10dbb32c2..e9c4c148e9b 100644 --- a/src/Doctrine/Orm/Filter/SortFilter.php +++ b/src/Doctrine/Orm/Filter/SortFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\BCHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\JsonSchemaFilterInterface; @@ -74,32 +75,11 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q if (null !== $nullsDirection) { $nullRankHiddenField = \sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); $queryBuilder->addSelect(\sprintf('CASE WHEN %s.%s IS NULL THEN 0 ELSE 1 END AS HIDDEN %s', $alias, $field, $nullRankHiddenField)); - $queryBuilder->addOrderBy($nullRankHiddenField, self::bcSortDirection($nullsDirection)); + $queryBuilder->addOrderBy($nullRankHiddenField, BCHelper::sortDirection($nullsDirection)); } } - $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), self::bcSortDirection($direction)); - } - - /** - * @param 'ASC'|'DESC' $direction - */ - private static function bcSortDirection(string $direction): string|object - { - if (!interface_exists('Doctrine\ORM\Tools\Pagination\PaginatorInterface')) { - // doctrine/orm < 3.7 - return $direction; - } - - if (!enum_exists('SortDirection')) { - // PHP < 8.6 (Safety check because doctrine/orm already provides a polyfill) - return $direction; - } - - return match ($direction) { - 'ASC' => \SortDirection::Ascending, - 'DESC' => \SortDirection::Descending, - }; + $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), BCHelper::sortDirection($direction)); } /** diff --git a/src/Doctrine/Orm/Tests/Extension/OrderExtensionTest.php b/src/Doctrine/Orm/Tests/Extension/OrderExtensionTest.php index 684a7bb723c..9150f3b4bde 100644 --- a/src/Doctrine/Orm/Tests/Extension/OrderExtensionTest.php +++ b/src/Doctrine/Orm/Tests/Extension/OrderExtensionTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Orm\Extension\OrderExtension; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\EmbeddedDummy; +use ApiPlatform\Doctrine\Orm\Util\BCHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator; use ApiPlatform\Metadata\GetCollection; use Doctrine\ORM\EntityManager; @@ -37,7 +38,7 @@ public function testApplyToCollectionWithValidOrder(): void $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getDQLPart('orderBy')->shouldBeCalled()->willReturn([]); - $queryBuilderProphecy->addOrderBy('o.name', 'asc')->shouldBeCalled()->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addOrderBy('o.name', BCHelper::sortDirection('asc'))->shouldBeCalled()->willReturn($queryBuilderProphecy); $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn(['name']); @@ -58,7 +59,7 @@ public function testApplyToCollectionWithWrongOrder(): void $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getDQLPart('orderBy')->shouldBeCalled()->willReturn([]); - $queryBuilderProphecy->addOrderBy('o.name', 'asc')->shouldNotBeCalled(); + $queryBuilderProphecy->addOrderBy('o.name', BCHelper::sortDirection('asc'))->shouldNotBeCalled(); $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn(['name']); @@ -79,7 +80,7 @@ public function testApplyToCollectionWithOrderOverridden(): void $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getDQLPart('orderBy')->shouldBeCalled()->willReturn([]); - $queryBuilderProphecy->addOrderBy('o.foo', 'DESC')->shouldBeCalled()->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addOrderBy('o.foo', BCHelper::sortDirection('DESC'))->shouldBeCalled()->willReturn($queryBuilderProphecy); $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn(['name']); @@ -100,8 +101,8 @@ public function testApplyToCollectionWithOrderOverriddenWithNoDirection(): void $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getDQLPart('orderBy')->shouldBeCalled()->willReturn([]); - $queryBuilderProphecy->addOrderBy('o.foo', 'ASC')->shouldBeCalled()->willReturn($queryBuilderProphecy); - $queryBuilderProphecy->addOrderBy('o.bar', 'DESC')->shouldBeCalled()->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addOrderBy('o.foo', BCHelper::sortDirection('ASC'))->shouldBeCalled()->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addOrderBy('o.bar', BCHelper::sortDirection('DESC'))->shouldBeCalled()->willReturn($queryBuilderProphecy); $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn(['name']); @@ -124,7 +125,7 @@ public function testApplyToCollectionWithOrderOverriddenWithAssociation(): void $queryBuilderProphecy->getDQLPart('orderBy')->shouldBeCalled()->willReturn([]); $queryBuilderProphecy->getDQLPart('join')->willReturn(['o' => []])->shouldBeCalled(); $queryBuilderProphecy->innerJoin('o.author', 'author_a1', null, null)->shouldBeCalled()->willReturn($queryBuilderProphecy); - $queryBuilderProphecy->addOrderBy('author_a1.name', 'ASC')->shouldBeCalled()->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addOrderBy('author_a1.name', BCHelper::sortDirection('ASC'))->shouldBeCalled()->willReturn($queryBuilderProphecy); $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn(['name']); @@ -145,7 +146,7 @@ public function testApplyToCollectionWithOrderOverriddenWithEmbeddedAssociation( $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getDQLPart('orderBy')->shouldBeCalled()->willReturn([]); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); - $queryBuilderProphecy->addOrderBy('o.embeddedDummy.dummyName', 'DESC')->shouldBeCalled()->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addOrderBy('o.embeddedDummy.dummyName', BCHelper::sortDirection('DESC'))->shouldBeCalled()->willReturn($queryBuilderProphecy); $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn(['id']); diff --git a/src/Doctrine/Orm/Util/BCHelper.php b/src/Doctrine/Orm/Util/BCHelper.php new file mode 100644 index 00000000000..f4da465c126 --- /dev/null +++ b/src/Doctrine/Orm/Util/BCHelper.php @@ -0,0 +1,54 @@ + + * + * 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\Doctrine\Orm\Util; + +/** + * @internal + */ +final class BCHelper +{ + private function __construct() + { + } + + /** + * Maps a string sort direction to the \SortDirection enum expected by doctrine/orm >= 3.7, + * keeping the plain string for older versions. + * + * doctrine/orm 3.7 deprecates passing strings (or null) as sort directions to + * QueryBuilder::orderBy()/addOrderBy() in favor of the PHP 8.6 \SortDirection enum + * (provided on older PHP versions through symfony/polyfill-php86). Since API Platform + * still supports doctrine/orm ^2.17 || ^3.3, the enum cannot be used unconditionally. + * + * @see https://github.com/doctrine/orm/blob/3.7.x/UPGRADE.md + */ + public static function sortDirection(string $direction): string|object + { + if (!interface_exists('Doctrine\ORM\Tools\Pagination\PaginatorInterface')) { + // doctrine/orm < 3.7 + return $direction; + } + + if (!enum_exists('SortDirection')) { + // PHP < 8.6 (Safety check because doctrine/orm already provides a polyfill) + return $direction; + } + + return match (strtoupper($direction)) { + 'ASC' => \SortDirection::Ascending, + 'DESC' => \SortDirection::Descending, + default => $direction, + }; + } +} diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index 64ab681d6ab..2c645978fa5 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -370,19 +370,19 @@ private function isMaxDepthReached(array $attributesMetadata, string $class, str */ protected function isHalCircularReference(object $object, array &$context): bool { - $objectHash = spl_object_hash($object); + $objectId = spl_object_id($object); $circularReferenceLimit = $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT]; - if (isset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash])) { - if ($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] >= $circularReferenceLimit) { - unset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]); + if (isset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectId])) { + if ($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectId] >= $circularReferenceLimit) { + unset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectId]); return true; } - ++$context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]; + ++$context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectId]; } else { - $context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] = 1; + $context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectId] = 1; } return false; diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 9ebcd489bea..a86d70b35d0 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -167,8 +167,16 @@ private function getClass(string $resourceClass, ApiResource $resourceMetadata, */ private function getPropertyMetadataFactoryContext(ApiResource $resourceMetadata): array { - $normalizationGroups = $resourceMetadata->getNormalizationContext()[AbstractNormalizer::GROUPS] ?? null; - $denormalizationGroups = $resourceMetadata->getDenormalizationContext()[AbstractNormalizer::GROUPS] ?? null; + $normalizationGroups = ($resourceMetadata->getNormalizationContext() ?? [])[AbstractNormalizer::GROUPS] ?? null; + $denormalizationGroups = ($resourceMetadata->getDenormalizationContext() ?? [])[AbstractNormalizer::GROUPS] ?? null; + + if (\is_string($normalizationGroups)) { + $normalizationGroups = [$normalizationGroups]; + } + if (\is_string($denormalizationGroups)) { + $denormalizationGroups = [$denormalizationGroups]; + } + $propertyContext = [ 'normalization_groups' => $normalizationGroups, 'denormalization_groups' => $denormalizationGroups, diff --git a/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php b/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php index 1780a949f8a..94be6a771c7 100644 --- a/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php @@ -1162,4 +1162,54 @@ public function testNormalizeNoEntrypointAndHideHydraOperation(): void $this->assertEquals($expected, $documentationNormalizer->normalize($documentation, null, [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false])); } + + public function testNormalizeWithStringGroups(): void + { + $title = 'Test Api'; + $desc = 'test'; + $version = '0.0.0'; + $documentation = new Documentation(new ResourceNameCollection(['dummy' => 'dummy']), $title, $desc, $version); + + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->expects($this->once())->method('create')->with('dummy', $this->isArray())->willReturn(new PropertyNameCollection(['name'])); + + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->expects($this->once())->method('create')->with('dummy', 'name', $this->isArray())->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withDescription('name')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true) + ); + + $resourceMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->expects($this->once())->method('create')->with('dummy')->willReturn(new ResourceMetadataCollection('dummy', [ + (new ApiResource()) + ->withShortName('dummy') + ->withOperations(new Operations([ + 'get' => (new Get())->withShortName('dummy'), + ])) + ->withNormalizationContext(['groups' => 'read']) + ->withDenormalizationContext(['groups' => 'write']), + ])); + + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->with($this->isString())->willReturn(false); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturnMap([ + ['api_entrypoint', '/'], + ['api_doc', ['_format' => 'jsonld'], '/doc'], + ['api_doc', ['_format' => 'jsonld'], 0, '/doc'], + ]); + + $documentationNormalizer = new DocumentationNormalizer( + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $resourceClassResolver, + $urlGenerator + ); + + $doc = $documentationNormalizer->normalize($documentation); + + $this->assertSame('#dummy', $doc['hydra:supportedClass'][0]['@id']); + $this->assertNotEmpty($doc['hydra:supportedClass'][0]['hydra:supportedProperty']); + } } diff --git a/src/Laravel/Eloquent/Filter/RangeFilter.php b/src/Laravel/Eloquent/Filter/RangeFilter.php index bb86f7dc78f..04cd0d97356 100644 --- a/src/Laravel/Eloquent/Filter/RangeFilter.php +++ b/src/Laravel/Eloquent/Filter/RangeFilter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Laravel\Eloquent\Filter; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Parameter; @@ -20,6 +21,7 @@ use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Psr\Log\LoggerInterface; final class RangeFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { @@ -32,12 +34,25 @@ final class RangeFilter implements FilterInterface, JsonSchemaFilterInterface, O 'gte' => '>=', ]; + public function __construct(private readonly ?LoggerInterface $logger = null) + { + } + /** * @param Builder $builder * @param array $context */ public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder { + $values = \is_array($values) ? array_intersect_key($values, self::OPERATOR_VALUE) : []; + if (!$values) { + $this->logger?->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(\sprintf('At least one valid operator ("%s") is required for "%s" parameter', implode('", "', array_keys(self::OPERATOR_VALUE)), $parameter->getKey())), + ]); + + return $builder; + } + $queryProperty = $this->getQueryProperty($parameter); foreach ($values as $key => $value) { diff --git a/src/Laravel/Tests/EloquentTest.php b/src/Laravel/Tests/EloquentTest.php index 30091ef78d6..1b34ff89bcf 100644 --- a/src/Laravel/Tests/EloquentTest.php +++ b/src/Laravel/Tests/EloquentTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; use ApiPlatform\Laravel\workbench\app\Enums\BookStatus; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; @@ -427,6 +428,21 @@ public function testRangeGreaterThanEqualFilter(): void $this->assertSame($json['totalItems'], 2); } + public function testRangeFilterWithInvalidValue(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $log = Log::spy(); + + $res = $this->get('/api/books?isbn_range=5', ['Accept' => ['application/ld+json']]); + $res->assertOk(); + $this->assertSame(10, $res->json()['totalItems']); + + $res = $this->get('/api/books?isbn_range[unknown]=5', ['Accept' => ['application/ld+json']]); + $res->assertOk(); + $this->assertSame(10, $res->json()['totalItems']); + $log->shouldHaveReceived('notice')->twice(); + } + public function testWrongOrderFilter(): void { BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); diff --git a/src/Metadata/Operation/Factory/OperationMetadataFactoryInterface.php b/src/Metadata/Operation/Factory/OperationMetadataFactoryInterface.php index b5a49c048bd..1689b815435 100644 --- a/src/Metadata/Operation/Factory/OperationMetadataFactoryInterface.php +++ b/src/Metadata/Operation/Factory/OperationMetadataFactoryInterface.php @@ -15,6 +15,9 @@ use ApiPlatform\Metadata\Operation; +/** + * Finds the operation that matches a URI template or an operation name. + */ interface OperationMetadataFactoryInterface { public function create(string $uriTemplate, array $context = []): ?Operation; diff --git a/src/Metadata/Resource/Factory/LinkFactory.php b/src/Metadata/Resource/Factory/LinkFactory.php index 9f80599db9e..ec5e81c6ac5 100644 --- a/src/Metadata/Resource/Factory/LinkFactory.php +++ b/src/Metadata/Resource/Factory/LinkFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata\Resource\Factory; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Metadata; @@ -141,6 +142,10 @@ public function completeLink(Link $link): Link $link = $link->withCompositeIdentifier(true); } + if (null === $link->getSchema() && null !== ($schema = $this->getSchemaFromLinkedProperty($link))) { + $link = $link->withSchema($schema); + } + return $link; } @@ -198,4 +203,41 @@ private function getPropertyClassType(?Type $type): ?string return TypeHelper::getClassName($type); } + + private function getSchemaFromLinkedProperty(Link $link): ?array + { + $resourceClass = $link->getFromClass() ?? $link->getToClass(); + $property = $link->getFromProperty() ?? $link->getToProperty() ?? $link->getParameterName(); + if (!$resourceClass || !$property) { + return null; + } + + try { + $schema = $this->propertyMetadataFactory->create($resourceClass, $property)->getSchema(); + } catch (PropertyNotFoundException) { + return null; + } + + if (null === $schema || !isset($schema['type'])) { + return null; + } + + if (!\is_array($schema['type'])) { + $schema['type'] = [$schema['type']]; + } + + // Properties can be nullable, but the uriVariable won't be + $schema['type'] = array_diff($schema['type'], ['null']); + + // Link types are limited to primitive types + $schema['type'] = array_values(array_filter($schema['type'], static fn ($type) => \in_array($type, ['string', 'number', 'integer', 'boolean'], true))); + if (!$schema['type']) { + unset($schema['type']); + } elseif (1 === \count($schema['type'])) { + $schema['type'] = $schema['type'][0]; + } + + // only allow the type/format fields + return array_intersect_key($schema, array_flip(['type', 'format'])) ?: null; + } } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 8d8b344527a..d16a12d3e78 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -237,7 +237,8 @@ private function getProperties(string $resourceClass, ?Parameter $parameter = nu private function getDefaultParameters(Operation $operation, string $resourceClass, int &$internalPriority, array $declaredOperationClasses): Parameters { $propertyNames = $properties = []; - $parameters = $operation->getParameters() ?? new Parameters(); + $existingParameters = $operation->getParameters(); + $parameters = $existingParameters ? clone $existingParameters : new Parameters(); foreach ($this->createParametersFromAttributes($operation, $declaredOperationClasses) as $key => $parameter) { $parameters->add($key, $parameter); diff --git a/src/Metadata/ResourceAccessCheckerInterface.php b/src/Metadata/ResourceAccessCheckerInterface.php index 29bdd007b9f..a004fd54a76 100644 --- a/src/Metadata/ResourceAccessCheckerInterface.php +++ b/src/Metadata/ResourceAccessCheckerInterface.php @@ -13,6 +13,9 @@ namespace ApiPlatform\Metadata; +/** + * Checks if the current user can access a resource, with a security expression. + */ interface ResourceAccessCheckerInterface { /** diff --git a/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php b/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php index ba7503be6c9..026ca7d6d00 100644 --- a/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php @@ -143,6 +143,45 @@ public function testCompleteLink(): void ); } + #[DataProvider('provideCompleteLinkSchemaCases')] + public function testCompleteLinkSchema(Link $link, ?array $propertySchema, ?array $expectedSchema): void + { + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->expects($this->never())->method('create'); + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->expects($this->any())->method('create')->with(AttributeResource::class, 'uuid')->willReturn(new ApiProperty(schema: $propertySchema)); + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $linkFactory = new LinkFactory($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceClassResolver); + + self::assertEquals( + null === $expectedSchema ? $link : $link->withSchema($expectedSchema), + $linkFactory->completeLink($link) + ); + } + + public static function provideCompleteLinkSchemaCases(): \Generator + { + $schema = ['type' => 'string', 'format' => 'uuid']; + + yield 'fromClass and toProperty' => [new Link(toProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, $schema]; + yield 'fromClass and parameterName' => [new Link(parameterName: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, $schema]; + yield 'fromClass and fromProperty' => [new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, $schema]; + yield 'no class' => [new Link(parameterName: 'uuid', identifiers: ['uuid']), $schema, null]; + yield 'no property' => [new Link(fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, null]; + + yield 'existing schema - string type is preserved' => [ + new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid'], schema: ['type' => 'string', 'format' => 'custom']), + $schema, + ['type' => 'string', 'format' => 'custom'], + ]; + yield 'existing schema - array type is ignored' => [new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), ['type' => 'array'], null]; + yield 'nullable type is filtered' => [ + new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), + ['type' => ['string', 'null'], 'format' => 'uuid'], + ['type' => 'string', 'format' => 'uuid'], + ]; + } + public function testCreateLinksFromAttributesWithExtendedLink(): void { $propertyNameCollectionFactory = new PropertyInfoPropertyNameCollectionFactory(new PropertyInfoExtractor([new ReflectionExtractor()])); diff --git a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php index e38e5d1a9cd..5f47070ee8c 100644 --- a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php @@ -884,6 +884,44 @@ public function testHeaderParameterOnPropertiesWithOperations(): void $this->assertTrue($getParameters->has('X-API-Key', HeaderParameter::class)); } + public function testQueryParameterOnPropertiesWithOperationsDoesNotLeakThroughResourceParameters(): void + { + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'title'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $resource = $parameterFactory->create(QueryParameterOnPropertiesWithOperationsAndResourceParameters::class)[0]; + $operations = array_values(iterator_to_array($resource->getOperations())); + + $this->assertCount(2, $operations); + + [$getOperation, $collectionOperation] = $operations; + $this->assertInstanceOf(Get::class, $getOperation); + $this->assertTrue($getOperation->getParameters()->has('q')); + $this->assertFalse($getOperation->getParameters()->has('title')); + + $this->assertInstanceOf(GetCollection::class, $collectionOperation); + $this->assertTrue($collectionOperation->getParameters()->has('q')); + $this->assertTrue($collectionOperation->getParameters()->has('title')); + + $this->assertNotSame($getOperation->getParameters(), $collectionOperation->getParameters()); + $this->assertFalse($resource->getParameters()->has('title')); + } + public function testNestedPropertyWithNameConverter(): void { $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); @@ -1400,3 +1438,18 @@ class HeaderParameterOnPropertiesWithOperations #[HeaderParameter(key: 'X-API-Key', description: 'API key header', operations: [new GetCollection(), new Get()])] public string $apiKey = ''; } + +#[ApiResource( + operations: [ + new Get(), + new GetCollection(), + ], + parameters: ['q' => new QueryParameter()], +)] +class QueryParameterOnPropertiesWithOperationsAndResourceParameters +{ + public string $id = ''; + + #[QueryParameter(operations: [new GetCollection()])] + public string $title = ''; +} diff --git a/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php index 31d47c2ec81..b26605249a0 100644 --- a/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php @@ -48,6 +48,7 @@ public function testCreate(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Argument::cetera())->willReturn(new PropertyNameCollection()); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Argument::cetera())->willReturn(new ApiProperty()); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $linkFactory = new LinkFactory($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceClassResolverProphecy->reveal()); $pathSegmentNameGeneratorProphecy = $this->prophesize(PathSegmentNameGeneratorInterface::class); diff --git a/src/Metadata/UrlGeneratorInterface.php b/src/Metadata/UrlGeneratorInterface.php index 5df27ef16e4..b6e446a12ca 100644 --- a/src/Metadata/UrlGeneratorInterface.php +++ b/src/Metadata/UrlGeneratorInterface.php @@ -18,7 +18,7 @@ use Symfony\Component\Routing\Exception\RouteNotFoundException; /** - * UrlGeneratorInterface is the interface that all URL generator classes must implement. + * Generates URLs and paths from route names and parameters. * * This interface has been imported and adapted from the Symfony project. * diff --git a/src/OpenApi/Factory/OpenApiFactoryInterface.php b/src/OpenApi/Factory/OpenApiFactoryInterface.php index 5e125ee4917..36d46deb953 100644 --- a/src/OpenApi/Factory/OpenApiFactoryInterface.php +++ b/src/OpenApi/Factory/OpenApiFactoryInterface.php @@ -15,6 +15,9 @@ use ApiPlatform\OpenApi\OpenApi; +/** + * Creates the OpenAPI document of the API. + */ interface OpenApiFactoryInterface { /** diff --git a/src/OpenApi/Options.php b/src/OpenApi/Options.php index a22904bd15c..ace1bc36eba 100644 --- a/src/OpenApi/Options.php +++ b/src/OpenApi/Options.php @@ -15,6 +15,9 @@ use ApiPlatform\OpenApi\Model\Tag; +/** + * Holds the options of the OpenAPI document, such as its title, version and authentication schemes. + */ final readonly class Options { /** diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 040ac4f0ef9..dc8fb810996 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -256,7 +256,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a try { return $this->serializer->denormalize($data, $inputClass, $format, $context); - } catch (NotNormalizableValueException $e) { + } catch (NotNormalizableValueException|MissingConstructorArgumentsException $e) { throw new UnexpectedValueException('The input data is misformatted.', $e->getCode(), $e); } } diff --git a/src/State/Pagination/PaginationOptions.php b/src/State/Pagination/PaginationOptions.php index 24b226f10bd..766f04c3c22 100644 --- a/src/State/Pagination/PaginationOptions.php +++ b/src/State/Pagination/PaginationOptions.php @@ -13,6 +13,9 @@ namespace ApiPlatform\State\Pagination; +/** + * Holds the pagination options, such as the page size and the names of the query parameters. + */ final class PaginationOptions { public function __construct(private readonly bool $paginationEnabled = true, private readonly string $paginationPageParameterName = 'page', private readonly bool $clientItemsPerPage = false, private readonly string $itemsPerPageParameterName = 'itemsPerPage', private readonly bool $paginationClientEnabled = false, private readonly string $paginationClientEnabledParameterName = 'pagination', private readonly int $itemsPerPage = 30, private readonly ?int $maximumItemsPerPage = null, private readonly bool $partialPaginationEnabled = false, private readonly bool $clientPartialPaginationEnabled = false, private readonly string $partialPaginationParameterName = 'partial') diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 0b2548a3ff0..49f20f828f2 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -70,7 +70,7 @@ use PHPStan\PhpDocParser\Parser\PhpDocParser; use Ramsey\Uuid\Uuid; use Symfony\AI\McpBundle\McpBundle; -use Symfony\Bundle\FrameworkBundle\Command\TranslationExtractCommand; +use Symfony\Bundle\FrameworkBundle\Command\TranslationExtractCommand as FrameworkTranslationExtractCommand; use Symfony\Bundle\FrameworkBundle\Controller\ControllerHelper; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Resource\DirectoryResource; @@ -89,6 +89,7 @@ use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\Normalizer\NumberNormalizer; +use Symfony\Component\Translation\Command\TranslationExtractCommand; use Symfony\Component\Uid\AbstractUid; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Yaml\Yaml; @@ -212,7 +213,7 @@ public function load(array $configs, ContainerBuilder $container): void // TranslationExtractCommand was introduced in framework-bundle/7.3 with the object mapper service. // willBeAvailable mirrors FrameworkBundle's own gate: when symfony/object-mapper is in dev-only, // FrameworkBundle skips object_mapper.php and the "object_mapper" service we alias to does not exist. - if (class_exists(ObjectMapper::class) && class_exists(TranslationExtractCommand::class) && ContainerBuilder::willBeAvailable('symfony/object-mapper', ObjectMapperInterface::class, ['symfony/framework-bundle'])) { + if (class_exists(ObjectMapper::class) && (class_exists(TranslationExtractCommand::class) || class_exists(FrameworkTranslationExtractCommand::class)) && ContainerBuilder::willBeAvailable('symfony/object-mapper', ObjectMapperInterface::class, ['symfony/framework-bundle'])) { $loader->load('state/object_mapper.php'); $loader->load($config['use_symfony_listeners'] ? 'symfony/object_mapper.php' : 'state/object_mapper_processor.php'); } diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/TestClientPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/TestClientPass.php index c00bcdfc7ff..cb2f4488c33 100644 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/TestClientPass.php +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/TestClientPass.php @@ -26,9 +26,10 @@ final class TestClientPass implements CompilerPassInterface public function process(ContainerBuilder $container): void { if ( - !class_exists(Client::class) - || !class_exists(AbstractBrowser::class) + !class_exists(AbstractBrowser::class) || !trait_exists(HttpClientTrait::class) + // after the HttpClientTrait check: Client uses that trait + || !class_exists(Client::class) || !$container->hasParameter('test.client.parameters') ) { return; diff --git a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig index 7c026ff0614..294b30a5dc3 100644 --- a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig +++ b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig @@ -10,7 +10,7 @@ {% if title %}{{ title }} - {% endif %}API Platform {% endblock %} - {% set active_ui = app.request.query.get('ui', 'swagger_ui') %} + {% set active_ui = app.request.query.get('ui', reDocEnabled and not swaggerUiEnabled ? 're_doc' : 'swagger_ui') %} {% set is_scalar = (scalarEnabled and not swaggerUiEnabled and not reDocEnabled) or (scalarEnabled and 'scalar' == active_ui) %} {%- set csp_nonce_attr = cspNonce is defined and cspNonce is not null ? ' nonce="' ~ (cspNonce|e('html_attr')) ~ '"' : '' -%} @@ -90,12 +90,19 @@ {% for format in formats|keys %} {{ format }} {% endfor %} -
- Other API docs: - {% if swaggerUiEnabled and active_ui != 'swagger_ui' %}Swagger UI{% endif %} - {% if reDocEnabled and active_ui != 're_doc' %}ReDoc{% endif %} - {% if scalarEnabled and active_ui != 'scalar' %}Scalar{% endif %} - {% if graphiQlEnabled %}GraphiQL{% endif %} + + {% set swaggerUiOption = swaggerUiEnabled and active_ui != 'swagger_ui' %} + {% set reDocOption = reDocEnabled and active_ui != 're_doc' %} + {% set scalarOption = scalarEnabled and active_ui != 'scalar' %} + + {% if swaggerUiOption or reDocOption or scalarOption or graphiQlEnabled %} +
+ Other API docs: + {% if swaggerUiOption %}Swagger UI{% endif %} + {% if reDocOption %}ReDoc{% endif %} + {% if scalarOption %}Scalar{% endif %} + {% if graphiQlEnabled %}GraphiQL{% endif %} + {% endif %} @@ -105,7 +112,7 @@ {% if is_scalar %} - {% elseif (reDocEnabled and not swaggerUiEnabled) or (reDocEnabled and 're_doc' == active_ui) %} + {% elseif (reDocEnabled and 're_doc' == active_ui) %} {% else %} diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 6a0b2f737d2..0f3fe8a6099 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -29,8 +29,11 @@ use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\ORM\OptimisticLockException; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use Symfony\AI\McpBundle\McpBundle; +use Symfony\Bundle\FrameworkBundle\Command\TranslationExtractCommand as FrameworkTranslationExtractCommand; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\DependencyInjection\ChildDefinition; @@ -38,6 +41,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; +use Symfony\Component\Translation\Command\TranslationExtractCommand; class ApiPlatformExtensionTest extends TestCase { @@ -184,6 +188,20 @@ private function assertServiceHasTags(string $service, array $tags = []): void } } + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testObjectMapperDoesNotLoadDeprecatedTranslationCommand(): void + { + if (!class_exists(TranslationExtractCommand::class)) { + self::markTestSkipped('Requires the Symfony 8.2 translation command.'); + } + + (new ApiPlatformExtension())->load(self::DEFAULT_CONFIG, $this->container); + + self::assertTrue($this->container->hasDefinition('api_platform.object_mapper.metadata_factory')); + self::assertFalse(class_exists(FrameworkTranslationExtractCommand::class, false)); + } + public function testCommonConfiguration(): void { $config = self::DEFAULT_CONFIG; diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index fa02ecace20..cc756a947c1 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -70,6 +70,7 @@ "webonyx/graphql-php": "^15.0" }, "suggest": { + "ext-deepclone": "To speed up the PHP files metadata cache with symfony/var-exporter 8.1+ (install symfony/deepclone with PIE).", "api-platform/doctrine-orm": "To support Doctrine ORM.", "api-platform/doctrine-odm": "To support MongoDB. Only versions 4.0 and later are supported.", "api-platform/elasticsearch": "To support Elasticsearch.", diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/RequiredConstructorInputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/RequiredConstructorInputResource.php new file mode 100644 index 00000000000..d5902f6dff9 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/RequiredConstructorInputResource.php @@ -0,0 +1,46 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdRequiredConstructorInput', + operations: [ + new Post( + uriTemplate: '/jsonld_required_constructor_inputs', + input: RequiredConstructorInputDto::class, + output: false, + processor: [self::class, 'process'], + ), + ], +)] +final class RequiredConstructorInputResource +{ + public static function process(RequiredConstructorInputDto $data): RequiredConstructorInputDto + { + return $data; + } +} + +final class RequiredConstructorInputDto +{ + public function __construct( + public string $title, + public int $rating, + public string $comment, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaChild.php b/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaChild.php new file mode 100644 index 00000000000..18f4aa82d4b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaChild.php @@ -0,0 +1,64 @@ + + * + * 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\LinkSchema; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/link_schema_parents/{parentId}/children/{id}', + uriVariables: [ + 'parentId' => new Link( + fromProperty: 'id', + fromClass: LinkSchemaParent::class + ), + 'id' => new Link( + fromClass: LinkSchemaChild::class, + identifiers: ['id'] + ), + ], + provider: [LinkSchemaChild::class, 'provide'] + ), + new Get( + uriTemplate: '/link_schema_parents_by_code/{parentCode}/children/{id}', + uriVariables: [ + 'parentCode' => new Link( + fromProperty: 'code', + fromClass: LinkSchemaParent::class + ), + 'id' => new Link( + fromClass: LinkSchemaChild::class, + identifiers: ['id'] + ), + ], + provider: [LinkSchemaChild::class, 'provide'] + ), + ] +)] +class LinkSchemaChild +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $title = null; + + public static function provide(): self + { + return new self(); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaParent.php b/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaParent.php new file mode 100644 index 00000000000..8852588c42b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaParent.php @@ -0,0 +1,42 @@ + + * + * 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\LinkSchema; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Uid\Uuid; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/link_schema_parents/{id}', + provider: [LinkSchemaParent::class, 'provide'] + ), + ] +)] +class LinkSchemaParent +{ + #[ApiProperty(identifier: true)] + public ?Uuid $id = null; + + public ?int $code = null; + + public ?string $name = null; + + public static function provide(): self + { + return new self(); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/NestedInputItem.php b/tests/Fixtures/TestBundle/Entity/NestedInputItem.php new file mode 100644 index 00000000000..51dd042c2fa --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/NestedInputItem.php @@ -0,0 +1,37 @@ + + * + * 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 ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(graphQlOperations: [])] +#[ORM\Entity] +class NestedInputItem +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string')] + #[Groups(['nested_input_write'])] + public string $label = ''; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/NestedInputOwner.php b/tests/Fixtures/TestBundle/Entity/NestedInputOwner.php new file mode 100644 index 00000000000..3870336371d --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/NestedInputOwner.php @@ -0,0 +1,73 @@ + + * + * 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 ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + normalizationContext: ['groups' => ['nested_input_read']], + denormalizationContext: ['groups' => ['nested_input_write']], + graphQlOperations: [ + new Query(), + new Mutation(name: 'create'), + ], +)] +#[ORM\Entity] +class NestedInputOwner +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + #[Groups(['nested_input_read'])] + private ?int $id = null; + + #[ORM\Column(type: 'string')] + #[Groups(['nested_input_read', 'nested_input_write'])] + public string $name = ''; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: NestedInputItem::class, cascade: ['persist'])] + #[ORM\JoinTable(name: 'nested_input_owner_items')] + #[ORM\InverseJoinColumn(unique: true)] + #[Groups(['nested_input_write'])] + public Collection $items; + + public function __construct() + { + $this->items = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function addItem(NestedInputItem $item): void + { + $this->items->add($item); + } + + public function removeItem(NestedInputItem $item): void + { + $this->items->removeElement($item); + } +} diff --git a/tests/Functional/GraphQl/NestedInputWithoutMutationTest.php b/tests/Functional/GraphQl/NestedInputWithoutMutationTest.php new file mode 100644 index 00000000000..3958eca0e02 --- /dev/null +++ b/tests/Functional/GraphQl/NestedInputWithoutMutationTest.php @@ -0,0 +1,82 @@ + + * + * 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\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NestedInputItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NestedInputOwner; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NestedInputWithoutMutationTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [NestedInputOwner::class, NestedInputItem::class]; + } + + public function testRelatedResourceWithoutMutationIsNotExposedAsMutation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM only fixture.'); + } + + $response = $this->introspectSchema(); + + $this->assertResponseIsSuccessful(); + $mutationFields = []; + foreach ($response->toArray()['data']['__schema']['types'] as $type) { + if ('Mutation' === $type['name']) { + $mutationFields = array_column($type['fields'], 'name'); + } + } + $this->assertContains('createNestedInputOwner', $mutationFields); + $this->assertNotContains('createNestedInputItem', $mutationFields); + } + + public function testCreateWithNestedInputPersistsRelatedResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM only fixture.'); + } + + $this->recreateSchema([NestedInputOwner::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createNestedInputOwner(input: {name: "owner", items: [{label: "first"}, {label: "second"}]}) { + nestedInputOwner { id name } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertSame('owner', $json['data']['createNestedInputOwner']['nestedInputOwner']['name']); + + $items = $this->getManager()->getRepository(NestedInputItem::class)->findBy([], ['id' => 'ASC']); + $this->assertSame(['first', 'second'], array_map(static fn (NestedInputItem $i): string => $i->label, $items)); + } +} diff --git a/tests/Functional/JsonLd/InputOutputDtoTest.php b/tests/Functional/JsonLd/InputOutputDtoTest.php index 8aa912278dc..66da5143511 100644 --- a/tests/Functional/JsonLd/InputOutputDtoTest.php +++ b/tests/Functional/JsonLd/InputOutputDtoTest.php @@ -22,6 +22,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\InputOutputResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NoInputResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PostNoOutputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\RequiredConstructorInputResource; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UserResource; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; @@ -41,6 +42,7 @@ public static function getResources(): array InputOutputResource::class, NoInputResource::class, PostNoOutputResource::class, + RequiredConstructorInputResource::class, DummyCollectionDto::class, DummyFooCollectionDto::class, DummyIdCollectionDto::class, @@ -83,6 +85,21 @@ public function testCustomInputRejectsBadType(): void $this->assertSame('The input data is misformatted.', $body['detail']); } + public function testCustomInputRejectsBadTypeMissingConstructorParam(): void + { + $response = self::createClient()->request('POST', '/jsonld_required_constructor_inputs', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['title' => 'Hello'], + ]); + + $this->assertResponseStatusCodeSame(400); + $body = $response->toArray(false); + $this->assertSame('The input data is misformatted.', $body['detail']); + } + public function testItemWithCustomOutput(): void { $response = self::createClient()->request('GET', '/jsonld_custom_outputs/1', [ diff --git a/tests/Functional/OpenApiLinkSchemaTest.php b/tests/Functional/OpenApiLinkSchemaTest.php new file mode 100644 index 00000000000..c9f958d95f2 --- /dev/null +++ b/tests/Functional/OpenApiLinkSchemaTest.php @@ -0,0 +1,89 @@ + + * + * 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\Functional; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema\LinkSchemaChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema\LinkSchemaParent; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OpenApiLinkSchemaTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + LinkSchemaParent::class, + LinkSchemaChild::class, + ]; + } + + public function testLinkSchemaIsInferredFromLinkedProperty(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + $this->assertResponseIsSuccessful(); + + $json = $response->toArray(); + + $this->assertArrayHasKey('/link_schema_parents/{parentId}/children/{id}', $json['paths']); + $parameters = $json['paths']['/link_schema_parents/{parentId}/children/{id}']['get']['parameters']; + + $this->assertCount(2, $parameters); + $this->assertSame(['parentId', 'id'], array_column($parameters, 'name')); + $this->assertSame($parameters[0]['name'], 'parentId'); + $this->assertSame($parameters[1]['name'], 'id'); + + $parentParam = $parameters[0]; + $idParam = $parameters[1]; + + $this->assertNotNull($parentParam); + $this->assertSame('path', $parentParam['in']); + $this->assertTrue($parentParam['required']); + $this->assertSame(['type' => 'string', 'format' => 'uuid'], $parentParam['schema']); + + $this->assertNotNull($idParam); + $this->assertSame('path', $idParam['in']); + $this->assertTrue($idParam['required']); + $this->assertSame(['type' => 'integer'], $idParam['schema']); + } + + public function testLinkSchemaIsInferredFromLinkedNonIdentifierProperty(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + $this->assertResponseIsSuccessful(); + + $json = $response->toArray(); + + $this->assertArrayHasKey('/link_schema_parents_by_code/{parentCode}/children/{id}', $json['paths']); + $byCodeParameters = $json['paths']['/link_schema_parents_by_code/{parentCode}/children/{id}']['get']['parameters']; + + $this->assertSame($byCodeParameters[0]['name'], 'parentCode'); + $parentCodeParam = $byCodeParameters[0]; + + $this->assertNotNull($parentCodeParam); + $this->assertSame('path', $parentCodeParam['in']); + $this->assertTrue($parentCodeParam['required']); + $this->assertSame(['type' => 'integer'], $parentCodeParam['schema']); + } +} diff --git a/tests/Functional/RedocOtherApiDocLinkTest.php b/tests/Functional/RedocOtherApiDocLinkTest.php new file mode 100644 index 00000000000..31294271447 --- /dev/null +++ b/tests/Functional/RedocOtherApiDocLinkTest.php @@ -0,0 +1,69 @@ + + * + * 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\Functional; + +use ApiPlatform\Test\ApiTestCase; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class RedocOtherApiDocLinkAppKernel extends \AppKernel +{ + public function getCacheDir(): string + { + return parent::getCacheDir().'/redoc_otherapidoc_link'; + } + + public function getLogDir(): string + { + return parent::getLogDir().'/redoc_otherapidoc_link'; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('api_platform', [ + 'enable_swagger_ui' => false, + 'enable_re_doc' => true, + 'enable_scalar' => false, + 'graphql' => ['enabled' => false], + ]); + + // the fixtures decorate a GraphQL service that is not registered once GraphQL is off + if ($container->hasDefinition('app.graphql.type_converter')) { + $container->removeDefinition('app.graphql.type_converter'); + } + }); + } +} + +final class RedocOtherApiDocLinkTest extends ApiTestCase +{ + protected static ?bool $alwaysBootKernel = true; + + protected static function getKernelClass(): string + { + return RedocOtherApiDocLinkAppKernel::class; + } + + public function testOtherApiDocsLinkAreHiddenWhenAllOptionsAreDisabled(): void + { + $client = self::createClient(); + $client->request('GET', '/docs', ['headers' => ['Accept' => 'text/html']]); + + $this->assertResponseIsSuccessful(); + $this->assertStringNotContainsString('Other API docs:', $client->getResponse()->getContent()); + } +} diff --git a/tests/Functional/SwaggerUiOtherApiDocLinkTest.php b/tests/Functional/SwaggerUiOtherApiDocLinkTest.php new file mode 100644 index 00000000000..27b4d4c53cf --- /dev/null +++ b/tests/Functional/SwaggerUiOtherApiDocLinkTest.php @@ -0,0 +1,69 @@ + + * + * 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\Functional; + +use ApiPlatform\Test\ApiTestCase; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class SwaggerUiOtherApiDocLinkAppKernel extends \AppKernel +{ + public function getCacheDir(): string + { + return parent::getCacheDir().'/swagger_otherapidoc_link'; + } + + public function getLogDir(): string + { + return parent::getLogDir().'/swagger_otherapidoc_link'; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('api_platform', [ + 'enable_swagger_ui' => true, + 'enable_re_doc' => false, + 'enable_scalar' => false, + 'graphql' => ['enabled' => false], + ]); + + // the fixtures decorate a GraphQL service that is not registered once GraphQL is off + if ($container->hasDefinition('app.graphql.type_converter')) { + $container->removeDefinition('app.graphql.type_converter'); + } + }); + } +} + +final class SwaggerUiOtherApiDocLinkTest extends ApiTestCase +{ + protected static ?bool $alwaysBootKernel = true; + + protected static function getKernelClass(): string + { + return SwaggerUiOtherApiDocLinkAppKernel::class; + } + + public function testOtherApiDocsLinkAreHiddenWhenAllOptionsAreDisabled(): void + { + $client = self::createClient(); + $client->request('GET', '/docs', ['headers' => ['Accept' => 'text/html']]); + + $this->assertResponseIsSuccessful(); + $this->assertStringNotContainsString('Other API docs:', $client->getResponse()->getContent()); + } +} diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/TestClientPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/TestClientPassTest.php index 762a716bc89..c161c423455 100644 --- a/tests/Symfony/Bundle/DependencyInjection/Compiler/TestClientPassTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/TestClientPassTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler; use ApiPlatform\Test\Client; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -21,6 +22,7 @@ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\HttpClient\HttpClientTrait; final class TestClientPassTest extends TestCase { @@ -65,4 +67,28 @@ public function testProcess(): void $this->testClientPass->process($this->containerBuilderProphecy->reveal()); } + + /** + * ApiPlatform\Test\Client uses HttpClientTrait, so checking for the client class + * must not autoload it before the pass knows that symfony/http-client is installed. + */ + #[RunInSeparateProcess] + public function testProcessWithoutHttpClient(): void + { + foreach (spl_autoload_functions() as $autoloader) { + spl_autoload_unregister($autoloader); + spl_autoload_register(static function (string $class) use ($autoloader): void { + if (HttpClientTrait::class !== $class) { + $autoloader($class); + } + }); + } + + $container = new ContainerBuilder(); + $container->setParameter('test.client.parameters', []); + + $this->testClientPass->process($container); + + self::assertFalse($container->hasDefinition('test.api_platform.client')); + } }