diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c02ff4cb8b..ab8ae648e57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1193,7 +1193,7 @@ jobs: - name: Install api-platform/laravel run: | composer require laravel/framework:"^13.0" --no-update - composer global link ../src/Laravel --permanent + composer global link ../ --working-directory=$(pwd) --permanent composer config minimum-stability dev composer config prefer-stable true composer require api-platform/laravel:"@dev" -W diff --git a/CHANGELOG.md b/CHANGELOG.md index dc88f61ab15..227cd508a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,31 @@ * [c9e5071d9](https://github.com/api-platform/core/commit/c9e5071d973caedeb9b554f885dedbc89743b93d) feat(symfony): deprecate Symfony Security AccessDeniedException (#8318) * [cc0ae1254](https://github.com/api-platform/core/commit/cc0ae1254acaf9742c7f899dd24a14d46c89ca6e) feat: support dynamic HTTP response status code via request attribute (#7904) +## v4.3.17 + +### Bug fixes + +* [079c7461f](https://github.com/api-platform/core/commit/079c7461f947714f15bb29b41fb143ed4bb171ea) fix(openapi): don't inherit name converter from a possibly-missing Symfony parent (#8386) +* [3d48a4aa4](https://github.com/api-platform/core/commit/3d48a4aa4d87f1fa80fe19361393d645d0d013c2) fix(serializer): preserve denormalization errors for nullable object properties (#8393) +* [56de59737](https://github.com/api-platform/core/commit/56de59737c53e18120ed0b9b6c40633c109d58c2) fix(doctrine): fetch_data=false reference for stateOptions resources (#8387) +* [b396ff938](https://github.com/api-platform/core/commit/b396ff938272280a26d38d89011970dcee56d721) fix(serializer): report enum backing type in denormalization violations (#8389) + +## v4.3.16 + +### Bug fixes + +* [328cb67ce](https://github.com/api-platform/core/commit/328cb67ceda3d934d8985fb0f7e08542f59fb38d) fix(symfony): don't expose entrypoint in openapi format (#8383) +* [340e982c2](https://github.com/api-platform/core/commit/340e982c22ebc6c4cf45a6f481151f668722eaf9) fix(test): remove stale phpstan ignores in CollectionNormalizerTest (#8384) +* [43e3b4cf7](https://github.com/api-platform/core/commit/43e3b4cf716ed7b0d3962b5e022d065a2bb746c9) fix(openapi): don't apply the global name converter to the generated document (#8360) +* [48e8f9781](https://github.com/api-platform/core/commit/48e8f97816a945d205b0cfaf4c76cc532c143312) fix(doctrine): use PHP property name in DQL for modern filters with name converter (#8382) +* [66829859f](https://github.com/api-platform/core/commit/66829859fac0fe0363b8a91fd338d3ab71877774) fix(doctrine): support filtering scalar enum columns by IRI (#8358) +* [71918e464](https://github.com/api-platform/core/commit/71918e4644ed2f6ce1ff8514b9ee87a57f074285) fix(jsonschema): respect readableLink for resource-typed properties on non-resource parents (#8362) +* [798b4cc53](https://github.com/api-platform/core/commit/798b4cc53dee7ef1e8dd8042668bbf9f8086b746) fix(serializer): denormalize nullable collections of enums/objects (#8381) +* [a9c1b538c](https://github.com/api-platform/core/commit/a9c1b538cb48ee5e629ed3909ce56653970c8889) fix: clone Parameters before mutation in security and validator providers (#8378) +* [f1f33cac9](https://github.com/api-platform/core/commit/f1f33cac9a15331bc940654d37bf9fd9828d0c28) fix(ci): link monorepo root in laravel e2e so siblings resolve from working tree (#8363) +* [f9d3706bb](https://github.com/api-platform/core/commit/f9d3706bb583f3a7dd74be1cc94729e49cc0611c) fix(mcp): make tools/list resilient to an empty registry (#8371) +* [fe84af236](https://github.com/api-platform/core/commit/fe84af2365244055737df73f888f7aa3888f462e) fix(symfony): allow null $data in PlaceholderAction (#8359) + ## v4.3.15 ### Bug fixes diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 26290e09b56..0464e631e75 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -25,6 +25,7 @@ use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; /** @@ -68,7 +69,23 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $leafProperty = $nestedInfo['leaf_property'] ?? $property; $classMetadata = $documentManager->getClassMetadata($leafClass); + // Backed enum exposed as a resource: match the scalar field against the resolved enum. if (!$classMetadata->hasReference($leafProperty)) { + if (!$this->isEnumField($classMetadata, $leafProperty)) { + return; + } + + $normalize = static fn (mixed $v): mixed => $v instanceof \BackedEnum ? $v->value : $v; + + if (is_iterable($value)) { + $values = \is_array($value) ? $value : iterator_to_array($value); + $match->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->in(array_map($normalize, $values))); + + return; + } + + $match->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->equals($normalize($value))); + return; } @@ -113,4 +130,9 @@ public static function getParameterProvider(): string { return IriConverterParameterProvider::class; } + + private function isEnumField(ClassMetadata $classMetadata, string $field): bool + { + return $classMetadata->hasField($field) && null !== ($classMetadata->getFieldMapping($field)['enumType'] ?? null); + } } diff --git a/src/Doctrine/Odm/NestedPropertyHelperTrait.php b/src/Doctrine/Odm/NestedPropertyHelperTrait.php index e713382bece..d3a92ecdf0b 100644 --- a/src/Doctrine/Odm/NestedPropertyHelperTrait.php +++ b/src/Doctrine/Odm/NestedPropertyHelperTrait.php @@ -39,7 +39,7 @@ protected function addNestedParameterLookups(string $property, Builder $aggregat $nestedInfo = ($extraProperties['nested_properties_info'] ?? []) ? reset($extraProperties['nested_properties_info']) : null; if (!$nestedInfo) { - return $property; + return $extraProperties['query_property'] ?? $property; } $odmSegments = $nestedInfo['odm_segments'] ?? []; diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index 4cac01d21b6..e8de32ce793 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -22,6 +22,8 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ParameterProviderFilterInterface; use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\FieldMapping; use Doctrine\ORM\QueryBuilder; /** @@ -57,6 +59,13 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $ormLeafMetadata = $this->resolveLeafMetadataAtRuntime($queryBuilder, $resourceClass, $parameter->getProperty(), $property); } + // Backed enum exposed as a resource: compare the scalar column to the resolved enum. + if (null === $ormLeafMetadata) { + $this->applyEnumComparison($queryBuilder, $alias, $property, $parameterName, $value, $context); + + return; + } + // Collection associations (OneToMany/ManyToMany) require a JOIN to compare individual elements. if ($ormLeafMetadata['is_collection_valued']) { $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); @@ -102,6 +111,25 @@ public static function getParameterProvider(): string return IriConverterParameterProvider::class; } + private function applyEnumComparison(QueryBuilder $queryBuilder, string $alias, string $property, string $parameterName, mixed $value, array $context): void + { + $propertyExpr = \sprintf('%s.%s', $alias, $property); + $normalize = static fn (mixed $v): mixed => $v instanceof \BackedEnum ? $v->value : $v; + + if (is_iterable($value)) { + $values = \is_array($value) ? $value : iterator_to_array($value); + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $propertyExpr, $parameterName)) + ->setParameter($parameterName, array_map($normalize, $values)); + + return; + } + + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $propertyExpr, $parameterName)) + ->setParameter($parameterName, $normalize($value)); + } + /** * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}|null */ @@ -122,9 +150,12 @@ private function getOrmLeafMetadata(mixed $parameter): ?array * Resolves leaf metadata at runtime by walking the association chain. * Used as fallback when precomputed orm_leaf_metadata is not available. * - * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string} + * Returns null when the leaf is a backed enum column so the caller compares it directly; + * a non-association, non-enum leaf is left to raise a mapping error. + * + * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}|null */ - private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $resourceClass, string $originalProperty, string $leafProperty): array + private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $resourceClass, string $originalProperty, string $leafProperty): ?array { $em = $queryBuilder->getEntityManager(); $metadata = $em->getClassMetadata($resourceClass); @@ -135,6 +166,10 @@ private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $metadata = $em->getClassMetadata($associationMapping['targetEntity']); } + if (!$metadata->hasAssociation($leafProperty) && $this->isEnumField($metadata, $leafProperty)) { + return null; + } + $isCollectionValued = $metadata->isCollectionValuedAssociation($leafProperty); $associationMapping = $metadata->getAssociationMapping($leafProperty); $targetClass = $associationMapping['targetEntity']; @@ -152,4 +187,19 @@ private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string 'identifier_type' => $identifierType, ]; } + + private function isEnumField(ClassMetadata $metadata, string $field): bool + { + if (!isset($metadata->fieldMappings[$field])) { + return false; + } + + $fieldMapping = $metadata->fieldMappings[$field]; + // Doctrine ORM 2.x returns an array and Doctrine ORM 3.x returns a FieldMapping object. + if ($fieldMapping instanceof FieldMapping) { + $fieldMapping = (array) $fieldMapping; + } + + return null !== ($fieldMapping['enumType'] ?? null); + } } diff --git a/src/Doctrine/Orm/NestedPropertyHelperTrait.php b/src/Doctrine/Orm/NestedPropertyHelperTrait.php index e93f17d46f8..f8f0e1e52e0 100644 --- a/src/Doctrine/Orm/NestedPropertyHelperTrait.php +++ b/src/Doctrine/Orm/NestedPropertyHelperTrait.php @@ -43,7 +43,7 @@ protected function addNestedParameterJoins( $nestedInfo = ($extraProperties['nested_properties_info'] ?? []) ? reset($extraProperties['nested_properties_info']) : null; if (!$nestedInfo) { - return [$alias, $property]; + return [$alias, $extraProperties['query_property'] ?? $property]; } $relationClasses = $nestedInfo['relation_classes'] ?? []; diff --git a/src/Doctrine/Orm/State/ItemProvider.php b/src/Doctrine/Orm/State/ItemProvider.php index 0830e43aa1b..9b1e317d064 100644 --- a/src/Doctrine/Orm/State/ItemProvider.php +++ b/src/Doctrine/Orm/State/ItemProvider.php @@ -124,8 +124,10 @@ private function getReferenceIdentifiers(EntityManagerInterface $manager, string $identifiers = []; foreach ($this->getLinks($entityClass, $operation, $context) as $parameterName => $link) { - // Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the entity itself. - if ($entityClass !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) { + // Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the resource itself. + // Links are expressed in resource terms, so the identifier-self link's fromClass is the resource class + // ($operation->getClass()), which differs from $entityClass for a DTO/stateOptions resource. + if ($operation->getClass() !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) { continue; } diff --git a/src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php b/src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php new file mode 100644 index 00000000000..c5008dbd047 --- /dev/null +++ b/src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php @@ -0,0 +1,31 @@ + + * + * 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\Tests\Fixtures\Model; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Employee; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +/** + * DTO resource backed by the {@see Employee} entity through stateOptions: the resource + * class differs from the Doctrine entity class, which is what exercises the + * fetch_data=false getReference() fast-path for DTO/stateOptions resources. + */ +#[ApiResource(stateOptions: new Options(entityClass: Employee::class))] +#[Get] +class EmployeeApi +{ + public ?int $id = null; +} diff --git a/src/Doctrine/Orm/Tests/State/ItemProviderTest.php b/src/Doctrine/Orm/Tests/State/ItemProviderTest.php index 8fbb549636e..4eba41322d7 100644 --- a/src/Doctrine/Orm/Tests/State/ItemProviderTest.php +++ b/src/Doctrine/Orm/Tests/State/ItemProviderTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Company; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Employee; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\OperationResource; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Model\EmployeeApi; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Get; @@ -278,6 +279,84 @@ public function testGetItemWithFetchDataFalseFallsBackToQueryWhenIdentifierIsNot $this->assertSame($returnObject, $dataProvider->provide($operation, ['uuid' => '61817181-0ecc-42fb-a6e7-d97f2ddcb344'], ['fetch_data' => false])); } + public function testGetItemWithFetchDataFalseReturnsReferenceForStateOptionsResource(): void + { + $reference = new Employee(); + + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->with(Employee::class)->willReturn($classMetadataMock); + $managerMock->expects($this->once()) + ->method('getReference') + ->with(Employee::class, ['id' => 2]) + ->willReturn($reference); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock); + + // DTO resource: the operation class (EmployeeApi) differs from the stateOptions entity class (Employee), + // so the identifier-self link's fromClass is the resource class, not the entity class. + $operation = (new Get())->withUriVariables([ + 'id' => (new Link())->withFromClass(EmployeeApi::class)->withIdentifiers(['id']), + ])->withStateOptions(new Options(entityClass: Employee::class))->withName('get')->withClass(EmployeeApi::class); + + // The reference fast-path must return before the item query is built, so item extensions never run. + $extensionMock = $this->createMock(QueryItemExtensionInterface::class); + $extensionMock->expects($this->never())->method('applyToItem'); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + [$extensionMock] + ); + + $this->assertSame($reference, $dataProvider->provide($operation, ['id' => 2], ['fetch_data' => false])); + } + + public function testGetItemWithFetchDataFalseFallsBackToQueryForStateOptionsResourceWhenIdentifierIsNotADoctrineIdentifier(): void + { + $returnObject = new \stdClass(); + + $queryMock = $this->createMock($this->getQueryClass()); + $queryMock->method('getOneOrNullResult')->willReturn($returnObject); + + $queryBuilderMock = $this->createMock(QueryBuilder::class); + $queryBuilderMock->method('getQuery')->willReturn($queryMock); + $queryBuilderMock->method('getRootAliases')->willReturn(['o']); + + // Even for a DTO resource, the entity-identifier guard holds: the resource exposes "uuid" as its + // API identifier while the Doctrine identifier is "id", so getReference() cannot be built and we + // must fall back to the link-resolving query. + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $repositoryMock = $this->createMock(EntityRepository::class); + $repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->willReturn($classMetadataMock); + $managerMock->method('getRepository')->willReturn($repositoryMock); + $managerMock->expects($this->never())->method('getReference'); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->willReturn($managerMock); + + // A no-op handleLinks mirrors what DoctrineOrmResourceCollectionMetadataFactory injects for a + // stateOptions resource in production; the unit test bypasses that factory (see CollectionProviderTest). + $operation = (new Get())->withUriVariables([ + 'uuid' => (new Link())->withFromClass(EmployeeApi::class)->withIdentifiers(['uuid'])->withParameterName('uuid'), + ])->withStateOptions(new Options(entityClass: Employee::class, handleLinks: static fn () => null))->withName('get')->withClass(EmployeeApi::class); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + ); + + $this->assertSame($returnObject, $dataProvider->provide($operation, ['uuid' => '61817181-0ecc-42fb-a6e7-d97f2ddcb344'], ['fetch_data' => false])); + } + public function testQueryResultExtension(): void { $returnObject = new \stdClass(); diff --git a/src/GraphQl/foo.diff b/src/GraphQl/foo.diff deleted file mode 100644 index 37938da2912..00000000000 --- a/src/GraphQl/foo.diff +++ /dev/null @@ -1,289 +0,0 @@ -diff --git a/src/Doctrine/Common/Filter/ParameterAwareFilterInterface.php b/src/Doctrine/Common/Filter/ParameterAwareFilterInterface.php -new file mode 100644 -index 00000000000..d1974a32c55 ---- /dev/null -+++ b/src/Doctrine/Common/Filter/ParameterAwareFilterInterface.php -@@ -0,0 +1,31 @@ -+ -+ * -+ * 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\Common\Filter; -+ -+use ApiPlatform\Metadata\Operation; -+ -+/** -+ * Interface for filters that can be applied by a ParameterExtension. -+ * -+ * @author Antoine Bluchet -+ */ -+interface ParameterAwareFilterInterface -+{ -+ /** -+ * Applies the filter to the query. -+ * -+ * @param array $context -+ */ -+ public function apply(object $queryBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void; -+} -diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php -index aa0857cef20..1b041f480fb 100644 ---- a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php -+++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php -@@ -16,6 +16,8 @@ - /** - * @author Antoine Bluchet - * -+ * @method ?array getProperties() -+ * - * @experimental - */ - interface PropertyAwareFilterInterface -@@ -24,4 +26,9 @@ interface PropertyAwareFilterInterface - * @param string[] $properties - */ - public function setProperties(array $properties): void; -+ -+ // /** -+ // * @return string[] -+ // */ -+ // public function getProperties(): ?array; - } -diff --git a/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php b/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php -index f2f09e5758c..3acab18a05f 100644 ---- a/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php -+++ b/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php -@@ -23,16 +23,6 @@ trait PropertyPlaceholderOpenApiParameterTrait - */ - public function getOpenApiParameters(Parameter $parameter): ?array - { -- if (str_contains($parameter->getKey(), ':property')) { -- $parameters = []; -- $key = str_replace('[:property]', '', $parameter->getKey()); -- foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { -- $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); -- } -- -- return $parameters; -- } -- -- return null; -+ return [new OpenApiParameter(name: $parameter->getKey(), in: 'query')]; - } - } -diff --git a/src/Doctrine/Common/ParameterExtensionTrait.php b/src/Doctrine/Common/ParameterExtensionTrait.php -new file mode 100644 -index 00000000000..4d865a8730f ---- /dev/null -+++ b/src/Doctrine/Common/ParameterExtensionTrait.php -@@ -0,0 +1,63 @@ -+ -+ * -+ * 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\Common; -+ -+use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; -+use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; -+use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; -+use ApiPlatform\Metadata\Parameter; -+use Doctrine\Persistence\ManagerRegistry; -+use Psr\Container\ContainerInterface; -+use Psr\Log\LoggerInterface; -+ -+trait ParameterExtensionTrait -+{ -+ use ParameterValueExtractorTrait; -+ -+ protected ContainerInterface $filterLocator; -+ protected ?ManagerRegistry $managerRegistry = null; -+ protected ?LoggerInterface $logger = null; -+ -+ /** -+ * @param object $filter the filter instance to configure -+ * @param Parameter $parameter the operation parameter associated with the filter -+ */ -+ private function configureFilter(object $filter, Parameter $parameter): void -+ { -+ if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { -+ $filter->setManagerRegistry($this->managerRegistry); -+ } -+ -+ if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { -+ $filter->setLogger($this->logger); -+ } -+ -+ if ($filter instanceof PropertyAwareFilterInterface) { -+ $properties = []; -+ // Check if the filter has getProperties method (e.g., if it's an AbstractFilter) -+ if (method_exists($filter, 'getProperties')) { // @phpstan-ignore-line todo 5.x remove this check @see interface -+ $properties = $filter->getProperties() ?? []; -+ } -+ -+ $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); -+ foreach ($parameter->getProperties() ?? [$propertyKey] as $property) { -+ if (!isset($properties[$property])) { -+ $properties[$property] = $parameter->getFilterContext(); -+ } -+ } -+ -+ $filter->setProperties($properties); -+ } -+ } -+} -diff --git a/src/Doctrine/Common/ParameterValueExtractorTrait.php b/src/Doctrine/Common/ParameterValueExtractorTrait.php -index b50dede8f76..62c18f6483c 100644 ---- a/src/Doctrine/Common/ParameterValueExtractorTrait.php -+++ b/src/Doctrine/Common/ParameterValueExtractorTrait.php -@@ -23,10 +23,7 @@ trait ParameterValueExtractorTrait - private function extractParameterValue(Parameter $parameter, mixed $value): array - { - $key = $parameter->getProperty() ?? $parameter->getKey(); -- if (!str_contains($key, ':property')) { -- return [$key => $value]; -- } - -- return [str_replace('[:property]', '', $key) => $value]; -+ return [$key => $value]; - } - } -diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php -index d68e6e9ed3b..d841fb9240e 100644 ---- a/src/Doctrine/Odm/Extension/ParameterExtension.php -+++ b/src/Doctrine/Odm/Extension/ParameterExtension.php -@@ -13,11 +13,9 @@ - - namespace ApiPlatform\Doctrine\Odm\Extension; - --use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; --use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; --use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; --use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter; --use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; -+use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; -+use ApiPlatform\Doctrine\Common\ParameterExtensionTrait; -+use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; // Explicitly import PropertyAwareFilterInterface - use ApiPlatform\Metadata\Operation; - use ApiPlatform\State\ParameterNotFound; - use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; -@@ -32,13 +30,16 @@ - */ - final class ParameterExtension implements AggregationCollectionExtensionInterface, AggregationItemExtensionInterface - { -- use ParameterValueExtractorTrait; -+ use ParameterExtensionTrait; - - public function __construct( -- private readonly ContainerInterface $filterLocator, -- private readonly ?ManagerRegistry $managerRegistry = null, -- private readonly ?LoggerInterface $logger = null, -+ ContainerInterface $filterLocator, -+ ?ManagerRegistry $managerRegistry = null, -+ ?LoggerInterface $logger = null, - ) { -+ $this->filterLocator = $filterLocator; -+ $this->managerRegistry = $managerRegistry; -+ $this->logger = $logger; - } - - /** -@@ -66,28 +67,7 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass - continue; - } - -- if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { -- $filter->setManagerRegistry($this->managerRegistry); -- } -- -- if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { -- $filter->setLogger($this->logger); -- } -- -- if ($filter instanceof AbstractFilter && !$filter->getProperties()) { -- $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); -- -- if (str_contains($propertyKey, ':property')) { -- $extraProperties = $parameter->getExtraProperties()['_properties'] ?? []; -- foreach (array_keys($extraProperties) as $property) { -- $properties[$property] = $parameter->getFilterContext(); -- } -- } else { -- $properties = [$propertyKey => $parameter->getFilterContext()]; -- } -- -- $filter->setProperties($properties ?? []); -- } -+ $this->configureFilter($filter, $parameter); - - $context['filters'] = $values; - $context['parameter'] = $parameter; -diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php -index b7c8d8938b9..0264567a602 100644 ---- a/src/Doctrine/Orm/Extension/ParameterExtension.php -+++ b/src/Doctrine/Orm/Extension/ParameterExtension.php -@@ -13,11 +13,8 @@ - - namespace ApiPlatform\Doctrine\Orm\Extension; - --use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; --use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; - use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; --use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; --use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; -+use ApiPlatform\Doctrine\Common\ParameterExtensionTrait; // Explicitly import PropertyAwareFilterInterface - use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; - use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; - use ApiPlatform\Metadata\Operation; -@@ -34,13 +31,16 @@ - */ - final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface - { -- use ParameterValueExtractorTrait; -+ use ParameterExtensionTrait; - - public function __construct( -- private readonly ContainerInterface $filterLocator, -- private readonly ?ManagerRegistry $managerRegistry = null, -- private readonly ?LoggerInterface $logger = null, -+ ContainerInterface $filterLocator, -+ ?ManagerRegistry $managerRegistry = null, -+ ?LoggerInterface $logger = null, - ) { -+ $this->filterLocator = $filterLocator; -+ $this->managerRegistry = $managerRegistry; -+ $this->logger = $logger; - } - - /** -@@ -68,30 +68,7 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter - continue; - } - -- if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { -- $filter->setManagerRegistry($this->managerRegistry); -- } -- -- if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { -- $filter->setLogger($this->logger); -- } -- -- if ($filter instanceof PropertyAwareFilterInterface) { -- $properties = []; diff --git a/src/Hal/Tests/Serializer/CollectionNormalizerTest.php b/src/Hal/Tests/Serializer/CollectionNormalizerTest.php index 73095b1cd60..1f5bc63e3f6 100644 --- a/src/Hal/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hal/Tests/Serializer/CollectionNormalizerTest.php @@ -125,8 +125,8 @@ private function normalizePaginator(bool $partial = false): array $paginator->method('current')->willReturn('foo'); // @phpstan-ignore-line if (!$partial) { - $paginator->method('getLastPage')->willReturn(7.); // @phpstan-ignore-line - $paginator->method('getTotalItems')->willReturn(1312.); // @phpstan-ignore-line + $paginator->method('getLastPage')->willReturn(7.); + $paginator->method('getTotalItems')->willReturn(1312.); } else { $paginator->method('count')->willReturn(12); } diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 6e55fa4b467..540a9230771 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -73,9 +73,27 @@ public function create(string $resourceClass, string $property, array $options = // on output the serializer embeds the relation as soon as gen_id is false, even when it is not a readable link (see AbstractItemNormalizer::normalizeRelation()) $link = $isInput ? $propertyMetadata->isWritableLink() : ($propertyMetadata->isReadableLink() || false === $propertyMetadata->getGenId()); - // on output a non-resource object is serialized by the standard object normalizer, which embeds related resources regardless of readableLink (see AbstractItemNormalizer::supportsNormalization()) + // on output a non-resource object is serialized by the standard object normalizer, which embeds non-resource properties regardless of readableLink (see AbstractItemNormalizer::supportsNormalization()) + // For resource-typed properties however, the circular reference handler (see AbstractItemNormalizer::$defaultContext) may produce an IRI, so isReadableLink should determine the schema if (!$isInput && !$this->isResourceClass($resourceClass)) { - $link = true; + if (method_exists(PropertyInfoExtractor::class, 'getType')) { + if (!$propertyMetadata->getNativeType()?->isSatisfiedBy(fn (Type $t) => $t instanceof ObjectType && $this->resourceClassResolver->isResourceClass($t->getClassName()))) { + $link = true; + } + } else { + $propertyTypeIsResource = false; + foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $builtinType) { + $className = $builtinType->isCollection() ? ($builtinType->getCollectionValueTypes()[0] ?? null)?->getClassName() : $builtinType->getClassName(); + if ($className && $this->resourceClassResolver->isResourceClass($className)) { + $propertyTypeIsResource = true; + break; + } + } + + if (!$propertyTypeIsResource) { + $link = true; + } + } } $propertySchema = $propertyMetadata->getSchema() ?? []; diff --git a/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php b/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php index ece12edc0a4..c89fa1ecdce 100644 --- a/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php +++ b/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php @@ -207,11 +207,12 @@ public function testRelationWithGenIdFalseIsEmbeddedInOutputSchema(): void } /** - * A relation borne by a non-resource object (e.g. a raw Doctrine entity embedded as a readable link) - * is serialized by the standard object normalizer, which embeds the related resource regardless of its - * readableLink/genId. The output schema must embed it too, otherwise a strict client rejects the payload. + * A resource-typed relation on a non-resource parent with readableLink=false must be an iri-reference in the + * output schema. The circular reference handler (see AbstractItemNormalizer::$defaultContext) converts the related + * resource to an IRI when a cycle is detected (e.g. A → B[] → A), so isReadableLink must drive the schema + * instead of the old "non-resource parents always embed" heuristic. */ - public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void + public function testRelationOnNonResourceParentFollowsReadableLinkInOutputSchema(): void { if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true $this->markTestSkipped('This test only supports type-info component'); @@ -221,7 +222,7 @@ public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void // the parent (DummyWithEnum) is not a resource, the related class (Dummy) is $resourceClassResolver->method('isResourceClass')->willReturnCallback(static fn (string $class): bool => Dummy::class === $class); - // not a readable link, gen_id left to its default: the relation would normally be an iri-reference string + // readableLink=false: the relation should be an iri-reference string even on a non-resource parent $apiProperty = (new ApiProperty(nativeType: Type::object(Dummy::class))) ->withReadableLink(false); $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); @@ -230,7 +231,32 @@ public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'relatedDummy'); - // defers to SchemaFactory ($ref to embedded subschema) instead of an iri-reference string + $this->assertSame(['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], $apiProperty->getSchema()); + } + + /** + * A relation on a non-resource parent where the property type is also not a resource must still be embedded + * (UNKNOWN_TYPE → resolved as $ref by SchemaFactory). The standard object normalizer embeds non-resource + * objects and isReadableLink is irrelevant here, so the schema defers to SchemaFactory. + */ + public function testNonResourceRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void + { + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true + $this->markTestSkipped('This test only supports type-info component'); + } + + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + // neither the parent nor the property type is a resource + $resourceClassResolver->method('isResourceClass')->willReturn(false); + + $apiProperty = (new ApiProperty(nativeType: Type::object(DummyWithEnum::class))) + ->withReadableLink(false); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(DummyWithEnum::class, 'nested')->willReturn($apiProperty); + + $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); + $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'nested'); + $this->assertSame(['type' => Schema::UNKNOWN_TYPE], $apiProperty->getSchema()); } diff --git a/src/Mcp/Server/ListHandler.php b/src/Mcp/Server/ListHandler.php new file mode 100644 index 00000000000..3ac710dc8d1 --- /dev/null +++ b/src/Mcp/Server/ListHandler.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\Mcp\Server; + +use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Request\ListResourcesRequest; +use Mcp\Schema\Request\ListToolsRequest; +use Mcp\Schema\Result\ListResourcesResult; +use Mcp\Schema\Result\ListToolsResult; +use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +/** + * Serves tools/list and resources/list from the MCP registry, loading API Platform elements + * into it on first use. + * + * The SDK populates the registry once, when mcp.server is built. Under a persistent runtime + * (e.g. FrankenPHP worker mode) that single build can capture an empty registry (cold metadata + * cache) and stays empty for the whole process, so tools/list returns [] while tools/call keeps + * working through the request-time {@see Handler}. Loading the API Platform elements lazily here + * heals that: it runs once per process (registrations are idempotent by name) and reads back + * through the shared registry, so runtime registrations and registry decorators are preserved. + * + * Tagged mcp.request_handler, it takes precedence over the SDK's registry-backed list handlers. + * + * @experimental + * TODO: remove once php-sdk:^0.7 has https://github.com/modelcontextprotocol/php-sdk/pull/389/changes + * + * @implements RequestHandlerInterface + */ +final class ListHandler implements RequestHandlerInterface +{ + private bool $loaded = false; + + public function __construct( + private readonly RegistryInterface $registry, + private readonly LoaderInterface $loader, + private readonly int $pageSize = 20, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ListToolsRequest || $request instanceof ListResourcesRequest; + } + + /** + * @return Response + */ + public function handle(Request $request, SessionInterface $session): Response + { + if (!$this->loaded) { + $this->loader->load($this->registry); + $this->loaded = true; + } + + if ($request instanceof ListResourcesRequest) { + $page = $this->registry->getResources($this->pageSize, $request->cursor); + $result = new ListResourcesResult($page->references, $page->nextCursor); + } else { + \assert($request instanceof ListToolsRequest); + $page = $this->registry->getTools($this->pageSize, $request->cursor); + $result = new ListToolsResult($page->references, $page->nextCursor); + } + + return new Response($request->getId(), $result); + } +} diff --git a/src/Mcp/Tests/Server/ListHandlerTest.php b/src/Mcp/Tests/Server/ListHandlerTest.php new file mode 100644 index 00000000000..a8bfd4bdf4a --- /dev/null +++ b/src/Mcp/Tests/Server/ListHandlerTest.php @@ -0,0 +1,140 @@ + + * + * 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\Mcp\Tests\Server; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Mcp\Capability\Registry\Loader; +use ApiPlatform\Mcp\Server\ListHandler; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use Mcp\Capability\Registry; +use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\Request\ListResourcesRequest; +use Mcp\Schema\Request\ListToolsRequest; +use Mcp\Schema\Result\ListResourcesResult; +use Mcp\Schema\Result\ListToolsResult; +use Mcp\Schema\Tool; +use Mcp\Server\Session\SessionInterface; +use PHPUnit\Framework\TestCase; + +class ListHandlerTest extends TestCase +{ + public function testListToolsLoadsApiPlatformElementsIntoTheRegistry(): void + { + $inputSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($inputSchema['$schema']); + $inputSchema['type'] = 'object'; + $inputSchema['properties'] = ['query' => ['type' => 'string']]; + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturn($inputSchema); + + $mcpTool = new McpTool( + name: 'search', + description: 'Search things', + structuredContent: false, + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['search' => $mcpTool]); + + $registry = new Registry(); + $handler = new ListHandler($registry, $this->createLoader($resource, $schemaFactory)); + + $result = $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class))->result; + + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(1, $result->tools); + $this->assertSame('search', $result->tools[0]->name); + } + + public function testListResourcesLoadsApiPlatformElementsIntoTheRegistry(): void + { + $mcpResource = new McpResource( + uri: 'dummy://docs', + name: 'docs', + description: 'Documentation resource', + mimeType: 'text/plain', + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['docs' => $mcpResource]); + + $registry = new Registry(); + $handler = new ListHandler($registry, $this->createLoader($resource, $this->createMock(SchemaFactoryInterface::class))); + + $result = $handler->handle((new ListResourcesRequest())->withId(1), $this->createMock(SessionInterface::class))->result; + + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(1, $result->resources); + $this->assertSame('dummy://docs', $result->resources[0]->uri); + } + + /** + * Reading through the shared registry (rather than a private one) keeps tools registered at + * runtime — e.g. dynamically discovered affordances — visible in tools/list. + */ + public function testListToolsIncludesToolsRegisteredAtRuntime(): void + { + $registry = new Registry(); + $registry->registerTool(new Tool(name: 'runtime_tool', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: null, annotations: null), 'runtime_handler'); + + $loader = $this->createMock(LoaderInterface::class); + $handler = new ListHandler($registry, $loader); + + $result = $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class))->result; + + $names = array_map(static fn (Tool $t): string => $t->name, $result->tools); + $this->assertContains('runtime_tool', $names); + } + + public function testElementsAreLoadedOncePerProcess(): void + { + $registry = $this->createMock(RegistryInterface::class); + $registry->method('getTools')->willReturn(new \Mcp\Schema\Page([], null)); + + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->once())->method('load'); + + $handler = new ListHandler($registry, $loader); + $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class)); + $handler->handle((new ListToolsRequest())->withId(2), $this->createMock(SessionInterface::class)); + } + + public function testSupportsListRequests(): void + { + $handler = new ListHandler($this->createMock(RegistryInterface::class), $this->createMock(LoaderInterface::class)); + + $this->assertTrue($handler->supports(new ListToolsRequest())); + $this->assertTrue($handler->supports(new ListResourcesRequest())); + } + + private function createLoader(ApiResource $resource, SchemaFactoryInterface $schemaFactory): Loader + { + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + return new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + } +} diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index f55734196e5..3972b1e8978 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -405,7 +405,10 @@ private function setDefaults(string $key, Parameter $parameter, ?object $filter, // Skip name conversion if we already have nested property info $paramExtraProps = $parameter->getExtraProperties(); if (!isset($paramExtraProps['nested_properties_info'])) { - $parameter = $parameter->withProperty($this->nameConverter->normalize($property)); + // Keep the original (non name-converted) property to build the query while the public property matches the API naming convention. + $parameter = $parameter + ->withExtraProperties([...$paramExtraProps, 'query_property' => $property]) + ->withProperty($this->nameConverter->normalize($property)); } } diff --git a/src/Metadata/Util/ContentNegotiationTrait.php b/src/Metadata/Util/ContentNegotiationTrait.php index 9f951261a4e..4f8cb15f650 100644 --- a/src/Metadata/Util/ContentNegotiationTrait.php +++ b/src/Metadata/Util/ContentNegotiationTrait.php @@ -119,12 +119,12 @@ private function getRequestFormat(Request $request, array $formats, bool $throw if (null !== $requestFormat) { $mimeType = $request->getMimeType($requestFormat); - if (isset($flattenedMimeTypes[$mimeType])) { + if (null !== $mimeType && isset($flattenedMimeTypes[$mimeType])) { return $requestFormat; } if ($throw) { - throw $this->getNotAcceptableHttpException($mimeType, $flattenedMimeTypes); + throw $this->getNotAcceptableHttpException($mimeType ?? $requestFormat, $flattenedMimeTypes); } } diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 50f2984ce28..365bdbc4319 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -51,6 +51,7 @@ use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\EnumType; use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; @@ -1299,11 +1300,19 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value } } + // A nullable collection value type (e.g. an array of a nullable BackedEnum/object) is represented + // as a NullableType wrapping the real ObjectType, so it must be unwrapped before the instanceof + // check below; nested CollectionType wrapping is left untouched to keep union/collection detection intact. + $unwrappedCollectionValueType = $collectionValueType; + while ($unwrappedCollectionValueType instanceof WrappingTypeInterface && !$unwrappedCollectionValueType instanceof CollectionType) { + $unwrappedCollectionValueType = $unwrappedCollectionValueType->getWrappedType(); + } + if ( - ($t instanceof CollectionType && $collectionValueType instanceof ObjectType) + ($t instanceof CollectionType && $unwrappedCollectionValueType instanceof ObjectType) || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== $collectionValueType->getClassName()) ) { - $className = $collectionValueType->getClassName(); + $className = $unwrappedCollectionValueType instanceof ObjectType ? $unwrappedCollectionValueType->getClassName() : $collectionValueType->getClassName(); if (!$this->serializer instanceof DenormalizerInterface) { throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); } @@ -1437,12 +1446,42 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value if ($denormalizationException) { if ($type instanceof Type && $type->isSatisfiedBy(static fn ($type) => $type instanceof BuiltinType) && !$type->isSatisfiedBy($typeIsResourceClass)) { - // If the exception came from object denormalization, preserve its message as it's more specific - $message = $type->isSatisfiedBy(static fn ($type) => $type instanceof ObjectType) + // When the union contains an object member that is not a resource class (e.g. a nullable + // Uuid), the exception stashed while denormalizing it comes from its dedicated normalizer + // and carries the expected types actually accepted on the wire (e.g. ["string"]) plus a + // hint that is safe to expose to the user. Rebuilding the error from the PHP type union + // would report unactionable expected types such as "Uuid|null" instead, so re-throw it, + // only extending its expected types with "null" when the property also accepts null. + // Enum members are deliberately not re-thrown here: their violations are rendered from + // the rebuilt exception below, whose expected types are mapped to the enum backing type + // downstream (see DeserializeProvider). + foreach ($types as $memberType) { + while ($memberType instanceof WrappingTypeInterface && !$memberType instanceof CollectionType) { + $memberType = $memberType->getWrappedType(); + } + + if (!$memberType instanceof ObjectType || $memberType instanceof EnumType || $this->resourceClassResolver->isResourceClass($memberType->getClassName())) { + continue; + } + + $expectedTypes = $denormalizationException->getExpectedTypes(); + if ($type->isNullable() && $expectedTypes && !\in_array('null', $expectedTypes, true)) { + throw NotNormalizableValueException::createForUnexpectedDataType($denormalizationException->getMessage(), $value, [...$expectedTypes, 'null'], $denormalizationException->getPath() ?? $context['deserialization_path'] ?? null, $denormalizationException->canUseMessageForUser(), $denormalizationException->getCode(), $denormalizationException); + } + + throw $denormalizationException; + } + + // If the exception came from object denormalization (e.g. BackedEnumNormalizer for a + // nullable backed enum), preserve its more specific message and its user-facing hint flag; + // the expected types still carry the object/enum class so the failure stays recognizable + // downstream (see DeserializeProvider). + $isObject = $type->isSatisfiedBy(static fn ($type) => $type instanceof ObjectType); + $message = $isObject ? $denormalizationException->getMessage() : \sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $type, \gettype($value)); - throw NotNormalizableValueException::createForUnexpectedDataType($message, $value, array_map(strval(...), $types), $context['deserialization_path'] ?? null, false, 0, $denormalizationException); + throw NotNormalizableValueException::createForUnexpectedDataType($message, $value, array_map(strval(...), $types), $context['deserialization_path'] ?? null, $isObject && $denormalizationException->canUseMessageForUser(), 0, $denormalizationException); } throw $denormalizationException; diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 5bdebb37817..bc17aae5005 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -40,6 +40,7 @@ use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithMultipleRequiredConstructorArgs; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithNullableConstructorArg; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NonCloneableDummy; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NotificationType; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnly; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnlyRelation; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\RelatedDummy; @@ -1335,6 +1336,135 @@ public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void $this->assertInstanceOf(Dummy::class, $actual); } + public function testDenormalizeNullableCollectionOfBackedEnums(): void + { + // Nullable collection value types (NullableType wrapping ObjectType/BackedEnumType) only exist + // in the native TypeInfo system. + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + $data = ['notificationType' => ['email']]; + + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection(['notificationType'])); + + // Mirrors what Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor produces for a nullable + // Doctrine SIMPLE_ARRAY column mapped with "enumType": both the collection and its value type + // end up wrapped in a NullableType. + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn( + (new ApiProperty()) + ->withNativeType(Type::nullable(Type::list(Type::nullable(Type::enum(NotificationType::class))))) + ->withWritable(true) + ); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnMap([ + [Dummy::class, true], + [NotificationType::class, false], + ]); + $resourceClassResolver->method('getResourceClass')->willReturnMap([ + [null, Dummy::class, Dummy::class], + ]); + + $propertyAccessor = $this->createMock(PropertyAccessorInterface::class); + $propertyAccessor->expects($this->once()) + ->method('setValue') + ->with($this->isInstanceOf(Dummy::class), 'notificationType', [NotificationType::Email]); + + $iriConverter = $this->createStub(IriConverterInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + $serializerProphecy->denormalize(['email'], NotificationType::class.'[]', null, Argument::type('array'))->willReturn([NotificationType::Email]); + + $normalizer = new class($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + $actual = $normalizer->denormalize($data, Dummy::class); + + $this->assertInstanceOf(Dummy::class, $actual); + } + + public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreservesNormalizerException(): void + { + // Nullable object types (NullableType wrapping ObjectType) only exist in the native TypeInfo system. + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + // What Symfony's DateTimeNormalizer throws for a value it cannot parse. + $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); + + $normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::nullable(Type::object(\DateTimeImmutable::class)), \DateTimeImmutable::class, $normalizerException); + + try { + $normalizer->denormalize(['dummyDate' => false], Dummy::class); + $this->fail('Expected a NotNormalizableValueException to be thrown.'); + } catch (NotNormalizableValueException $exception) { + $this->assertSame(['string', 'null'], $exception->getExpectedTypes()); + $this->assertTrue($exception->canUseMessageForUser()); + $this->assertSame($normalizerException->getMessage(), $exception->getMessage()); + $this->assertSame('dummyDate', $exception->getPath()); + } + } + + public function testDenormalizeWrongTypedValueForNonNullableObjectPropertyPreservesNormalizerException(): void + { + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); + + $normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::object(\DateTimeImmutable::class), \DateTimeImmutable::class, $normalizerException); + + try { + $normalizer->denormalize(['dummyDate' => false], Dummy::class); + $this->fail('Expected a NotNormalizableValueException to be thrown.'); + } catch (NotNormalizableValueException $exception) { + $this->assertSame(['string'], $exception->getExpectedTypes()); + $this->assertTrue($exception->canUseMessageForUser()); + $this->assertSame($normalizerException->getMessage(), $exception->getMessage()); + $this->assertSame('dummyDate', $exception->getPath()); + } + } + + private function createNormalizerForObjectProperty(string $attribute, Type $nativeType, string $className, NotNormalizableValueException $normalizerException): AbstractItemNormalizer + { + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection([$attribute])); + + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn( + (new ApiProperty()) + ->withNativeType($nativeType) + ->withWritable(true) + ); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnMap([ + [Dummy::class, true], + [$className, false], + ]); + $resourceClassResolver->method('getResourceClass')->willReturnMap([ + [null, Dummy::class, Dummy::class], + ]); + + $propertyAccessor = $this->createStub(PropertyAccessorInterface::class); + $iriConverter = $this->createStub(IriConverterInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + $serializerProphecy->denormalize(false, $className, null, Argument::type('array'))->willThrow($normalizerException); + + $normalizer = new class($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + return $normalizer; + } + public function testTypeConfusionGuardPreservesPathAndExpectedType(): void { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); diff --git a/src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php b/src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php new file mode 100644 index 00000000000..279299ac4d4 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php @@ -0,0 +1,20 @@ + + * + * 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\Serializer\Tests\Fixtures\ApiResource; + +enum NotificationType: string +{ + case Email = 'email'; + case Sms = 'sms'; +} diff --git a/src/State/Provider/SecurityParameterProvider.php b/src/State/Provider/SecurityParameterProvider.php index 238908b2362..301c2d5cb36 100644 --- a/src/State/Provider/SecurityParameterProvider.php +++ b/src/State/Provider/SecurityParameterProvider.php @@ -52,7 +52,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $operation = $request?->attributes->get('_api_operation') ?? $operation; - $parameters = $operation->getParameters() ?? new Parameters(); + $existingParameters = $operation->getParameters(); + $parameters = $existingParameters ? clone $existingParameters : new Parameters(); if ($operation instanceof HttpOperation) { foreach ($operation->getUriVariables() ?? [] as $key => $uriVariable) { diff --git a/src/State/Tests/Provider/SecurityParameterProviderTest.php b/src/State/Tests/Provider/SecurityParameterProviderTest.php index ee7bbae8710..bccc26be323 100644 --- a/src/State/Tests/Provider/SecurityParameterProviderTest.php +++ b/src/State/Tests/Provider/SecurityParameterProviderTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\State\Provider\SecurityParameterProvider; use ApiPlatform\State\ProviderInterface; @@ -28,9 +29,10 @@ public function testIsGrantedLink(): void { $obj = new \stdClass(); $barObj = new \stdClass(); + $sharedParams = new Parameters(); $operation = new GetCollection(uriVariables: [ 'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")'), - ], class: \stdClass::class); + ], class: \stdClass::class, parameters: $sharedParams); $decorated = $this->createMock(ProviderInterface::class); $decorated->method('provide')->willReturn($obj); $request = new Request(attributes: ['bar' => $barObj]); @@ -38,6 +40,7 @@ public function testIsGrantedLink(): void $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(true); $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); + $this->assertCount(0, $sharedParams, 'provide() must not mutate the Parameters object stored on the cached operation'); } public function testIsNotGrantedLink(): void diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index c103878d3e6..623f0ede027 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -369,6 +369,11 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.patch_formats', $patchFormats); $container->setParameter('api_platform.error_formats', $errorFormats); $container->setParameter('api_platform.docs_formats', $docsFormats); + // The entrypoint only has normalizers for hypermedia formats (jsonld, jsonhal, jsonapi) and a + // dedicated Swagger UI code path for html. Other documentation formats (e.g. openapi, yamlopenapi) + // have no Entrypoint normalizer and must not be advertised, otherwise content negotiation lets them + // through and the Symfony ObjectNormalizer fallback leaks the internal ResourceNameCollection FQCNs. + $container->setParameter('api_platform.entrypoint_formats', array_intersect_key($docsFormats, array_flip(['jsonld', 'jsonhal', 'jsonapi', 'html']))); $container->setParameter('api_platform.jsonschema_formats', []); $container->setParameter('api_platform.eager_loading.enabled', $this->isConfigEnabled($container, $config['eager_loading'])); $container->setParameter('api_platform.eager_loading.max_joins', $config['eager_loading']['max_joins']); diff --git a/src/Symfony/Bundle/Resources/config/mcp/mcp.php b/src/Symfony/Bundle/Resources/config/mcp/mcp.php index 97e042661b3..4740dde1f38 100644 --- a/src/Symfony/Bundle/Resources/config/mcp/mcp.php +++ b/src/Symfony/Bundle/Resources/config/mcp/mcp.php @@ -17,6 +17,7 @@ use ApiPlatform\Mcp\JsonSchema\SchemaFactory; use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory; use ApiPlatform\Mcp\Routing\IriConverter; +use ApiPlatform\Mcp\Server\ListHandler; use ApiPlatform\Mcp\State\ToolProvider; return static function (ContainerConfigurator $container) { @@ -35,6 +36,18 @@ ]) ->tag('mcp.loader'); + // Serves tools/list and resources/list, loading API Platform elements into the registry on + // first use. This heals a persistent runtime (e.g. FrankenPHP worker mode) where the SDK + // builds the registry once and may capture an empty state. Reads back through the shared + // registry so runtime registrations and decorators are preserved. Takes precedence over the + // SDK's registry-backed list handlers. + $services->set('api_platform.mcp.list_handler', ListHandler::class) + ->args([ + service('mcp.registry'), + service('api_platform.mcp.loader'), + ]) + ->tag('mcp.request_handler'); + $services->set('api_platform.mcp.iri_converter', IriConverter::class) ->decorate('api_platform.iri_converter', null, 300) ->args([ diff --git a/src/Symfony/Bundle/Resources/config/openapi.php b/src/Symfony/Bundle/Resources/config/openapi.php index b09d51abc8b..f164982ae01 100644 --- a/src/Symfony/Bundle/Resources/config/openapi.php +++ b/src/Symfony/Bundle/Resources/config/openapi.php @@ -23,14 +23,20 @@ use ApiPlatform\OpenApi\Serializer\SerializerContextBuilder; use ApiPlatform\OpenApi\State\OpenApiProvider; use ApiPlatform\Serializer\JsonEncoder; +use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; return function (ContainerConfigurator $container) { $services = $container->services(); + // A metadata-aware name converter isolated from Symfony's global one so the generated + // document only honors serializer metadata (e.g. #[SerializedName]) without the global name converter. + $services->set('api_platform.openapi.name_converter', MetadataAwareNameConverter::class) + ->args([service('serializer.mapping.class_metadata_factory')]); + $services->set('api_platform.openapi.normalizer', OpenApiNormalizer::class) - ->args([inline_service(Serializer::class)->arg(0, [inline_service(ObjectNormalizer::class)->arg(0, service('serializer.mapping.class_metadata_factory'))->arg(1, service('serializer.name_converter.metadata_aware'))->arg(2, service('api_platform.property_accessor'))->arg(3, service('api_platform.property_info'))])->arg(1, [service('serializer.encoder.json')])]) + ->args([inline_service(Serializer::class)->arg(0, [inline_service(ObjectNormalizer::class)->arg(0, service('serializer.mapping.class_metadata_factory'))->arg(1, service('api_platform.openapi.name_converter'))->arg(2, service('api_platform.property_accessor'))->arg(3, service('api_platform.property_info'))])->arg(1, [service('serializer.encoder.json')])]) ->tag('serializer.normalizer', ['priority' => -795]); $services->alias(OpenApiNormalizer::class, 'api_platform.openapi.normalizer'); diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.php b/src/Symfony/Bundle/Resources/config/symfony/controller.php index 2ac24e613e0..3bbed6d6106 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.php +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.php @@ -36,7 +36,7 @@ service('api_platform.metadata.resource.name_collection_factory'), service('api_platform.state_provider.main'), service('api_platform.state_processor.main'), - '%api_platform.docs_formats%', + '%api_platform.entrypoint_formats%', ]); $services->set('api_platform.action.documentation', DocumentationAction::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index 23105235373..22451e0bd6c 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -195,7 +195,7 @@ service('api_platform.metadata.resource.name_collection_factory'), service('api_platform.state_provider.documentation'), service('api_platform.state_processor.documentation'), - '%api_platform.docs_formats%', + '%api_platform.entrypoint_formats%', ]); $services->set('api_platform.action.documentation', DocumentationAction::class) diff --git a/src/Symfony/EventListener/DeserializeListener.php b/src/Symfony/EventListener/DeserializeListener.php index bf34cea24ca..0a00de57633 100644 --- a/src/Symfony/EventListener/DeserializeListener.php +++ b/src/Symfony/EventListener/DeserializeListener.php @@ -76,6 +76,10 @@ public function onKernelRequest(RequestEvent $event): void } if (!$operation->canDeserialize()) { + if (!$request->attributes->has('data')) { + $request->attributes->set('data', null); + } + return; } diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 5505f07337a..854a06f2515 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -32,9 +32,11 @@ use PHPUnit\Framework\TestCase; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; class ApiPlatformExtensionTest extends TestCase { @@ -301,6 +303,19 @@ public function testSwaggerUiEnabledConfiguration(): void $this->assertContainerHas($services); } + public function testOpenApiNameConverterDoesNotDependOnMissingSymfonyParent(): void + { + $config = self::DEFAULT_CONFIG; + $config['api_platform']['enable_swagger'] = true; + + (new ApiPlatformExtension())->load($config, $this->container); + + $definition = $this->container->getDefinition('api_platform.openapi.name_converter'); + + $this->assertNotInstanceOf(ChildDefinition::class, $definition, 'The OpenAPI name converter must not depend on Symfony\'s "serializer.name_converter.metadata_aware.abstract" parent, which does not exist when no name converter is configured.'); + $this->assertSame(MetadataAwareNameConverter::class, $definition->getClass()); + } + public function testReDocEnabledWithSwaggerUiDisabledConfiguration(): void { $config = self::DEFAULT_CONFIG; diff --git a/src/Symfony/Validator/State/ParameterValidatorProvider.php b/src/Symfony/Validator/State/ParameterValidatorProvider.php index 62af5b22c28..b814f378dd8 100644 --- a/src/Symfony/Validator/State/ParameterValidatorProvider.php +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -52,7 +52,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $constraintViolationList = new ConstraintViolationList(); - $parameters = $operation->getParameters() ?? new Parameters(); + $existingParameters = $operation->getParameters(); + $parameters = $existingParameters ? clone $existingParameters : new Parameters(); if ($operation instanceof HttpOperation) { foreach ($operation->getUriVariables() ?? [] as $key => $uriVariable) { diff --git a/src/Validator/DenormalizationViolationFactory.php b/src/Validator/DenormalizationViolationFactory.php index 76b38e69cb2..2296c38ed9c 100644 --- a/src/Validator/DenormalizationViolationFactory.php +++ b/src/Validator/DenormalizationViolationFactory.php @@ -229,6 +229,13 @@ private function normalizeExpectedTypes(?array $expectedTypes): array $normalized = []; foreach ($expectedTypes ?? [] as $expectedType) { if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType))) { + // A backed enum is sent over the wire as its backing scalar (e.g. "string"), not as the + // PHP enum class, so report the JSON-visible type rather than the internal FQCN (#8388). + if (is_subclass_of($expectedType, \BackedEnum::class) && ($backingType = (new \ReflectionEnum($expectedType))->getBackingType())) { + $normalized[] = (string) $backingType; + continue; + } + $pos = strrpos($expectedType, '\\'); $normalized[] = false === $pos ? $expectedType : substr($expectedType, $pos + 1); continue; @@ -236,6 +243,6 @@ private function normalizeExpectedTypes(?array $expectedTypes): array $normalized[] = $expectedType; } - return $normalized; + return array_values(array_unique($normalized)); } } diff --git a/tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.php b/tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.php new file mode 100644 index 00000000000..20aa8cfe0ec --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.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\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FetchDataFalseStateOptionsEntity; + +/** + * DTO resource whose class differs from its Doctrine entity: the identifier link's fromClass is this + * resource class, so getReferenceIdentifiers() must compare against it (not the entity class) for the + * fetch_data=false reference fast-path to trigger. + */ +#[ApiResource( + shortName: 'FetchDataFalseStateOptions', + operations: [new Get()], + stateOptions: new Options(entityClass: FetchDataFalseStateOptionsEntity::class), +)] +class FetchDataFalseStateOptions +{ + public ?int $id = null; + + public ?string $name = null; +} diff --git a/tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.php b/tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.php new file mode 100644 index 00000000000..2a49737067a --- /dev/null +++ b/tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.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\Doctrine\Orm; + +use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FetchDataFalseStateOptionsEntity; +use Doctrine\ORM\QueryBuilder; + +/** + * Excludes every FetchDataFalseStateOptionsEntity row from item queries. A fetch_data=false request must + * return a getReference() and skip the query entirely, so this extension must never run for it; if it does, + * the item resolves to null. Scoped to the fixture entity so it does not affect other resources. + */ +final class FetchDataFalseStateOptionsItemExtension implements QueryItemExtensionInterface +{ + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void + { + if (FetchDataFalseStateOptionsEntity::class !== $resourceClass) { + return; + } + + $queryBuilder->andWhere('1 = 0'); + } +} diff --git a/tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.php b/tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.php new file mode 100644 index 00000000000..9a44bc34e0a --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.php @@ -0,0 +1,57 @@ + + * + * 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\Document; + +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Tests that modern parameter filters (ExactFilter, SortFilter) work with + * QueryParameter on a multi-word property when a nameConverter is configured + * (issue #8380). + */ +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'nameConverted' => new QueryParameter( + filter: new ExactFilter(), + property: 'nameConverted', + ), + 'orderNameConverted' => new QueryParameter( + filter: new SortFilter(), + property: 'nameConverted', + ), + ], + ), + ], +)] +#[ODM\Document] +class ConvertedFilterParameter +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $nameConverted = ''; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php b/tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php new file mode 100644 index 00000000000..1fcc69cba1e --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php @@ -0,0 +1,55 @@ + + * + * 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\Document\IriFilterScalarEnum; + +use ApiPlatform\Doctrine\Odm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[ApiResource( + shortName: 'IriFilterScalarEnumGame', + operations: [ + new GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'playMode' => new QueryParameter(filter: new IriFilter()), + ], + ), + new Get(), + ] +)] +class Game +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $name; + + // Scalar field backed by an enum that is itself exposed as an API resource: + // the enum is never a Doctrine reference, so IriFilter must resolve the IRI + // to the enum case and match the scalar field against its backing value. + #[ODM\Field(type: 'string', enumType: GamePlayMode::class)] + public GamePlayMode $playMode = GamePlayMode::SINGLE_PLAYER; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php b/tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php new file mode 100644 index 00000000000..302a2c63e10 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php @@ -0,0 +1,59 @@ + + * + * 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\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +/** + * Tests that modern parameter filters (ExactFilter, SortFilter) work with + * QueryParameter on a multi-word property when a nameConverter is configured + * (issue #8380). + */ +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'nameConverted' => new QueryParameter( + filter: new ExactFilter(), + property: 'nameConverted', + ), + 'orderNameConverted' => new QueryParameter( + filter: new SortFilter(), + property: 'nameConverted', + ), + ], + ), + ], +)] +#[ORM\Entity] +class ConvertedFilterParameter +{ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(type: 'string')] + public string $nameConverted = ''; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.php b/tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.php new file mode 100644 index 00000000000..80f805c13e5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.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\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * Backing entity for the FetchDataFalseStateOptions DTO resource. + */ +#[ORM\Entity] +class FetchDataFalseStateOptionsEntity +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + public ?int $id = null; + + #[ORM\Column(length: 255)] + public ?string $name = null; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php b/tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php new file mode 100644 index 00000000000..d582a780df4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php @@ -0,0 +1,57 @@ + + * + * 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\IriFilterScalarEnum; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + shortName: 'IriFilterScalarEnumGame', + operations: [ + new GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'playMode' => new QueryParameter(filter: new IriFilter()), + ], + ), + new Get(), + ] +)] +class Game +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + public string $name; + + // Scalar column backed by an enum that is itself exposed as an API resource: + // the enum can never be a Doctrine association, so IriFilter must resolve the + // IRI to the enum case and compare the scalar column to its backing value. + #[ORM\Column(type: 'string', enumType: GamePlayMode::class)] + public GamePlayMode $playMode = GamePlayMode::SINGLE_PLAYER; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index b046bc20e2f..7507fab3287 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -133,6 +133,10 @@ services: tags: - name: 'api_platform.state_provider' + ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm\FetchDataFalseStateOptionsItemExtension: + tags: + - name: 'api_platform.doctrine.orm.query_extension.item' + ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider' public: false diff --git a/tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.php b/tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.php new file mode 100644 index 00000000000..f493fd8a561 --- /dev/null +++ b/tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.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\Functional\Doctrine; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FetchDataFalseStateOptions; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FetchDataFalseStateOptionsEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class FetchDataFalseStateOptionsResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FetchDataFalseStateOptions::class]; + } + + public function testFetchDataFalseReturnsReferenceForStateOptionsResourceAndSkipsItemExtensions(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped('getReference() is ORM specific.'); + } + + $this->recreateSchema([FetchDataFalseStateOptionsEntity::class]); + + $manager = $this->getManager(); + $entity = new FetchDataFalseStateOptionsEntity(); + $entity->name = 'test'; + $manager->persist($entity); + $manager->flush(); + $id = $entity->id; + $manager->clear(); + + /** @var ResourceMetadataCollectionFactoryInterface $factory */ + $factory = $container->get('api_platform.metadata.resource.metadata_collection_factory'); + $operation = $factory->create(FetchDataFalseStateOptions::class)->getOperation(); + + /** @var ProviderInterface $provider */ + $provider = $container->get('api_platform.doctrine.orm.state.item_provider'); + + // FetchDataFalseStateOptionsItemExtension excludes every row from the item query. With fetch_data=false + // the provider must return EntityManager::getReference() and never build that query, so the extension is + // skipped and the reference is returned. Before the DTO fix the resource-vs-entity class mismatch made + // getReferenceIdentifiers() return null, the provider fell through to the query, the extension ran, and + // this resolved to null. + $reference = $provider->provide($operation, ['id' => $id], ['fetch_data' => false]); + + $this->assertInstanceOf(FetchDataFalseStateOptionsEntity::class, $reference); + $this->assertSame($id, $reference->getId()); + } +} diff --git a/tests/Functional/EntrypointFormatTest.php b/tests/Functional/EntrypointFormatTest.php new file mode 100644 index 00000000000..b1ac4df6780 --- /dev/null +++ b/tests/Functional/EntrypointFormatTest.php @@ -0,0 +1,125 @@ + + * + * 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\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MultipleResourceBook; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * The entrypoint must only expose hypermedia formats that have a dedicated + * EntrypointNormalizer (jsonld, jsonhal, jsonapi). Documentation formats + * (openapi, html) have no such normalizer and must not be served, otherwise + * the Symfony ObjectNormalizer fallback dumps the public ResourceNameCollection, + * leaking internal PHP FQCNs. + * + * @see https://github.com/api-platform/core/issues/8361 + */ +final class EntrypointFormatTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MultipleResourceBook::class]; + } + + public function testEntrypointRejectsOpenApiAcceptHeader(): void + { + $response = self::createClient()->request('GET', '/', [ + 'headers' => ['accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseStatusCodeSame(406); + } + + /** + * The ".jsonopenapi"/".yamlopenapi" URL suffixes are resolved by routing before + * content negotiation runs, so an unsupported route format yields a 404 + * (consistent with any other resource requested with an unsupported format + * suffix), not a 406. + */ + public function testEntrypointRejectsOpenApiFormatSuffix(): void + { + $response = self::createClient()->request('GET', '/index.jsonopenapi', [ + 'headers' => ['accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testEntrypointRejectsYamlOpenApiFormatSuffix(): void + { + $response = self::createClient()->request('GET', '/index.yamlopenapi', [ + 'headers' => ['accept' => 'application/vnd.openapi+yaml'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testEntrypointStillServesHtmlAsSwaggerUi(): void + { + $response = self::createClient()->request('GET', '/index.html', [ + 'headers' => ['accept' => 'text/html'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString('swagger-ui', $response->getContent()); + } + + public function testEntrypointStillServesJsonLd(): void + { + $response = self::createClient()->request('GET', '/index.jsonld', [ + 'headers' => ['accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('multipleResourceBook2', $data); + $this->assertEquals('/multi_route_books', $data['multipleResourceBook2']); + $this->assertStringNotContainsString('resourceNameCollection', $response->getContent()); + } + + public function testEntrypointStillServesJsonHal(): void + { + $response = self::createClient()->request('GET', '/index.jsonhal', [ + 'headers' => ['accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/hal+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('_links', $data); + $this->assertArrayHasKey('multipleResourceBook2', $data['_links']); + } + + public function testEntrypointStillServesJsonApi(): void + { + $response = self::createClient()->request('GET', '/index.jsonapi', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('links', $data); + $this->assertArrayHasKey('multipleResourceBook2', $data['links']); + } +} diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index 8d340915433..e469989fa2e 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -77,6 +77,29 @@ public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): vo $this->assertNotNull($this->findViolation($content['violations'] ?? [], 'gender')); } + /** + * @see https://github.com/api-platform/core/issues/8388 + */ + public function testWrongTypeForBackedEnumReportsAcceptedScalarTypes(): void + { + $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['gender' => true], + ]); + + $this->assertResponseStatusCodeSame(422); + + $content = $response->toArray(false); + $genderViolation = $this->findViolation($content['violations'] ?? [], 'gender'); + $this->assertNotNull($genderViolation); + // The message must name what a JSON consumer can actually send: the enum's backing scalar + // ("string") and, since the property is nullable, "null" — never the internal PHP type + // "GenderTypeEnum|null", which appears nowhere in the OpenAPI schema. + $this->assertSame('This value should be of type string|null.', $genderViolation['message']); + $this->assertStringNotContainsString('GenderTypeEnum', $genderViolation['message']); + $this->assertStringContainsString('enumeration case of type', $genderViolation['hint'] ?? ''); + } + private function findViolation(array $violations, string $propertyPath): ?array { foreach ($violations as $violation) { diff --git a/tests/Functional/JsonLd/InputOutputDtoTest.php b/tests/Functional/JsonLd/InputOutputDtoTest.php index e52474d0f82..45c40db9b26 100644 --- a/tests/Functional/JsonLd/InputOutputDtoTest.php +++ b/tests/Functional/JsonLd/InputOutputDtoTest.php @@ -158,10 +158,6 @@ public function testInputOutputCycle(): void public function testCreateNoInputResource(): void { - if ($_SERVER['USE_SYMFONY_LISTENERS'] ?? false) { - $this->markTestSkipped('PlaceholderAction cannot resolve $data when input:false in event-listener mode.'); - } - $response = self::createClient()->request('POST', '/jsonld_no_inputs', [ 'headers' => ['Accept' => 'application/ld+json'], ]); diff --git a/tests/Functional/OpenApiNameConverterTest.php b/tests/Functional/OpenApiNameConverterTest.php new file mode 100644 index 00000000000..2ff0828ca32 --- /dev/null +++ b/tests/Functional/OpenApiNameConverterTest.php @@ -0,0 +1,90 @@ + + * + * 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\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8143\ReferenceResponse; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class OpenApiNameConverterAppKernel extends \AppKernel +{ + public function getCacheDir(): string + { + return parent::getCacheDir().'/openapi_name_converter'; + } + + public function getLogDir(): string + { + return parent::getLogDir().'/openapi_name_converter'; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('framework', [ + 'serializer' => [ + 'name_converter' => 'serializer.name_converter.camel_case_to_snake_case', + ], + ]); + }); + } +} + +final class OpenApiNameConverterTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = true; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ReferenceResponse::class]; + } + + protected static function getKernelClass(): string + { + return OpenApiNameConverterAppKernel::class; + } + + public function testGlobalNameConverterDoesNotLeakIntoOpenApiDocument(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(); + $content = $response->getContent(); + + // OpenAPI keys must stay camelCase even when a global name converter is configured. + $this->assertStringContainsString('"operationId"', $content); + $this->assertStringNotContainsString('"operation_id"', $content); + $this->assertStringNotContainsString('"extension_properties"', $content); + $this->assertStringNotContainsString('"external_docs"', $content); + $this->assertStringNotContainsString('"request_bodies"', $content); + $this->assertStringNotContainsString('"security_schemes"', $content); + + // The #[SerializedName('$ref')] metadata must still be honored. + $responses = $json['paths']['/issue8143_reference_response']['post']['responses']; + $this->assertArrayHasKey('$ref', $responses['401']); + $this->assertSame('#/components/responses/401', $responses['401']['$ref']); + } +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index ada32910f10..e351cbf42d7 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -688,24 +688,30 @@ public function testOpenApiUiIsEnabledForDocsEndpointWithDummyObject(): void $this->assertResponseIsSuccessful(); } - public function testRetrieveTheEntrypoint(): void + /** + * @see https://github.com/api-platform/core/issues/8361 + */ + public function testEntrypointRejectsOpenApiFormat(): void { $response = self::createClient()->request('GET', '/', [ 'headers' => ['Accept' => 'application/vnd.openapi+json'], ]); - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.openapi+json; charset=utf-8'); - $this->assertJson($response->getContent()); + $this->assertResponseStatusCodeSame(406); } - public function testRetrieveTheEntrypointWithUrlFormat(): void + /** + * The ".jsonopenapi" URL suffix is resolved by routing before content negotiation + * runs, so an unsupported route format yields a 404 (consistent with any other + * resource requested with an unsupported format suffix), not a 406. + * + * @see https://github.com/api-platform/core/issues/8361 + */ + public function testEntrypointRejectsOpenApiFormatWithUrlFormat(): void { $response = self::createClient()->request('GET', '/index.jsonopenapi', [ 'headers' => ['Accept' => 'application/vnd.openapi+json'], ]); - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.openapi+json; charset=utf-8'); - $this->assertJson($response->getContent()); + $this->assertResponseStatusCodeSame(404); } public function testOpenApiSchemaWithNormalizationAttributes(): void diff --git a/tests/Functional/Parameters/IriFilterScalarEnumTest.php b/tests/Functional/Parameters/IriFilterScalarEnumTest.php new file mode 100644 index 00000000000..34ddcc14f08 --- /dev/null +++ b/tests/Functional/Parameters/IriFilterScalarEnumTest.php @@ -0,0 +1,97 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\IriFilterScalarEnum\Game as DocumentGame; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterScalarEnum\Game; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; + +final class IriFilterScalarEnumTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Game::class, GamePlayMode::class]; + } + + public function testFilterScalarEnumColumnByIri(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode=/game_play_modes/SINGLE_PLAYER')->toArray(); + + $this->assertCount(2, $res['member']); + foreach ($res['member'] as $game) { + $this->assertSame('/game_play_modes/SINGLE_PLAYER', $game['playMode']); + } + } + + public function testFilterScalarEnumColumnByIriMultiple(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode[]=/game_play_modes/SINGLE_PLAYER&playMode[]=/game_play_modes/CO_OP')->toArray(); + + $this->assertCount(3, $res['member']); + } + + public function testFilterScalarEnumColumnByUnknownIriYieldsNoResult(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode=/game_play_modes/MULTI_PLAYER')->toArray(); + + $this->assertCount(0, $res['member']); + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $this->recreateSchema([$this->isMongoDB() ? DocumentGame::class : Game::class]); + $this->loadFixtures(); + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DocumentGame::class : Game::class; + + foreach ([ + ['Solo Quest', GamePlayMode::SINGLE_PLAYER], + ['Lone Wolf', GamePlayMode::SINGLE_PLAYER], + ['Team Up', GamePlayMode::CO_OP], + ] as [$name, $playMode]) { + $game = new $class(); + $game->name = $name; + $game->playMode = $playMode; + $manager->persist($game); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/NameConverterModernFilterTest.php b/tests/Functional/Parameters/NameConverterModernFilterTest.php new file mode 100644 index 00000000000..a2f3c939141 --- /dev/null +++ b/tests/Functional/Parameters/NameConverterModernFilterTest.php @@ -0,0 +1,84 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedFilterParameter as ConvertedFilterParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedFilterParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Tests that modern parameter filters (ExactFilter, SortFilter) work with + * QueryParameter on a multi-word property when a nameConverter is configured. + * + * @see https://github.com/api-platform/core/issues/8380 + */ +final class NameConverterModernFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ConvertedFilterParameter::class]; + } + + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? ConvertedFilterParameterDocument::class : ConvertedFilterParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + public function testExactFilterWithNameConverter(): void + { + $response = self::createClient()->request('GET', '/converted_filter_parameters?nameConverted=bar'); + $this->assertResponseIsSuccessful(); + $members = $response->toArray()['hydra:member']; + $this->assertCount(1, $members); + $this->assertSame('bar', $members[0]['name_converted']); + } + + public function testSortFilterWithNameConverter(): void + { + $response = self::createClient()->request('GET', '/converted_filter_parameters?orderNameConverted=desc'); + $this->assertResponseIsSuccessful(); + $members = $response->toArray()['hydra:member']; + $names = array_map(static fn (array $m): string => $m['name_converted'], $members); + $this->assertSame(['foo', 'baz', 'bar'], $names); + } + + /** + * @param class-string $entityClass + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + foreach (['bar', 'baz', 'foo'] as $name) { + $entity = new $entityClass(); + $entity->nameConverted = $name; + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/ValidationTest.php b/tests/Functional/ValidationTest.php index c82238d75d0..7c9f4c214a0 100644 --- a/tests/Functional/ValidationTest.php +++ b/tests/Functional/ValidationTest.php @@ -119,8 +119,10 @@ public function testPostWithDenormalizationErrorsCollected(): void if (!method_exists(PropertyInfoExtractor::class, 'getType')) { $this->assertSame('This value should be of type uuid.', $violationUuid['message']); } else { - $this->assertSame('This value should be of type UuidInterface|null.', $violationUuid['message']); + $this->assertSame('This value should be of type uuid|null.', $violationUuid['message']); } + $this->assertArrayHasKey('hint', $violationUuid); + $this->assertSame('Invalid UUID string: y', $violationUuid['hint']); $violationRelatedDummy = $findViolation('relatedDummy'); $this->assertNotNull($violationRelatedDummy);