diff --git a/features/main/custom_identifier.feature b/features/main/custom_identifier.feature index ba37934d76b..6c078bcc684 100644 --- a/features/main/custom_identifier.feature +++ b/features/main/custom_identifier.feature @@ -101,3 +101,21 @@ Feature: Using custom identifier on resource When I send a "DELETE" request to "/custom_identifier_dummies/1" Then the response status code should be 204 And the response should be empty + + @createSchema + Scenario: Get a resource + Given there is a custom multiple identifier dummy + When I send a "GET" request to "/custom_multiple_identifier_dummies/1/2" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be equal to: + """ + { + "@context": "/contexts/CustomMultipleIdentifierDummy", + "@id": "/custom_multiple_identifier_dummies/1/2", + "@type": "CustomMultipleIdentifierDummy", + "firstId": 1, + "secondId": 2, + "name": "Orwell" + } + """ diff --git a/features/main/operation.feature b/features/main/operation.feature index 1c26db087c7..24dda9b8746 100644 --- a/features/main/operation.feature +++ b/features/main/operation.feature @@ -64,3 +64,22 @@ Feature: Operation support Scenario: Get a 404 response for the disabled item operation When I send a "GET" request to "/disable_item_operations/1" Then the response status code should be 404 + + @createSchema + Scenario: Get a book by its ISBN + Given there is a book + When I send a "GET" request to "books/by_isbn/9780451524935" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Book", + "@id": "/books/1", + "@type": "Book", + "name": "1984", + "isbn": "9780451524935", + "id": 1 + } + """ diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 1208080d692..75635f88c19 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -28,6 +28,7 @@ * @Attribute("attributes", type="array"), * @Attribute("cacheHeaders", type="array"), * @Attribute("collectionOperations", type="array"), + * @Attribute("compositeIdentifier", type="bool"), * @Attribute("denormalizationContext", type="array"), * @Attribute("deprecationReason", type="string"), * @Attribute("description", type="string"), @@ -217,7 +218,8 @@ public function __construct( ?string $sunset = null, ?array $swaggerContext = null, ?array $validationGroups = null, - ?int $urlGenerationStrategy = null + ?int $urlGenerationStrategy = null, + ?bool $compositeIdentifier = null ) { if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); diff --git a/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php index e263466320f..2d5b2d87fd5 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php +++ b/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php @@ -121,8 +121,16 @@ private function buildAggregation(array $identifiers, array $context, array $exe $topAggregationBuilder = $topAggregationBuilder ?? $previousAggregationBuilder; - [$identifier, $identifierResourceClass] = $context['identifiers'][$remainingIdentifiers - 1]; - $previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property']; + if (\is_string(key($context['identifiers']))) { + $contextIdentifiers = array_keys($context['identifiers']); + $identifier = $contextIdentifiers[$remainingIdentifiers - 1]; + $identifierResourceClass = $context['identifiers'][$identifier][0]; + $previousAssociationProperty = $contextIdentifiers[$remainingIdentifiers] ?? $context['property']; + } else { + @trigger_error('Identifiers should match the convention introduced in ADR 0001-resource-identifiers, this behavior will be removed in 3.0.', E_USER_DEPRECATED); + [$identifier, $identifierResourceClass] = $context['identifiers'][$remainingIdentifiers - 1]; + $previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property']; + } $manager = $this->managerRegistry->getManagerForClass($identifierResourceClass); if (!$manager instanceof DocumentManager) { diff --git a/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php b/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php index 9438a708fde..76b9cab0a5d 100644 --- a/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php +++ b/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php @@ -130,8 +130,16 @@ private function buildQuery(array $identifiers, array $context, QueryNameGenerat $topQueryBuilder = $topQueryBuilder ?? $previousQueryBuilder; - [$identifier, $identifierResourceClass] = $context['identifiers'][$remainingIdentifiers - 1]; - $previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property']; + if (\is_string(key($context['identifiers']))) { + $contextIdentifiers = array_keys($context['identifiers']); + $identifier = $contextIdentifiers[$remainingIdentifiers - 1]; + $identifierResourceClass = $context['identifiers'][$identifier][0]; + $previousAssociationProperty = $contextIdentifiers[$remainingIdentifiers] ?? $context['property']; + } else { + @trigger_error('Identifiers should match the convention introduced in ADR 0001-resource-identifiers, this behavior will be removed in 3.0.', E_USER_DEPRECATED); + [$identifier, $identifierResourceClass] = $context['identifiers'][$remainingIdentifiers - 1]; + $previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property']; + } $manager = $this->managerRegistry->getManagerForClass($identifierResourceClass); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index f5b94eb917d..3dac225ca80 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -43,6 +43,7 @@ %api_platform.enable_docs% %api_platform.graphql.graphiql.enabled% %api_platform.graphql.graphql_playground.enabled% + @@ -288,6 +289,7 @@ + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml index 065dc4d33ad..f512cfd7f0b 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -42,6 +42,7 @@ + %api_platform.formats% diff --git a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml index df28ac3f8fc..f41fb55c44e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml @@ -33,6 +33,7 @@ %api_platform.collection.pagination.enabled_parameter_name% %api_platform.swagger.versions% + diff --git a/src/Bridge/Symfony/Routing/ApiLoader.php b/src/Bridge/Symfony/Routing/ApiLoader.php index 3bdad5ac363..c2e6228de00 100644 --- a/src/Bridge/Symfony/Routing/ApiLoader.php +++ b/src/Bridge/Symfony/Routing/ApiLoader.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Routing; +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Exception\InvalidResourceException; use ApiPlatform\Core\Exception\RuntimeException; @@ -56,8 +57,9 @@ final class ApiLoader extends Loader private $graphQlPlaygroundEnabled; private $entrypointEnabled; private $docsEnabled; + private $identifiersExtractor; - public function __construct(KernelInterface $kernel, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $container, array $formats, array $resourceClassDirectories = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null, bool $graphqlEnabled = false, bool $entrypointEnabled = true, bool $docsEnabled = true, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false) + public function __construct(KernelInterface $kernel, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $container, array $formats, array $resourceClassDirectories = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null, bool $graphqlEnabled = false, bool $entrypointEnabled = true, bool $docsEnabled = true, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, IdentifiersExtractorInterface $identifiersExtractor = null) { /** @var string[]|string $paths */ $paths = $kernel->locateResource('@ApiPlatformBundle/Resources/config/routing'); @@ -74,6 +76,7 @@ public function __construct(KernelInterface $kernel, ResourceNameCollectionFacto $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; $this->entrypointEnabled = $entrypointEnabled; $this->docsEnabled = $docsEnabled; + $this->identifiersExtractor = $identifiersExtractor; } /** @@ -128,6 +131,8 @@ public function load($data, $type = null): RouteCollection '_format' => null, '_stateless' => $operation['stateless'] ?? $resourceMetadata->getAttribute('stateless'), '_api_resource_class' => $operation['resource_class'], + '_api_identifiers' => $operation['identifiers'], + '_api_has_composite_identifier' => false, '_api_subresource_operation_name' => $operation['route_name'], '_api_subresource_context' => [ 'property' => $operation['property'], @@ -222,6 +227,8 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas } } + $operation['identifiers'] = (array) ($operation['identifiers'] ?? $resourceMetadata->getAttribute('identifiers', $this->identifiersExtractor ? $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass) : ['id'])); + $operation['has_composite_identifier'] = \count($operation['identifiers']) > 1 ? $resourceMetadata->getAttribute('composite_identifier', true) : false; $path = trim(trim($resourceMetadata->getAttribute('route_prefix', '')), '/'); $path .= $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName); @@ -232,6 +239,8 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas '_format' => null, '_stateless' => $operation['stateless'], '_api_resource_class' => $resourceClass, + '_api_identifiers' => $operation['identifiers'], + '_api_has_composite_identifier' => $operation['has_composite_identifier'], sprintf('_api_%s_operation_name', $operationType) => $operationName, ] + ($operation['defaults'] ?? []), $operation['requirements'] ?? [], diff --git a/src/Bridge/Symfony/Routing/IriConverter.php b/src/Bridge/Symfony/Routing/IriConverter.php index e0c8e5c7211..61de1d562bf 100644 --- a/src/Bridge/Symfony/Routing/IriConverter.php +++ b/src/Bridge/Symfony/Routing/IriConverter.php @@ -26,6 +26,7 @@ use ApiPlatform\Core\Exception\InvalidIdentifierException; use ApiPlatform\Core\Exception\ItemNotFoundException; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Identifier\CompositeIdentifierParser; use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -60,11 +61,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->subresourceDataProvider = $subresourceDataProvider; $this->identifierConverter = $identifierConverter; $this->resourceClassResolver = $resourceClassResolver; - - if (null === $identifiersExtractor) { - @trigger_error(sprintf('Not injecting "%s" is deprecated since API Platform 2.1 and will not be possible anymore in API Platform 3', IdentifiersExtractorInterface::class), E_USER_DEPRECATED); - $this->identifiersExtractor = new IdentifiersExtractor($propertyNameCollectionFactory, $propertyMetadataFactory, $propertyAccessor ?? PropertyAccess::createPropertyAccessor()); - } + $this->identifiersExtractor = $identifiersExtractor ?: new IdentifiersExtractor($propertyNameCollectionFactory, $propertyMetadataFactory, $propertyAccessor ?? PropertyAccess::createPropertyAccessor()); $this->resourceMetadataFactory = $resourceMetadataFactory; } @@ -148,11 +145,14 @@ public function getIriFromResourceClass(string $resourceClass, int $referenceTyp public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = null): string { $routeName = $this->routeNameResolver->getRouteName($resourceClass, OperationType::ITEM); + $metadata = $this->resourceMetadataFactory->create($resourceClass); - try { - $identifiers = $this->generateIdentifiersUrl($identifiers, $resourceClass); + if (\count($identifiers) > 1 && true === $metadata->getAttribute('composite_identifier', true)) { + $identifiers = ['id' => CompositeIdentifierParser::stringify($identifiers)]; + } - return $this->router->generate($routeName, ['id' => implode(';', $identifiers)], $this->getReferenceType($resourceClass, $referenceType)); + try { + return $this->router->generate($routeName, $identifiers, $this->getReferenceType($resourceClass, $referenceType)); } catch (RoutingExceptionInterface $e) { throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e); } @@ -170,30 +170,6 @@ public function getSubresourceIriFromResourceClass(string $resourceClass, array } } - /** - * Generate the identifier url. - * - * @throws InvalidArgumentException - * - * @return string[] - */ - private function generateIdentifiersUrl(array $identifiers, string $resourceClass): array - { - if (0 === \count($identifiers)) { - throw new InvalidArgumentException(sprintf('No identifiers defined for resource of type "%s"', $resourceClass)); - } - - if (1 === \count($identifiers)) { - return [(string) reset($identifiers)]; - } - - foreach ($identifiers as $name => $value) { - $identifiers[$name] = sprintf('%s=%s', $name, $value); - } - - return array_values($identifiers); - } - private function getReferenceType(string $resourceClass, ?int $referenceType): ?int { if (null === $referenceType && null !== $this->resourceMetadataFactory) { diff --git a/src/Bridge/Symfony/Routing/RouteNameResolver.php b/src/Bridge/Symfony/Routing/RouteNameResolver.php index e17f5e7eb52..b3ba719ffd9 100644 --- a/src/Bridge/Symfony/Routing/RouteNameResolver.php +++ b/src/Bridge/Symfony/Routing/RouteNameResolver.php @@ -67,8 +67,8 @@ private function isSameSubresource(array $context, array $currentContext): bool $subresources = array_keys($context['subresource_resources']); $currentSubresources = []; - foreach ($currentContext['identifiers'] as $identifierContext) { - $currentSubresources[] = $identifierContext[1]; + foreach ($currentContext['identifiers'] as [$class]) { + $currentSubresources[] = $class; } return $currentSubresources === $subresources; diff --git a/src/DataProvider/OperationDataProviderTrait.php b/src/DataProvider/OperationDataProviderTrait.php index f08521ce379..a2754f92610 100644 --- a/src/DataProvider/OperationDataProviderTrait.php +++ b/src/DataProvider/OperationDataProviderTrait.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Exception\InvalidIdentifierException; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Identifier\CompositeIdentifierParser; use ApiPlatform\Core\Identifier\IdentifierConverterInterface; /** @@ -75,7 +76,15 @@ private function getSubresourceData($identifiers, array $attributes, array $cont throw new RuntimeException('Subresources not supported'); } - return $this->subresourceDataProvider->getSubresource($attributes['resource_class'], $identifiers, $attributes['subresource_context'] + $context, $attributes['subresource_operation_name']); + // TODO: SubresourceDataProvider wants: ['id' => ['id' => 1], 'relatedDummies' => ['id' => 2]], identifiers is ['id' => 1, 'relatedDummies' => 2] + $subresourceIdentifiers = []; + foreach ($attributes['identifiers'] as $parameterName => [$class, $property]) { + if (false !== ($attributes['identifiers'][$parameterName][2] ?? null)) { + $subresourceIdentifiers[$parameterName] = [$property => $identifiers[$parameterName]]; + } + } + + return $this->subresourceDataProvider->getSubresource($attributes['resource_class'], $subresourceIdentifiers, $attributes['subresource_context'] + $context, $attributes['subresource_operation_name']); } /** @@ -85,38 +94,32 @@ private function getSubresourceData($identifiers, array $attributes, array $cont */ private function extractIdentifiers(array $parameters, array $attributes) { - if (isset($attributes['item_operation_name'])) { - if (!isset($parameters['id'])) { - throw new InvalidIdentifierException('Parameter "id" not found'); - } - - $id = $parameters['id']; - - if (null !== $this->identifierConverter) { - return $this->identifierConverter->convert((string) $id, $attributes['resource_class']); - } + $identifiersKeys = $attributes['identifiers'] ?? ['id' => [$attributes['resource_class'], 'id']]; + $identifiers = []; - return $id; - } + $identifiersNumber = \count($identifiersKeys); + foreach ($identifiersKeys as $parameterName => $identifiedBy) { + if (!isset($parameters[$parameterName])) { + if ($attributes['has_composite_identifier']) { + $identifiers = CompositeIdentifierParser::parse($parameters['id']); + if (($currentIdentifiersNumber = \count($identifiers)) !== $identifiersNumber) { + throw new InvalidIdentifierException(sprintf('Expected %d identifiers, got %d', $identifiersNumber, $currentIdentifiersNumber)); + } - if (!isset($attributes['subresource_context'])) { - throw new RuntimeException('Either "item_operation_name" or "collection_operation_name" must be defined, unless the "_api_receive" request attribute is set to false.'); - } + return $identifiers; + } - $identifiers = []; + // TODO: Subresources tuple may have a third item representing if it is a "collection", this behavior will be removed in 3.0 + if (false === ($identifiedBy[2] ?? null)) { + continue; + } - foreach ($attributes['subresource_context']['identifiers'] as $key => [$id, $resourceClass, $hasIdentifier]) { - if (false === $hasIdentifier) { - continue; + throw new InvalidIdentifierException(sprintf('Parameter "%s" not found', $parameterName)); } - $identifiers[$id] = $parameters[$id]; - - if (null !== $this->identifierConverter) { - $identifiers[$id] = $this->identifierConverter->convert((string) $identifiers[$id], $resourceClass); - } + $identifiers[$parameterName] = $parameters[$parameterName]; } - return $identifiers; + return $this->identifierConverter->convert($identifiers, $attributes['resource_class']); } } diff --git a/src/GraphQl/Resolver/Stage/ReadStage.php b/src/GraphQl/Resolver/Stage/ReadStage.php index 213976035c5..d5f87effef9 100644 --- a/src/GraphQl/Resolver/Stage/ReadStage.php +++ b/src/GraphQl/Resolver/Stage/ReadStage.php @@ -172,7 +172,7 @@ private function getSubresource(string $rootResolvedClass, array $rootResolvedFi $resolvedIdentifiers = []; $rootIdentifiers = array_keys($rootResolvedFields); foreach ($rootIdentifiers as $rootIdentifier) { - $resolvedIdentifiers[] = [$rootIdentifier, $rootResolvedClass]; + $resolvedIdentifiers[$rootIdentifier] = [$rootResolvedClass, $rootIdentifier]; } return $this->subresourceDataProvider->getSubresource($subresourceClass, $rootResolvedFields, $normalizationContext + [ diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index be501d49e1a..a9ef38556f9 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -236,7 +236,7 @@ private function getHydraOperations(string $resourceClass, ResourceMetadata $res if (null !== $this->subresourceOperationFactory) { foreach ($this->subresourceOperationFactory->create($resourceClass) as $operationId => $operation) { $subresourceMetadata = $this->resourceMetadataFactory->create($operation['resource_class']); - $propertyMetadata = $this->propertyMetadataFactory->create(end($operation['identifiers'])[1], $operation['property']); + $propertyMetadata = $this->propertyMetadataFactory->create(end($operation['identifiers'])[0], $operation['property']); $hydraOperations[] = $this->getHydraOperation($resourceClass, $subresourceMetadata, $operation['route_name'], $operation, "#{$subresourceMetadata->getShortName()}", OperationType::SUBRESOURCE, $propertyMetadata->getSubresource()); } } diff --git a/src/Identifier/CompositeIdentifierParser.php b/src/Identifier/CompositeIdentifierParser.php index 3aa72c79bde..5d6fce92c74 100644 --- a/src/Identifier/CompositeIdentifierParser.php +++ b/src/Identifier/CompositeIdentifierParser.php @@ -20,6 +20,8 @@ */ final class CompositeIdentifierParser { + public const COMPOSITE_IDENTIFIER_REGEXP = '/(\w+)=(?<=\w=)(.*?)(?=;\w+=)|(\w+)=([^;]*);?$/'; + private function __construct() { } @@ -33,7 +35,7 @@ public static function parse(string $identifier): array { $matches = []; $identifiers = []; - $num = preg_match_all('/(\w+)=(?<=\w=)(.*?)(?=;\w+=)|(\w+)=([^;]*);?$/', $identifier, $matches, PREG_SET_ORDER); + $num = preg_match_all(self::COMPOSITE_IDENTIFIER_REGEXP, $identifier, $matches, PREG_SET_ORDER); foreach ($matches as $i => $match) { if ($i === $num - 1) { @@ -45,4 +47,17 @@ public static function parse(string $identifier): array return $identifiers; } + + /** + * Renders composite identifiers to string using: key=value;key2=value2. + */ + public static function stringify(array $identifiers): string + { + $composite = []; + foreach ($identifiers as $name => $value) { + $composite[] = sprintf('%s=%s', $name, $value); + } + + return implode(';', $composite); + } } diff --git a/src/Identifier/ContextAwareIdentifierConverterInterface.php b/src/Identifier/ContextAwareIdentifierConverterInterface.php index 83675089eb0..76f33ad81f0 100644 --- a/src/Identifier/ContextAwareIdentifierConverterInterface.php +++ b/src/Identifier/ContextAwareIdentifierConverterInterface.php @@ -23,5 +23,5 @@ interface ContextAwareIdentifierConverterInterface extends IdentifierConverterIn /** * {@inheritdoc} */ - public function convert(string $data, string $class, array $context = []): array; + public function convert($data, string $class, array $context = []): array; } diff --git a/src/Identifier/IdentifierConverter.php b/src/Identifier/IdentifierConverter.php index c60651b1064..9d8f53620e0 100644 --- a/src/Identifier/IdentifierConverter.php +++ b/src/Identifier/IdentifierConverter.php @@ -33,6 +33,8 @@ final class IdentifierConverter implements ContextAwareIdentifierConverterInterf private $resourceMetadataFactory; /** + * TODO: rename identifierDenormalizers to identifierTransformers in 3.0 and change their interfaces to a IdentifierTransformerInterface. + * * @param iterable $identifierDenormalizers */ public function __construct(IdentifiersExtractorInterface $identifiersExtractor, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $identifierDenormalizers, ResourceMetadataFactoryInterface $resourceMetadataFactory = null) @@ -46,43 +48,29 @@ public function __construct(IdentifiersExtractorInterface $identifiersExtractor, /** * {@inheritdoc} */ - public function convert(string $data, string $class, array $context = []): array + public function convert($data, string $class, array $context = []): array { - if (null !== $this->resourceMetadataFactory) { - $resourceMetadata = $this->resourceMetadataFactory->create($class); - $class = $resourceMetadata->getOperationAttribute($context, 'output', ['class' => $class], true)['class']; + if (!\is_array($data)) { + @trigger_error(sprintf('Not using an array as the first argument of "%s->convert" is deprecated since API Platform 2.6 and will not be possible anymore in API Platform 3', self::class), E_USER_DEPRECATED); + $data = ['id' => $data]; } - $keys = $this->identifiersExtractor->getIdentifiersFromResourceClass($class); - - if (($numIdentifiers = \count($keys)) > 1) { - $identifiers = CompositeIdentifierParser::parse($data); - } elseif (0 === $numIdentifiers) { - throw new InvalidIdentifierException(sprintf('Resource "%s" has no identifiers.', $class)); - } else { - $identifiers = [$keys[0] => $data]; - } - - // Normalize every identifier (DateTime, UUID etc.) - foreach ($keys as $key) { - if (!isset($identifiers[$key])) { - throw new InvalidIdentifierException(sprintf('Invalid identifier "%1$s", "%1$s" was not found.', $key)); - } - - if (null === $type = $this->getIdentifierType($class, $key)) { + $identifiers = $data; + foreach ($data as $identifier => $value) { + if (null === $type = $this->getIdentifierType($class, $identifier)) { continue; } - foreach ($this->identifierDenormalizers as $identifierDenormalizer) { - if (!$identifierDenormalizer->supportsDenormalization($identifiers[$key], $type)) { + /* @var DenormalizerInterface[] */ + foreach ($this->identifierDenormalizers as $identifierTransformer) { + if (!$identifierTransformer->supportsDenormalization($value, $type)) { continue; } try { - $identifiers[$key] = $identifierDenormalizer->denormalize($identifiers[$key], $type); - break; + $identifiers[$identifier] = $identifierTransformer->denormalize($value, $type); } catch (InvalidIdentifierException $e) { - throw new InvalidIdentifierException(sprintf('Identifier "%s" could not be denormalized.', $key), $e->getCode(), $e); + throw new InvalidIdentifierException(sprintf('Identifier "%s" could not be denormalized.', $identifier), $e->getCode(), $e); } } } diff --git a/src/Identifier/IdentifierConverterInterface.php b/src/Identifier/IdentifierConverterInterface.php index 93962a2d47d..e642df9562c 100644 --- a/src/Identifier/IdentifierConverterInterface.php +++ b/src/Identifier/IdentifierConverterInterface.php @@ -28,12 +28,14 @@ interface IdentifierConverterInterface public const HAS_IDENTIFIER_CONVERTER = 'has_identifier_converter'; /** - * @param string $data Identifier to convert to php values + * Takes an array of strings representing identifiers and transform their values to the expected type. + * + * @param mixed $data Identifier to convert to php values * @param string $class The class to which the identifiers belong * * @throws InvalidIdentifierException * * @return array Indexed by identifiers properties with their values denormalized */ - public function convert(string $data, string $class): array; + public function convert($data, string $class): array; } diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 208299f814c..bc67eb98e6c 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\OpenApi\Factory; use ApiPlatform\Core\Api\FilterLocatorTrait; +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\DataProvider\PaginationOptions; use ApiPlatform\Core\JsonSchema\Schema; @@ -54,8 +55,9 @@ final class OpenApiFactory implements OpenApiFactoryInterface private $jsonSchemaTypeFactory; private $openApiOptions; private $paginationOptions; + private $identifiersExtractor; - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, SchemaFactoryInterface $jsonSchemaFactory, TypeFactoryInterface $jsonSchemaTypeFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $filterLocator, SubresourceOperationFactoryInterface $subresourceOperationFactory, array $formats = [], Options $openApiOptions, PaginationOptions $paginationOptions) + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, SchemaFactoryInterface $jsonSchemaFactory, TypeFactoryInterface $jsonSchemaTypeFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $filterLocator, SubresourceOperationFactoryInterface $subresourceOperationFactory, IdentifiersExtractorInterface $identifiersExtractor = null, array $formats = [], Options $openApiOptions = null, PaginationOptions $paginationOptions = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->jsonSchemaFactory = $jsonSchemaFactory; @@ -66,9 +68,10 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; $this->operationPathResolver = $operationPathResolver; - $this->openApiOptions = $openApiOptions; - $this->paginationOptions = $paginationOptions; $this->subresourceOperationFactory = $subresourceOperationFactory; + $this->identifiersExtractor = $identifiersExtractor; + $this->openApiOptions = $openApiOptions ?: new Options('API Platform'); + $this->paginationOptions = $paginationOptions ?: new PaginationOptions(); } /** @@ -121,6 +124,11 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $rootResourceClass = $resourceClass; foreach ($operations as $operationName => $operation) { + $identifiers = (array) ($operation['identifiers'] ?? $resourceMetadata->getAttribute('identifiers', null === $this->identifiersExtractor ? ['id'] : $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass))); + if (\count($identifiers) > 1 ? $resourceMetadata->getAttribute('composite_identifier', true) : false) { + $identifiers = ['id']; + } + $resourceClass = $operation['resource_class'] ?? $rootResourceClass; $path = $this->getPath($resourceShortName, $operationName, $operation, $operationType); $method = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'method', 'GET'); @@ -142,20 +150,15 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour // Set up parameters if (OperationType::ITEM === $operationType) { - $parameters[] = new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string']); + foreach ($identifiers as $parameterName => $identifier) { + $parameters[] = new Model\Parameter(\is_string($parameterName) ? $parameterName : $identifier, 'path', 'Resource identifier', true, false, false, ['type' => 'string']); + } $links[$operationId] = $this->getLink($resourceClass, $operationId, $path); } elseif (OperationType::COLLECTION === $operationType && 'GET' === $method) { $parameters = array_merge($parameters, $this->getPaginationParameters($resourceMetadata, $operationName), $this->getFiltersParameters($resourceMetadata, $operationName, $resourceClass)); } elseif (OperationType::SUBRESOURCE === $operationType) { - // FIXME: In SubresourceOperationFactory identifiers may happen twice - $added = []; - foreach ($operation['identifiers'] as $identifier) { - if (\in_array($identifier[0], $added, true)) { - continue; - } - $added[] = $identifier[0]; - $parameterShortname = $this->resourceMetadataFactory->create($identifier[1])->getShortName(); - $parameters[] = new Model\Parameter($identifier[0], 'path', $parameterShortname.' identifier', true, false, false, ['type' => 'string']); + foreach ($operation['identifiers'] as $parameterName => [$class, $property]) { + $parameters[] = new Model\Parameter($parameterName, 'path', $this->resourceMetadataFactory->create($class)->getShortName().' identifier', true, false, false, ['type' => 'string']); } if ($operation['collection']) { diff --git a/src/Operation/Factory/SubresourceOperationFactory.php b/src/Operation/Factory/SubresourceOperationFactory.php index b6dbd4298e5..0986b3c75f6 100644 --- a/src/Operation/Factory/SubresourceOperationFactory.php +++ b/src/Operation/Factory/SubresourceOperationFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Operation\Factory; +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameGenerator; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -32,13 +33,15 @@ final class SubresourceOperationFactory implements SubresourceOperationFactoryIn private $propertyNameCollectionFactory; private $propertyMetadataFactory; private $pathSegmentNameGenerator; + private $identifiersExtractor; - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, PathSegmentNameGeneratorInterface $pathSegmentNameGenerator) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, PathSegmentNameGeneratorInterface $pathSegmentNameGenerator, IdentifiersExtractorInterface $identifiersExtractor = null) { $this->resourceMetadataFactory = $resourceMetadataFactory; $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; $this->pathSegmentNameGenerator = $pathSegmentNameGenerator; + $this->identifiersExtractor = $identifiersExtractor; } /** @@ -75,6 +78,7 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre $subresourceClass = $subresource->getResourceClass(); $subresourceMetadata = $this->resourceMetadataFactory->create($subresourceClass); + $subresourceMetadata = $subresourceMetadata->withAttributes(($subresourceMetadata->getAttributes() ?: []) + ['identifiers' => !$this->identifiersExtractor ? [$property] : $this->identifiersExtractor->getIdentifiersFromResourceClass($subresourceClass)]); $isLastItem = ($parentOperation['resource_class'] ?? null) === $resourceClass && $propertyMetadata->isIdentifier(); // A subresource that is also an identifier can't be a start point @@ -93,6 +97,7 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre } $rootResourceMetadata = $this->resourceMetadataFactory->create($rootResourceClass); + $rootResourceMetadata = $rootResourceMetadata->withAttributes(($rootResourceMetadata->getAttributes() ?: []) + ['identifiers' => !$this->identifiersExtractor ? ['id'] : $this->identifiersExtractor->getIdentifiersFromResourceClass($rootResourceClass)]); $operationName = 'get'; $operation = [ 'property' => $property, @@ -102,8 +107,10 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre ]; if (null === $parentOperation) { + $identifiers = (array) $rootResourceMetadata->getAttribute('identifiers'); $rootShortname = $rootResourceMetadata->getShortName(); - $operation['identifiers'] = [['id', $rootResourceClass, true]]; + $identifier = \is_string($key = array_key_first($identifiers)) ? $key : $identifiers[0]; + $operation['identifiers'][$identifier] = [$rootResourceClass, $identifiers[$identifier][1] ?? $identifier, true]; $operation['operation_name'] = sprintf( '%s_%s%s', RouteNameGenerator::inflector($operation['property'], $operation['collection'] ?? false), @@ -126,9 +133,10 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre } $operation['path'] = $subresourceOperation['path'] ?? sprintf( - '/%s%s/{id}/%s%s', + '/%s%s/{%s}/%s%s', $prefix, $this->pathSegmentNameGenerator->getSegmentName($rootShortname), + $identifier, $this->pathSegmentNameGenerator->getSegmentName($operation['property'], $operation['collection']), self::FORMAT_SUFFIX ); @@ -138,8 +146,11 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre } } else { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $identifiers = (array) $resourceMetadata->getAttribute('identifiers', null === $this->identifiersExtractor ? ['id'] : $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass)); + $identifier = \is_string($key = array_key_first($identifiers)) ? $key : $identifiers[0]; $operation['identifiers'] = $parentOperation['identifiers']; - $operation['identifiers'][] = [$parentOperation['property'], $resourceClass, $isLastItem ? true : $parentOperation['collection']]; + $operation['identifiers'][$parentOperation['property']] = [$resourceClass, $identifiers[$identifier][1] ?? $identifier, $isLastItem ? true : $parentOperation['collection']]; + $operation['operation_name'] = str_replace( 'get'.self::SUBRESOURCE_SUFFIX, RouteNameGenerator::inflector($isLastItem ? 'item' : $property, $operation['collection']).'_get'.self::SUBRESOURCE_SUFFIX, @@ -159,8 +170,7 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre $operation['path'] = str_replace(self::FORMAT_SUFFIX, '', (string) $parentOperation['path']); if ($parentOperation['collection']) { - [$key] = end($operation['identifiers']); - $operation['path'] .= sprintf('/{%s}', $key); + $operation['path'] .= sprintf('/{%s}', array_key_last($operation['identifiers'])); } if ($isLastItem) { diff --git a/src/PathResolver/OperationPathResolver.php b/src/PathResolver/OperationPathResolver.php index aae217f00de..ad7ceddfc67 100644 --- a/src/PathResolver/OperationPathResolver.php +++ b/src/PathResolver/OperationPathResolver.php @@ -50,7 +50,13 @@ public function resolveOperationPath(string $resourceShortName, array $operation $path = '/'.$this->pathSegmentNameGenerator->getSegmentName($resourceShortName); if (OperationType::ITEM === $operationType) { - $path .= '/{id}'; + if (isset($operation['identifiers']) && (\count($operation['identifiers']) <= 1 || false === ($operation['has_composite_identifier'] ?? true))) { + foreach ($operation['identifiers'] as $parameterName => $identifier) { + $path .= sprintf('/{%s}', \is_string($parameterName) ? $parameterName : $identifier); + } + } else { + $path .= '/{id}'; + } } $path .= '.{_format}'; diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index d280b751a14..04a26f485c1 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -85,12 +85,12 @@ public function createFromRequest(Request $request, bool $normalization, array $ if (isset($attributes['subresource_context'])) { $context['subresource_identifiers'] = []; - foreach ($attributes['subresource_context']['identifiers'] as $key => [$id, $resourceClass]) { + foreach ($attributes['subresource_context']['identifiers'] as $parameterName => [$resourceClass]) { if (!isset($context['subresource_resources'][$resourceClass])) { $context['subresource_resources'][$resourceClass] = []; } - $context['subresource_identifiers'][$id] = $context['subresource_resources'][$resourceClass][$id] = $request->attributes->get($id); + $context['subresource_identifiers'][$parameterName] = $context['subresource_resources'][$resourceClass][$parameterName] = $request->attributes->get($parameterName); } } diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index a6a1085b845..77962bdd765 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\FilterCollection; use ApiPlatform\Core\Api\FilterLocatorTrait; use ApiPlatform\Core\Api\FormatsProviderInterface; +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\Api\OperationAwareFormatsProviderInterface; use ApiPlatform\Core\Api\OperationMethodResolverInterface; use ApiPlatform\Core\Api\OperationType; @@ -99,6 +100,8 @@ final class DocumentationNormalizer implements NormalizerInterface, CacheableSup ApiGatewayNormalizer::API_GATEWAY => false, ]; + private $identifiersExtractor; + /** * @param SchemaFactoryInterface|ResourceClassResolverInterface|null $jsonSchemaFactory * @param ContainerInterface|FilterCollection|null $filterLocator @@ -106,7 +109,7 @@ final class DocumentationNormalizer implements NormalizerInterface, CacheableSup * @param mixed|null $jsonSchemaTypeFactory * @param int[] $swaggerVersions */ - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, $jsonSchemaFactory = null, $jsonSchemaTypeFactory = null, OperationPathResolverInterface $operationPathResolver = null, UrlGeneratorInterface $urlGenerator = null, $filterLocator = null, NameConverterInterface $nameConverter = null, bool $oauthEnabled = false, string $oauthType = '', string $oauthFlow = '', string $oauthTokenUrl = '', string $oauthAuthorizationUrl = '', array $oauthScopes = [], array $apiKeys = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null, bool $paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', $formats = [], bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination', array $defaultContext = [], array $swaggerVersions = [2, 3]) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, $jsonSchemaFactory = null, $jsonSchemaTypeFactory = null, OperationPathResolverInterface $operationPathResolver = null, UrlGeneratorInterface $urlGenerator = null, $filterLocator = null, NameConverterInterface $nameConverter = null, bool $oauthEnabled = false, string $oauthType = '', string $oauthFlow = '', string $oauthTokenUrl = '', string $oauthAuthorizationUrl = '', array $oauthScopes = [], array $apiKeys = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null, bool $paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', $formats = [], bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination', array $defaultContext = [], array $swaggerVersions = [2, 3], IdentifiersExtractorInterface $identifiersExtractor = null) { if ($jsonSchemaTypeFactory instanceof OperationMethodResolverInterface) { @trigger_error(sprintf('Passing an instance of %s to %s() is deprecated since version 2.5 and will be removed in 3.0.', OperationMethodResolverInterface::class, __METHOD__), E_USER_DEPRECATED); @@ -167,6 +170,7 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa $this->defaultContext[self::SPEC_VERSION] = $swaggerVersions[0] ?? 2; $this->defaultContext = array_merge($this->defaultContext, $defaultContext); + $this->identifiersExtractor = $identifiersExtractor; } /** @@ -182,6 +186,9 @@ public function normalize($object, $format = null, array $context = []) foreach ($object->getResourceNameCollection() as $resourceClass) { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + if ($this->identifiersExtractor) { + $resourceMetadata = $resourceMetadata->withAttributes(($resourceMetadata->getAttributes() ?: []) + ['identifiers' => $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass)]); + } $resourceShortName = $resourceMetadata->getShortName(); // Items needs to be parsed first to be able to reference the lines from the collection operation @@ -341,7 +348,7 @@ private function updateGetOperation(bool $v3, \ArrayObject $pathOperation, array $outputResourseShortName = $resourceMetadata->getItemOperations()[$operationName]['output']['name'] ?? $resourceShortName; $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Retrieves a %s resource.', $outputResourseShortName); - $pathOperation = $this->addItemOperationParameters($v3, $pathOperation); + $pathOperation = $this->addItemOperationParameters($v3, $pathOperation, $operationType, $operationName, $resourceMetadata); $successResponse = ['description' => sprintf('%s resource response', $outputResourseShortName)]; [$successResponse] = $this->addSchemas($v3, $successResponse, $definitions, $resourceClass, $operationType, $operationName, $mimeTypes); @@ -454,14 +461,17 @@ private function addSubresourceOperation(bool $v3, array $subresourceOperation, // Avoid duplicates parameters when there is a filter on a subresource identifier $parametersMemory = []; $pathOperation['parameters'] = []; - foreach ($subresourceOperation['identifiers'] as list($identifier, , $hasIdentifier)) { - if (true === $hasIdentifier) { - $parameter = ['name' => $identifier, 'in' => 'path', 'required' => true]; - $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string'; - $pathOperation['parameters'][] = $parameter; - $parametersMemory[] = $identifier; + foreach ($subresourceOperation['identifiers'] as $parameterName => [$class, $identifier, $hasIdentifier]) { + if (false === strpos($subresourceOperation['path'], sprintf('{%s}', $parameterName))) { + continue; } + + $parameter = ['name' => $parameterName, 'in' => 'path', 'required' => true]; + $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string'; + $pathOperation['parameters'][] = $parameter; + $parametersMemory[] = $parameterName; } + if ($parameters = $this->getFiltersParameters($v3, $subresourceOperation['resource_class'], $operationName, $subResourceMetadata)) { foreach ($parameters as $parameter) { if (!\in_array($parameter['name'], $parametersMemory, true)) { @@ -487,7 +497,7 @@ private function updatePostOperation(bool $v3, \ArrayObject $pathOperation, arra $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Creates a %s resource.', $resourceShortName); if (OperationType::ITEM === $operationType) { - $pathOperation = $this->addItemOperationParameters($v3, $pathOperation); + $pathOperation = $this->addItemOperationParameters($v3, $pathOperation, $operationType, $operationName, $resourceMetadata); } $successResponse = ['description' => sprintf('%s resource created', $resourceShortName)]; @@ -515,7 +525,7 @@ private function updatePutOperation(bool $v3, \ArrayObject $pathOperation, array $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Replaces the %s resource.', $resourceShortName); - $pathOperation = $this->addItemOperationParameters($v3, $pathOperation); + $pathOperation = $this->addItemOperationParameters($v3, $pathOperation, $operationType, $operationName, $resourceMetadata); $successResponse = ['description' => sprintf('%s resource updated', $resourceShortName)]; [$successResponse] = $this->addSchemas($v3, $successResponse, $definitions, $resourceClass, $operationType, $operationName, $responseMimeTypes); @@ -577,18 +587,30 @@ private function updateDeleteOperation(bool $v3, \ArrayObject $pathOperation, st '404' => ['description' => 'Resource not found'], ]; - return $this->addItemOperationParameters($v3, $pathOperation); + return $this->addItemOperationParameters($v3, $pathOperation, $operationType, $operationName, $resourceMetadata); } - private function addItemOperationParameters(bool $v3, \ArrayObject $pathOperation): \ArrayObject + private function addItemOperationParameters(bool $v3, \ArrayObject $pathOperation, string $operationType, string $operationName, ResourceMetadata $resourceMetadata): \ArrayObject { - $parameter = [ - 'name' => 'id', - 'in' => 'path', - 'required' => true, - ]; - $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string'; - $pathOperation['parameters'] ?? $pathOperation['parameters'] = [$parameter]; + $identifiers = (array) $resourceMetadata + ->getTypedOperationAttribute(OperationType::ITEM, $operationName, 'identifiers', ['id'], true); + if (\count($identifiers) > 1 ? $resourceMetadata->getAttribute('composite_identifier', true) : false) { + $identifiers = ['id']; + } + + if (!isset($pathOperation['parameters'])) { + $pathOperation['parameters'] = []; + } + + foreach ($identifiers as $parameterName => $identifier) { + $parameter = [ + 'name' => \is_string($parameterName) ? $parameterName : $identifier, + 'in' => 'path', + 'required' => true, + ]; + $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string'; + $pathOperation['parameters'][] = $parameter; + } return $pathOperation; } diff --git a/src/Util/AttributesExtractor.php b/src/Util/AttributesExtractor.php index 56b73217b9d..3871abba58e 100644 --- a/src/Util/AttributesExtractor.php +++ b/src/Util/AttributesExtractor.php @@ -34,11 +34,23 @@ private function __construct() */ public static function extractAttributes(array $attributes): array { - $result = ['resource_class' => $attributes['_api_resource_class'] ?? null]; + $result = ['resource_class' => $attributes['_api_resource_class'] ?? null, 'has_composite_identifier' => $attributes['_api_has_composite_identifier'] ?? false]; if ($subresourceContext = $attributes['_api_subresource_context'] ?? null) { $result['subresource_context'] = $subresourceContext; } + // Normalizing identifiers tuples + $identifiers = []; + foreach (($attributes['_api_identifiers'] ?? ['id']) as $parameterName => $identifiedBy) { + if (\is_string($identifiedBy)) { + $identifiers[$identifiedBy] = [$result['resource_class'], $identifiedBy]; + } else { + $identifiers[$parameterName] = $identifiedBy; + } + } + + $result['identifiers'] = $identifiers; + if (null === $result['resource_class']) { return []; } diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index a3e8c367268..3d5754c4f34 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\AbsoluteUrlRelationDummy as AbsoluteUrlRelationDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Address as AddressDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Answer as AnswerDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Book as BookDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\CompositeItem as CompositeItemDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\CompositeLabel as CompositeLabelDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\CompositePrimitiveItem as CompositePrimitiveItemDocument; @@ -28,6 +29,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ConvertedRelated as ConvertedRelatedDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ConvertedString as ConvertedStringDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Customer as CustomerDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\CustomMultipleIdentifierDummy as CustomMultipleIdentifierDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyAggregateOffer as DummyAggregateOfferDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; @@ -82,6 +84,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlRelationDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Address; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Book; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeItem; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeLabel; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositePrimitiveItem; @@ -93,6 +96,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ConvertedRelated; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ConvertedString; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Customer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CustomMultipleIdentifierDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar; @@ -1673,6 +1677,32 @@ public function thereIsAPatchDummyRelation() $this->manager->flush(); } + /** + * @Given there is a book + */ + public function thereIsABook() + { + $book = $this->buildBook(); + $book->name = '1984'; + $book->isbn = '9780451524935'; + $this->manager->persist($book); + $this->manager->flush(); + } + + /** + * @Given there is a custom multiple identifier dummy + */ + public function thereIsACustomMultipleIdentifierDummy() + { + $dummy = $this->buildCustomMultipleIdentifierDummy(); + $dummy->setName('Orwell'); + $dummy->setFirstId(1); + $dummy->setSecondId(2); + + $this->manager->persist($dummy); + $this->manager->flush(); + } + private function isOrm(): bool { return null !== $this->schemaTool; @@ -2114,4 +2144,20 @@ private function buildPatchDummyRelation() { return $this->isOrm() ? new PatchDummyRelation() : new PatchDummyRelationDocument(); } + + /** + * @return BookDocument | Book + */ + private function buildBook() + { + return $this->isOrm() ? new Book() : new BookDocument(); + } + + /** + * @return CustomMultipleIdentifierDummy | CustomMultipleIdentifierDummyDocument + */ + private function buildCustomMultipleIdentifierDummy() + { + return $this->isOrm() ? new CustomMultipleIdentifierDummy() : new CustomMultipleIdentifierDummyDocument(); + } } diff --git a/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php b/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php index 58290ca51e4..d9e49037bef 100644 --- a/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php +++ b/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php @@ -43,12 +43,14 @@ use Doctrine\Persistence\ObjectRepository; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; /** * @author Kévin Dunglas */ class SubresourceDataProviderTest extends TestCase { + use ExpectDeprecationTrait; use ProphecyTrait; private function assertIdentifierManagerMethodCalls($managerProphecy) @@ -172,7 +174,7 @@ public function testGetSubresource() $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); - $context = ['property' => 'relatedDummies', 'identifiers' => [['id', Dummy::class]], 'collection' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; + $context = ['property' => 'relatedDummies', 'identifiers' => ['id' => [Dummy::class, 'id']], 'collection' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; $this->assertEquals([], $dataProvider->getSubresource(RelatedDummy::class, ['id' => ['id' => 1]], $context)); } @@ -266,7 +268,7 @@ public function testGetSubSubresourceItem() $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); - $context = ['property' => 'thirdLevel', 'identifiers' => [['id', Dummy::class], ['relatedDummies', RelatedDummy::class]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; + $context = ['property' => 'thirdLevel', 'identifiers' => ['id' => [Dummy::class, 'id'], 'relatedDummies' => [RelatedDummy::class, 'id']], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; $this->assertEquals($result, $dataProvider->getSubresource(ThirdLevel::class, ['id' => ['id' => 1], 'relatedDummies' => ['id' => 1]], $context)); } @@ -322,7 +324,7 @@ public function testGetSubresourceOneToOneOwningRelation() $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); - $context = ['property' => 'ownedDummy', 'identifiers' => [['id', Dummy::class]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; + $context = ['property' => 'ownedDummy', 'identifiers' => ['id' => [Dummy::class, 'id']], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; $this->assertEquals([], $dataProvider->getSubresource(RelatedOwningDummy::class, ['id' => ['id' => 1]], $context)); } @@ -382,7 +384,7 @@ public function testQueryResultExtension() $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); - $context = ['property' => 'relatedDummies', 'identifiers' => [['id', Dummy::class]], 'collection' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; + $context = ['property' => 'relatedDummies', 'identifiers' => ['id' => [Dummy::class, 'id']], 'collection' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; $this->assertEquals([], $dataProvider->getSubresource(RelatedDummy::class, ['id' => ['id' => 1]], $context)); } @@ -426,6 +428,7 @@ public function testThrowResourceClassNotSupportedException() */ public function testGetSubSubresourceItemLegacy() { + $this->expectDeprecation('Identifiers should match the convention introduced in ADR 0001-resource-identifiers, this behavior will be removed in 3.0.'); $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); $identifiers = ['id']; $funcProphecy = $this->prophesize(Func::class); @@ -605,7 +608,7 @@ public function testGetSubresourceCollectionItem() $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); - $context = ['property' => 'id', 'identifiers' => [['id', Dummy::class, true], ['relatedDummies', RelatedDummy::class, true]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; + $context = ['property' => 'id', 'identifiers' => ['id' => [Dummy::class, 'id', true], 'relatedDummies' => [RelatedDummy::class, 'id', true]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; $this->assertEquals($result, $dataProvider->getSubresource(RelatedDummy::class, ['id' => ['id' => 1], 'relatedDummies' => ['id' => 2]], $context)); } diff --git a/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php b/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php index 4875acf1b0c..980add90322 100644 --- a/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php +++ b/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php @@ -142,6 +142,8 @@ public function testWithResource() $this->assertSame([ 'resource_class' => DummyEntity::class, + 'has_composite_identifier' => false, + 'identifiers' => ['id' => [DummyEntity::class, 'id']], 'item_operation_name' => 'get', 'receive' => true, 'respond' => true, @@ -282,7 +284,7 @@ public function getItem(string $resourceClass, $id, string $operationName = null } }, ])); - $itemDataProvider->getItem('', '', null, ['item_context']); + $itemDataProvider->getItem('', [], null, ['item_context']); return $itemDataProvider; } diff --git a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php index 629b4bd2995..aae3a3b66eb 100644 --- a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php +++ b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Routing; +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\Bridge\Symfony\Routing\ApiLoader; use ApiPlatform\Core\Exception\InvalidResourceException; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -50,6 +51,7 @@ public function testApiLoader() { $resourceMetadata = new ResourceMetadata(); $resourceMetadata = $resourceMetadata->withShortName('dummy'); + $resourceMetadata = $resourceMetadata->withAttributes(['identifiers' => 'id']); //default operation based on OperationResourceMetadataFactory $resourceMetadata = $resourceMetadata->withItemOperations([ 'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden'], 'stateless' => null], @@ -105,7 +107,7 @@ public function testApiLoader() ); $this->assertEquals( - $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class, true]], 'collection' => true, 'operationId' => 'api_dummies_subresources_get_subresource'], [], ['_stateless' => true]), + $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => ['id' => [DummyEntity::class, 'id', true]], 'collection' => true, 'operationId' => 'api_dummies_subresources_get_subresource'], [], ['_stateless' => true]), $routeCollection->get('api_dummies_subresources_get_subresource') ); } @@ -119,7 +121,7 @@ public function testApiLoaderWithPrefix() 'put' => ['method' => 'PUT', 'stateless' => null], 'delete' => ['method' => 'DELETE', 'stateless' => null], ]); - $resourceMetadata = $resourceMetadata->withAttributes(['route_prefix' => '/foobar-prefix']); + $resourceMetadata = $resourceMetadata->withAttributes(['route_prefix' => '/foobar-prefix', 'identifiers' => 'id']); $routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null); @@ -200,32 +202,32 @@ public function testRecursiveSubresource() $routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata, true)->load(null); $this->assertEquals( - $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class, true]], 'collection' => true, 'operationId' => 'api_dummies_subresources_get_subresource']), + $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => ['id' => [DummyEntity::class, 'id', true]], 'collection' => true, 'operationId' => 'api_dummies_subresources_get_subresource']), $routeCollection->get('api_dummies_subresources_get_subresource') ); $this->assertEquals( - $this->getSubresourceRoute('/related_dummies/{id}/recursivesubresource/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_related_dummies_recursivesubresource_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', RelatedDummyEntity::class, true], ['recursivesubresource', DummyEntity::class, false]], 'collection' => true, 'operationId' => 'api_related_dummies_recursivesubresource_subresources_get_subresource']), + $this->getSubresourceRoute('/related_dummies/{id}/recursivesubresource/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_related_dummies_recursivesubresource_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => ['id' => [RelatedDummyEntity::class, 'id', true], 'recursivesubresource' => [DummyEntity::class, 'id', false]], 'collection' => true, 'operationId' => 'api_related_dummies_recursivesubresource_subresources_get_subresource']), $routeCollection->get('api_related_dummies_recursivesubresource_subresources_get_subresource') ); $this->assertEquals( - $this->getSubresourceRoute('/related_dummies/{id}/recursivesubresource.{_format}', 'dummy_controller', DummyEntity::class, 'api_related_dummies_recursivesubresource_get_subresource', ['property' => 'recursivesubresource', 'identifiers' => [['id', RelatedDummyEntity::class, true]], 'collection' => false, 'operationId' => 'api_related_dummies_recursivesubresource_get_subresource']), + $this->getSubresourceRoute('/related_dummies/{id}/recursivesubresource.{_format}', 'dummy_controller', DummyEntity::class, 'api_related_dummies_recursivesubresource_get_subresource', ['property' => 'recursivesubresource', 'identifiers' => ['id' => [RelatedDummyEntity::class, 'id', true]], 'collection' => false, 'operationId' => 'api_related_dummies_recursivesubresource_get_subresource']), $routeCollection->get('api_related_dummies_recursivesubresource_get_subresource') ); $this->assertEquals( - $this->getSubresourceRoute('/dummies/{id}/subresources/{subresource}/recursivesubresource.{_format}', 'api_platform.action.get_subresource', DummyEntity::class, 'api_dummies_subresources_recursivesubresource_get_subresource', ['property' => 'recursivesubresource', 'identifiers' => [['id', DummyEntity::class, true], ['subresource', RelatedDummyEntity::class, true]], 'collection' => false, 'operationId' => 'api_dummies_subresources_recursivesubresource_get_subresource']), + $this->getSubresourceRoute('/dummies/{id}/subresources/{subresource}/recursivesubresource.{_format}', 'api_platform.action.get_subresource', DummyEntity::class, 'api_dummies_subresources_recursivesubresource_get_subresource', ['property' => 'recursivesubresource', 'identifiers' => ['id' => [DummyEntity::class, 'id', true], 'subresource' => [RelatedDummyEntity::class, 'id', true]], 'collection' => false, 'operationId' => 'api_dummies_subresources_recursivesubresource_get_subresource']), $routeCollection->get('api_dummies_subresources_recursivesubresource_get_subresource') ); $this->assertEquals( - $this->getSubresourceRoute('/related_dummies/{id}/secondrecursivesubresource/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_related_dummies_secondrecursivesubresource_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', RelatedDummyEntity::class, true], ['secondrecursivesubresource', DummyEntity::class, false]], 'collection' => true, 'operationId' => 'api_related_dummies_secondrecursivesubresource_subresources_get_subresource']), + $this->getSubresourceRoute('/related_dummies/{id}/secondrecursivesubresource/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_related_dummies_secondrecursivesubresource_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => ['id' => [RelatedDummyEntity::class, 'id', true], 'secondrecursivesubresource' => [DummyEntity::class, 'id', false]], 'collection' => true, 'operationId' => 'api_related_dummies_secondrecursivesubresource_subresources_get_subresource']), $routeCollection->get('api_related_dummies_secondrecursivesubresource_subresources_get_subresource') ); $this->assertEquals( - $this->getSubresourceRoute('/related_dummies/{id}/secondrecursivesubresource.{_format}', 'api_platform.action.get_subresource', DummyEntity::class, 'api_related_dummies_secondrecursivesubresource_get_subresource', ['property' => 'secondrecursivesubresource', 'identifiers' => [['id', RelatedDummyEntity::class, true]], 'collection' => false, 'operationId' => 'api_related_dummies_secondrecursivesubresource_get_subresource']), + $this->getSubresourceRoute('/related_dummies/{id}/secondrecursivesubresource.{_format}', 'api_platform.action.get_subresource', DummyEntity::class, 'api_related_dummies_secondrecursivesubresource_get_subresource', ['property' => 'secondrecursivesubresource', 'identifiers' => ['id' => [RelatedDummyEntity::class, 'id', true]], 'collection' => false, 'operationId' => 'api_related_dummies_secondrecursivesubresource_get_subresource']), $routeCollection->get('api_related_dummies_secondrecursivesubresource_get_subresource') ); } @@ -301,9 +303,13 @@ private function getApiLoaderWithResourceMetadata(ResourceMetadata $resourceMeta $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); - $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), new UnderscorePathSegmentNameGenerator()); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $identifiersExtractor = $identifiersExtractorProphecy->reveal(); - return new ApiLoader($kernelProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactory, $operationPathResolver, $containerProphecy->reveal(), ['jsonld' => ['application/ld+json']], [], $subresourceOperationFactory, false, true, true, false, false); + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), new UnderscorePathSegmentNameGenerator(), $identifiersExtractor); + + return new ApiLoader($kernelProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactory, $operationPathResolver, $containerProphecy->reveal(), ['jsonld' => ['application/ld+json']], [], $subresourceOperationFactory, false, true, true, false, false, $identifiersExtractor); } private function getRoute(string $path, string $controller, string $resourceClass, string $operationName, array $methods, bool $collection = false, array $requirements = [], array $extraDefaults = ['_stateless' => null], array $options = [], string $host = '', array $schemes = [], string $condition = ''): Route @@ -314,6 +320,8 @@ private function getRoute(string $path, string $controller, string $resourceClas '_controller' => $controller, '_format' => null, '_api_resource_class' => $resourceClass, + '_api_identifiers' => ['id'], + '_api_has_composite_identifier' => false, sprintf('_api_%s_operation_name', $collection ? 'collection' : 'item') => $operationName, ] + $extraDefaults, $requirements, @@ -335,6 +343,8 @@ private function getSubresourceRoute(string $path, string $controller, string $r '_api_resource_class' => $resourceClass, '_api_subresource_operation_name' => $operationName, '_api_subresource_context' => $context, + '_api_identifiers' => $context['identifiers'], + '_api_has_composite_identifier' => false, ] + $extraDefaults, $requirements, [], diff --git a/tests/Bridge/Symfony/Routing/IriConverterTest.php b/tests/Bridge/Symfony/Routing/IriConverterTest.php index dbf51036f9b..763d7c3499e 100644 --- a/tests/Bridge/Symfony/Routing/IriConverterTest.php +++ b/tests/Bridge/Symfony/Routing/IriConverterTest.php @@ -34,6 +34,7 @@ use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Routing\Exception\RouteNotFoundException; use Symfony\Component\Routing\RouterInterface; @@ -43,6 +44,7 @@ class IriConverterTest extends TestCase { use ProphecyTrait; + use ExpectDeprecationTrait; public function testGetItemFromIriNoRouteException() { @@ -76,6 +78,7 @@ public function testGetItemFromIriCollectionRouteException() $routerProphecy->match('/users')->willReturn([ '_api_resource_class' => Dummy::class, '_api_collection_operation_name' => 'get', + '_api_identifiers' => ['id'], ])->shouldBeCalledTimes(1); $converter = $this->getIriConverter($routerProphecy); @@ -89,13 +92,14 @@ public function testGetItemFromIriItemNotFoundException() $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); $itemDataProviderProphecy - ->getItem(Dummy::class, 3, 'get', []) + ->getItem(Dummy::class, ['id' => 3], 'get', [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]) ->shouldBeCalled()->willReturn(null); $routerProphecy = $this->prophesize(RouterInterface::class); $routerProphecy->match('/users/3')->willReturn([ '_api_resource_class' => Dummy::class, '_api_item_operation_name' => 'get', + '_api_identifiers' => ['id'], 'id' => 3, ])->shouldBeCalledTimes(1); @@ -107,12 +111,13 @@ public function testGetItemFromIri() { $item = new \stdClass(); $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); - $itemDataProviderProphecy->getItem(Dummy::class, 3, 'get', ['fetch_data' => true])->shouldBeCalled()->willReturn($item); + $itemDataProviderProphecy->getItem(Dummy::class, ['id' => 3], 'get', ['fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true])->shouldBeCalled()->willReturn($item); $routerProphecy = $this->prophesize(RouterInterface::class); $routerProphecy->match('/users/3')->willReturn([ '_api_resource_class' => Dummy::class, '_api_item_operation_name' => 'get', + '_api_identifiers' => ['id'], 'id' => 3, ])->shouldBeCalledTimes(1); @@ -123,7 +128,7 @@ public function testGetItemFromIri() public function testGetItemFromIriWithOperationName() { $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); - $itemDataProviderProphecy->getItem('AppBundle\Entity\User', '3', 'operation_name', ['fetch_data' => true]) + $itemDataProviderProphecy->getItem('AppBundle\Entity\User', ['id' => 3], 'operation_name', ['fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]) ->willReturn('foo') ->shouldBeCalledTimes(1); @@ -131,6 +136,7 @@ public function testGetItemFromIriWithOperationName() $routerProphecy->match('/users/3')->willReturn([ '_api_resource_class' => 'AppBundle\Entity\User', '_api_item_operation_name' => 'operation_name', + '_api_identifiers' => ['id'], 'id' => 3, ])->shouldBeCalledTimes(1); @@ -215,7 +221,10 @@ public function testGetItemIriFromResourceClass() $routerProphecy = $this->prophesize(RouterInterface::class); $routerProphecy->generate('api_dummies_get_item', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willReturn('/dummies/1'); - $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn((new ResourceMetadata())->withAttributes(['composite_identifier' => true])); + + $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy, null, null, null, $resourceMetadataFactoryProphecy->reveal()); $this->assertEquals($converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]), '/dummies/1'); } @@ -245,7 +254,10 @@ public function testNotAbleToGenerateGetItemIriFromResourceClass() $routerProphecy = $this->prophesize(RouterInterface::class); $routerProphecy->generate('dummies', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException()); - $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn((new ResourceMetadata())->withAttributes(['composite_identifier' => true])); + + $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy, null, null, null, $resourceMetadataFactoryProphecy->reveal()); $converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]); } @@ -255,11 +267,12 @@ public function testGetItemFromIriWithIdentifierConverter() $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); $itemDataProviderProphecy->getItem(Dummy::class, ['id' => 3], 'get', ['fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true])->shouldBeCalled()->willReturn($item); $identifierConverterProphecy = $this->prophesize(IdentifierConverterInterface::class); - $identifierConverterProphecy->convert('3', Dummy::class)->shouldBeCalled()->willReturn(['id' => 3]); + $identifierConverterProphecy->convert(['id' => '3'], Dummy::class)->shouldBeCalled()->willReturn(['id' => 3]); $routerProphecy = $this->prophesize(RouterInterface::class); $routerProphecy->match('/users/3')->willReturn([ '_api_resource_class' => Dummy::class, '_api_item_operation_name' => 'get', + '_api_identifiers' => ['id' => [Dummy::class, 'id']], 'id' => 3, ])->shouldBeCalledTimes(1); @@ -270,17 +283,18 @@ public function testGetItemFromIriWithIdentifierConverter() public function testGetItemFromIriWithSubresourceDataProvider() { $item = new \stdClass(); - $subresourceContext = ['identifiers' => [['id', Dummy::class, true]]]; + $subresourceContext = ['identifiers' => ['id' => [Dummy::class, 'id', true]]]; $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); $routerProphecy = $this->prophesize(RouterInterface::class); $routerProphecy->match('/users/3/adresses')->willReturn([ '_api_resource_class' => Dummy::class, '_api_subresource_context' => $subresourceContext, '_api_subresource_operation_name' => 'get_subresource', + '_api_identifiers' => $subresourceContext['identifiers'], 'id' => 3, ])->shouldBeCalledTimes(1); $subresourceDataProviderProphecy = $this->prophesize(SubresourceDataProviderInterface::class); - $subresourceDataProviderProphecy->getSubresource(Dummy::class, ['id' => 3], $subresourceContext + ['fetch_data' => true], 'get_subresource')->shouldBeCalled()->willReturn($item); + $subresourceDataProviderProphecy->getSubresource(Dummy::class, ['id' => ['id' => 3]], $subresourceContext + ['fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true], 'get_subresource')->shouldBeCalled()->willReturn($item); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy, null, $subresourceDataProviderProphecy); $this->assertEquals($converter->getItemFromIri('/users/3/adresses', ['fetch_data' => true]), $item); } @@ -290,17 +304,18 @@ public function testGetItemFromIriWithSubresourceDataProviderNotFound() $this->expectException(ItemNotFoundException::class); $this->expectExceptionMessage('Item not found for "/users/3/adresses".'); - $subresourceContext = ['identifiers' => [['id', Dummy::class, true]]]; + $subresourceContext = ['identifiers' => ['id' => [Dummy::class, 'id', true]]]; $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); $routerProphecy = $this->prophesize(RouterInterface::class); $routerProphecy->match('/users/3/adresses')->willReturn([ '_api_resource_class' => Dummy::class, '_api_subresource_context' => $subresourceContext, '_api_subresource_operation_name' => 'get_subresource', + '_api_identifiers' => $subresourceContext['identifiers'], 'id' => 3, ])->shouldBeCalledTimes(1); $identifierConverterProphecy = $this->prophesize(IdentifierConverterInterface::class); - $identifierConverterProphecy->convert('3', Dummy::class)->shouldBeCalled()->willReturn(['id' => 3]); + $identifierConverterProphecy->convert(['id' => '3'], Dummy::class)->shouldBeCalled()->willReturn(['id' => 3]); $subresourceDataProviderProphecy = $this->prophesize(SubresourceDataProviderInterface::class); $subresourceDataProviderProphecy->getSubresource(Dummy::class, ['id' => ['id' => 3]], $subresourceContext + ['fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true], 'get_subresource')->shouldBeCalled()->willReturn(null); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy, null, $subresourceDataProviderProphecy, $identifierConverterProphecy); @@ -318,16 +333,19 @@ public function testGetItemFromIriBadIdentifierException() $routerProphecy->match('/users/3')->willReturn([ '_api_resource_class' => Dummy::class, '_api_item_operation_name' => 'get_subresource', + '_api_identifiers' => ['id'], 'id' => 3, ])->shouldBeCalledTimes(1); $identifierConverterProphecy = $this->prophesize(IdentifierConverterInterface::class); - $identifierConverterProphecy->convert('3', Dummy::class)->shouldBeCalled()->willThrow(new InvalidIdentifierException('Item not found for "/users/3".')); + $identifierConverterProphecy->convert(['id' => '3'], Dummy::class)->shouldBeCalled()->willThrow(new InvalidIdentifierException('Item not found for "/users/3".')); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy, null, null, $identifierConverterProphecy); $this->assertEquals($converter->getItemFromIri('/users/3', ['fetch_data' => true]), $item); } public function testNoIdentifiersException() { + $this->markTestSkipped('The method "generateIdentifiersUrl" has been removed.'); + /* @phpstan-ignore-next-line */ $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('No identifiers defined for resource of type "\App\Entity\Sample"'); @@ -343,11 +361,10 @@ public function testNoIdentifiersException() /** * @group legacy - * @expectedDeprecation Not injecting "ApiPlatform\Core\Api\IdentifiersExtractorInterface" is deprecated since API Platform 2.1 and will not be possible anymore in API Platform 3 - * @expectedDeprecation Not injecting ApiPlatform\Core\Api\ResourceClassResolverInterface in the IdentifiersExtractor might introduce cache issues with object identifiers. */ public function testLegacyConstructor() { + $this->expectDeprecation('Not injecting ApiPlatform\Core\Api\ResourceClassResolverInterface in the IdentifiersExtractor might introduce cache issues with object identifiers.'); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $routerProphecy = $this->prophesize(RouterInterface::class); @@ -392,6 +409,13 @@ private function getIriConverter($routerProphecy = null, $routeNameResolverProph $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + if (null === $identifierConverterProphecy) { + $identifierConverterProphecy = $this->prophesize(IdentifierConverterInterface::class); + $identifierConverterProphecy->convert(Argument::type('array'), Argument::type('string'))->will(function ($args) { + return $args[0]; + }); + } + return new IriConverter( $propertyNameCollectionFactory, $propertyMetadataFactory, @@ -401,7 +425,7 @@ private function getIriConverter($routerProphecy = null, $routeNameResolverProph null, new IdentifiersExtractor($propertyNameCollectionFactory, $propertyMetadataFactory, null, $this->getResourceClassResolver()), $subresourceDataProviderProphecy ? $subresourceDataProviderProphecy->reveal() : null, - $identifierConverterProphecy ? $identifierConverterProphecy->reveal() : null, + $identifierConverterProphecy->reveal(), null, $resourceMetadataFactory ); diff --git a/tests/Bridge/Symfony/Routing/RouteNameResolverTest.php b/tests/Bridge/Symfony/Routing/RouteNameResolverTest.php index ae602f62582..7f7ba96d33b 100644 --- a/tests/Bridge/Symfony/Routing/RouteNameResolverTest.php +++ b/tests/Bridge/Symfony/Routing/RouteNameResolverTest.php @@ -172,12 +172,12 @@ public function testGetRouteNameForSubresourceRoute() $routeCollection->add('a_some_subresource_route', new Route('/a/some/item/path/{id}', [ '_api_resource_class' => 'AppBundle\Entity\User', '_api_subresource_operation_name' => 'some_other_item_op', - '_api_subresource_context' => ['identifiers' => [[1, 'bar']]], + '_api_subresource_context' => ['identifiers' => ['id' => ['Bar', 'id']]], ])); $routeCollection->add('b_some_subresource_route', new Route('/b/some/item/path/{id}', [ '_api_resource_class' => 'AppBundle\Entity\User', '_api_subresource_operation_name' => 'some_item_op', - '_api_subresource_context' => ['identifiers' => [[1, 'foo']]], + '_api_subresource_context' => ['identifiers' => ['id' => ['Foo', 'id']]], ])); $routeCollection->add('some_collection_route', new Route('/some/collection/path', [ '_api_resource_class' => 'AppBundle\Entity\User', @@ -188,7 +188,7 @@ public function testGetRouteNameForSubresourceRoute() $routerProphecy->getRouteCollection()->willReturn($routeCollection); $routeNameResolver = new RouteNameResolver($routerProphecy->reveal()); - $actual = $routeNameResolver->getRouteName('AppBundle\Entity\User', OperationType::SUBRESOURCE, ['subresource_resources' => ['foo' => 1]]); + $actual = $routeNameResolver->getRouteName('AppBundle\Entity\User', OperationType::SUBRESOURCE, ['subresource_resources' => ['Foo' => 1]]); $this->assertSame('b_some_subresource_route', $actual); } diff --git a/tests/EventListener/DeserializeListenerTest.php b/tests/EventListener/DeserializeListenerTest.php index 6563567b420..f35f30d3542 100644 --- a/tests/EventListener/DeserializeListenerTest.php +++ b/tests/EventListener/DeserializeListenerTest.php @@ -200,13 +200,7 @@ public function testDeserializeResourceClassSupportedFormat(string $method, bool public function testLegacyDeserializeResourceClassSupportedFormat(string $method, bool $populateObject): void { $formatsProviderProphecy = $this->prophesize(FormatsProviderInterface::class); - $formatsProviderProphecy->getFormatsFromAttributes([ - 'resource_class' => 'Foo', - 'collection_operation_name' => 'post', - 'receive' => true, - 'respond' => true, - 'persist' => true, - ])->willReturn(self::FORMATS)->shouldBeCalled(); + $formatsProviderProphecy->getFormatsFromAttributes(Argument::type('array'))->willReturn(self::FORMATS)->shouldBeCalled(); $this->doTestDeserializeResourceClassSupportedFormat($method, $populateObject, $formatsProviderProphecy->reveal()); } diff --git a/tests/EventListener/ReadListenerTest.php b/tests/EventListener/ReadListenerTest.php index a809dfdd537..06b5c05502c 100644 --- a/tests/EventListener/ReadListenerTest.php +++ b/tests/EventListener/ReadListenerTest.php @@ -188,7 +188,7 @@ public function testRetrieveCollectionGet() public function testRetrieveItem() { $identifierConverter = $this->prophesize(IdentifierConverterInterface::class); - $identifierConverter->convert('1', 'Foo')->shouldBeCalled()->willReturn(['id' => '1']); + $identifierConverter->convert(['id' => '1'], 'Foo')->shouldBeCalled()->willReturn(['id' => '1']); $collectionDataProvider = $this->prophesize(CollectionDataProviderInterface::class); $collectionDataProvider->getCollection()->shouldNotBeCalled(); @@ -200,7 +200,7 @@ public function testRetrieveItem() $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); $subresourceDataProvider->getSubresource()->shouldNotBeCalled(); - $request = new Request([], [], ['id' => 1, '_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json']); + $request = new Request([], [], ['id' => '1', '_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json']); $request->setMethod('GET'); $event = $this->prophesize(RequestEvent::class); @@ -241,7 +241,7 @@ public function testRetrieveItemNoIdentifier() public function testRetrieveSubresource() { $identifierConverter = $this->prophesize(IdentifierConverterInterface::class); - $identifierConverter->convert('1', 'Bar')->shouldBeCalled()->willReturn(['id' => '1']); + $identifierConverter->convert(['id' => '1'], 'Foo')->shouldBeCalled()->willReturn(['id' => '1']); $collectionDataProvider = $this->prophesize(CollectionDataProviderInterface::class); $collectionDataProvider->getCollection()->shouldNotBeCalled(); @@ -251,9 +251,9 @@ public function testRetrieveSubresource() $data = [new \stdClass()]; $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); - $subresourceDataProvider->getSubresource('Foo', ['id' => ['id' => '1']], ['identifiers' => [['id', 'Bar', true]], 'property' => 'bar', IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true], 'get')->willReturn($data)->shouldBeCalled(); + $subresourceDataProvider->getSubresource('Foo', ['id' => ['id' => '1']], ['identifiers' => [['id', 'Bar', true, ['id']]], 'property' => 'bar', IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true], 'get')->willReturn($data)->shouldBeCalled(); - $request = new Request([], [], ['id' => 1, '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar', true]], 'property' => 'bar']]); + $request = new Request([], [], ['id' => '1', '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar', true, ['id']]], 'property' => 'bar']]); $request->setMethod('GET'); $event = $this->prophesize(RequestEvent::class); @@ -270,19 +270,22 @@ public function testRetrieveSubresourceNoDataProvider() { $this->expectException(RuntimeException::class); + $identifierConverter = $this->prophesize(IdentifierConverterInterface::class); + $identifierConverter->convert(['id' => '1'], 'Foo')->shouldBeCalled()->willReturn(['id' => '1']); + $collectionDataProvider = $this->prophesize(CollectionDataProviderInterface::class); $collectionDataProvider->getCollection()->shouldNotBeCalled(); $itemDataProvider = $this->prophesize(ItemDataProviderInterface::class); $itemDataProvider->getItem()->shouldNotBeCalled(); - $request = new Request([], [], ['id' => 1, '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar', true]], 'property' => 'bar']]); + $request = new Request([], [], ['id' => '1', '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar', true, ['id']]], 'property' => 'bar']]); $request->setMethod('GET'); $event = $this->prophesize(RequestEvent::class); $event->getRequest()->willReturn($request)->shouldBeCalled(); - $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal()); + $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal(), null, null, $identifierConverter->reveal()); $listener->onKernelRequest($event->reveal()); $request->attributes->get('data'); @@ -291,7 +294,7 @@ public function testRetrieveSubresourceNoDataProvider() public function testRetrieveSubresourceNotFound() { $identifierConverter = $this->prophesize(IdentifierConverterInterface::class); - $identifierConverter->convert('1', 'Bar')->willThrow(new InvalidIdentifierException())->shouldBeCalled(); + $identifierConverter->convert(['id' => '1'], 'Foo')->willThrow(new InvalidIdentifierException())->shouldBeCalled(); $this->expectException(NotFoundHttpException::class); $collectionDataProvider = $this->prophesize(CollectionDataProviderInterface::class); @@ -300,7 +303,7 @@ public function testRetrieveSubresourceNotFound() $itemDataProvider = $this->prophesize(ItemDataProviderInterface::class); $itemDataProvider->getItem()->shouldNotBeCalled(); - $request = new Request([], [], ['id' => 1, '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar', true]], 'property' => 'bar']]); + $request = new Request([], [], ['id' => '1', '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar', true, ['id']]], 'property' => 'bar']]); $request->setMethod('GET'); $event = $this->prophesize(RequestEvent::class); @@ -313,7 +316,7 @@ public function testRetrieveSubresourceNotFound() public function testRetrieveItemNotFound() { $identifierConverter = $this->prophesize(IdentifierConverterInterface::class); - $identifierConverter->convert('22', 'Foo')->shouldBeCalled()->willReturn(['id' => 22]); + $identifierConverter->convert(['id' => '22'], 'Foo')->shouldBeCalled()->willReturn(['id' => 22]); $this->expectException(NotFoundHttpException::class); $collectionDataProvider = $this->prophesize(CollectionDataProviderInterface::class); @@ -323,7 +326,7 @@ public function testRetrieveItemNotFound() $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); - $request = new Request([], [], ['id' => 22, '_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json']); + $request = new Request([], [], ['id' => '22', '_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json']); $request->setMethod('GET'); $event = $this->prophesize(RequestEvent::class); @@ -338,13 +341,13 @@ public function testRetrieveBadItemNormalizedIdentifiers() $this->expectException(NotFoundHttpException::class); $identifierConverter = $this->prophesize(IdentifierConverterInterface::class); - $identifierConverter->convert('1', 'Foo')->shouldBeCalled()->willThrow(new InvalidIdentifierException()); + $identifierConverter->convert(['id' => '1'], 'Foo')->shouldBeCalled()->willThrow(new InvalidIdentifierException()); $collectionDataProvider = $this->prophesize(CollectionDataProviderInterface::class); $itemDataProvider = $this->prophesize(ItemDataProviderInterface::class); $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); - $request = new Request([], [], ['id' => 1, '_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json']); + $request = new Request([], [], ['id' => '1', '_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json']); $request->setMethod(Request::METHOD_GET); $event = $this->prophesize(RequestEvent::class); @@ -359,7 +362,7 @@ public function testRetrieveBadSubresourceNormalizedIdentifiers() $this->expectException(NotFoundHttpException::class); $identifierConverter = $this->prophesize(IdentifierConverterInterface::class); - $identifierConverter->convert(Argument::type('string'), Argument::type('string'))->shouldBeCalled()->willThrow(new InvalidIdentifierException()); + $identifierConverter->convert(Argument::type('array'), Argument::type('string'))->shouldBeCalled()->willThrow(new InvalidIdentifierException()); $collectionDataProvider = $this->prophesize(CollectionDataProviderInterface::class); $collectionDataProvider->getCollection()->shouldNotBeCalled(); @@ -370,7 +373,7 @@ public function testRetrieveBadSubresourceNormalizedIdentifiers() $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); $subresourceDataProvider->getSubresource()->shouldNotBeCalled(); - $request = new Request([], [], ['id' => 1, '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar', true]], 'property' => 'bar']]); + $request = new Request([], [], ['id' => '1', '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar', true, ['id']]], 'property' => 'bar']]); $request->setMethod(Request::METHOD_GET); $event = $this->prophesize(RequestEvent::class); diff --git a/tests/Fixtures/TestBundle/Document/Book.php b/tests/Fixtures/TestBundle/Document/Book.php new file mode 100644 index 00000000000..32e880d6855 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Book.php @@ -0,0 +1,51 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Book. + * + * @author Antoine Bluchet + * + * @ApiResource(collectionOperations={}, itemOperations={ + * "get", + * "get_by_isbn"={"method"="GET", "path"="/books/by_isbn/{isbn}.{_format}", "requirements"={"isbn"=".+"}, "identifiers"="isbn"} + * }) + * @ODM\Document + */ +class Book +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @ODM\Field(type="string", nullable=true) + */ + public $name; + + /** + * @ODM\Field(type="string") + */ + public $isbn; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/CustomMultipleIdentifierDummy.php b/tests/Fixtures/TestBundle/Document/CustomMultipleIdentifierDummy.php new file mode 100644 index 00000000000..4bdc10a52d8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/CustomMultipleIdentifierDummy.php @@ -0,0 +1,79 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Custom Identifier Dummy. + * + * @ApiResource(compositeIdentifier=false) + * @ODM\Document + */ +class CustomMultipleIdentifierDummy +{ + /** + * @var int The custom identifier + * + * @ODM\Id(strategy="NONE", type="integer") + */ + private $firstId; + + /** + * @var int The custom identifier + * + * @ApiProperty(identifier=true) + * @ODM\Field(type="integer") + */ + private $secondId; + + /** + * @var string The dummy name + * + * @ODM\Field(type="string") + */ + private $name; + + public function getFirstId(): int + { + return $this->firstId; + } + + public function setFirstId(int $firstId) + { + $this->firstId = $firstId; + } + + public function getSecondId(): int + { + return $this->secondId; + } + + public function setSecondId(int $secondId) + { + $this->secondId = $secondId; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name) + { + $this->name = $name; + } +} diff --git a/tests/Fixtures/TestBundle/Document/SlugParentDummy.php b/tests/Fixtures/TestBundle/Document/SlugParentDummy.php index 7c96edd1882..730233c51b6 100644 --- a/tests/Fixtures/TestBundle/Document/SlugParentDummy.php +++ b/tests/Fixtures/TestBundle/Document/SlugParentDummy.php @@ -23,7 +23,7 @@ /** * Custom Identifier Dummy With Subresource. * - * @ApiResource + * @ApiResource(attributes={"identifiers"="slug"}) * @ODM\Document */ class SlugParentDummy diff --git a/tests/Fixtures/TestBundle/Entity/Book.php b/tests/Fixtures/TestBundle/Entity/Book.php new file mode 100644 index 00000000000..e2e874edbad --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Book.php @@ -0,0 +1,53 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Book. + * + * @author Antoine Bluchet + * + * @ApiResource(collectionOperations={}, itemOperations={ + * "get", + * "get_by_isbn"={"method"="GET", "path"="/books/by_isbn/{isbn}.{_format}", "requirements"={"isbn"=".+"}, "identifiers"="isbn"} + * }) + * @ORM\Entity + */ +class Book +{ + /** + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @ORM\Column + */ + public $name; + + /** + * @ORM\Column(unique=true) + */ + public $isbn; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/CustomMultipleIdentifierDummy.php b/tests/Fixtures/TestBundle/Entity/CustomMultipleIdentifierDummy.php new file mode 100644 index 00000000000..82d77d8d2d0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/CustomMultipleIdentifierDummy.php @@ -0,0 +1,79 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Custom Identifier Dummy. + * + * @ApiResource(compositeIdentifier=false) + * @ORM\Entity + */ +class CustomMultipleIdentifierDummy +{ + /** + * @var int The custom identifier + * + * @ORM\Column(type="integer") + * @ORM\Id + */ + private $firstId; + + /** + * @var int The custom identifier + * + * @ORM\Column(type="integer") + * @ORM\Id + */ + private $secondId; + + /** + * @var string The dummy name + * + * @ORM\Column(length=30) + */ + private $name; + + public function getFirstId(): int + { + return $this->firstId; + } + + public function setFirstId(int $firstId) + { + $this->firstId = $firstId; + } + + public function getSecondId(): int + { + return $this->secondId; + } + + public function setSecondId(int $secondId) + { + $this->secondId = $secondId; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name) + { + $this->name = $name; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/SlugParentDummy.php b/tests/Fixtures/TestBundle/Entity/SlugParentDummy.php index 722f5d94e93..c24e784cc91 100644 --- a/tests/Fixtures/TestBundle/Entity/SlugParentDummy.php +++ b/tests/Fixtures/TestBundle/Entity/SlugParentDummy.php @@ -23,7 +23,7 @@ /** * Custom Identifier Dummy With Subresource. * - * @ApiResource + * @ApiResource(attributes={"identifiers"="slug"}) * @ORM\Entity */ class SlugParentDummy diff --git a/tests/Fixtures/TestBundle/Model/ProductInterface.php b/tests/Fixtures/TestBundle/Model/ProductInterface.php index 04b095817e5..ba482a0d6c3 100644 --- a/tests/Fixtures/TestBundle/Model/ProductInterface.php +++ b/tests/Fixtures/TestBundle/Model/ProductInterface.php @@ -21,6 +21,7 @@ /** * @ApiResource( * shortName="Product", + * attributes={"identifiers"="code"}, * normalizationContext={ * "groups"={"product_read"}, * }, diff --git a/tests/Fixtures/TestBundle/Model/TaxonInterface.php b/tests/Fixtures/TestBundle/Model/TaxonInterface.php index e715e8d6348..b3386d45f90 100644 --- a/tests/Fixtures/TestBundle/Model/TaxonInterface.php +++ b/tests/Fixtures/TestBundle/Model/TaxonInterface.php @@ -20,6 +20,7 @@ /** * @ApiResource( + * attributes={"identifiers"="code"}, * shortName="Taxon", * normalizationContext={ * "groups"={"taxon_read"}, diff --git a/tests/GraphQl/Resolver/Stage/ReadStageTest.php b/tests/GraphQl/Resolver/Stage/ReadStageTest.php index 981d3a56693..1facf8d56a7 100644 --- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ReadStageTest.php @@ -212,7 +212,7 @@ public function testApplyCollection(array $args, ?string $rootClass, ?array $sou $normalizationContext = ['normalization' => true]; $this->serializerContextBuilderProphecy->create($resourceClass, $operationName, $context, true)->shouldBeCalled()->willReturn($normalizationContext); - $this->subresourceDataProviderProphecy->getSubresource($resourceClass, ['id' => 3], $normalizationContext + ['filters' => $expectedFilters, 'property' => $fieldName, 'identifiers' => [['id', $resourceClass]], 'collection' => true], $operationName)->willReturn(['subresource']); + $this->subresourceDataProviderProphecy->getSubresource($resourceClass, ['id' => 3], $normalizationContext + ['filters' => $expectedFilters, 'property' => $fieldName, 'identifiers' => ['id' => [$resourceClass, 'id']], 'collection' => true], $operationName)->willReturn(['subresource']); $this->collectionDataProviderProphecy->getCollection($resourceClass, $operationName, $normalizationContext + ['filters' => $expectedFilters])->willReturn([]); diff --git a/tests/Hydra/Serializer/DocumentationNormalizerTest.php b/tests/Hydra/Serializer/DocumentationNormalizerTest.php index b0ca173a402..e75d39186e8 100644 --- a/tests/Hydra/Serializer/DocumentationNormalizerTest.php +++ b/tests/Hydra/Serializer/DocumentationNormalizerTest.php @@ -95,7 +95,7 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth 'resource_class' => 'relatedDummy', 'shortNames' => ['relatedDummy'], 'identifiers' => [ - ['id', 'dummy', true], + 'id' => ['dummy', 'id', true], ], 'route_name' => 'api_dummies_subresource_get_related_dummy', 'path' => '/dummies/{id}/related_dummy.{_format}', diff --git a/tests/Identifier/CompositeIdentifierParserTest.php b/tests/Identifier/CompositeIdentifierParserTest.php index c78f00c2847..79e87b99e0d 100644 --- a/tests/Identifier/CompositeIdentifierParserTest.php +++ b/tests/Identifier/CompositeIdentifierParserTest.php @@ -41,4 +41,27 @@ public function variousIdentifiers(): array 'foo=test=bar;bar=' => ['foo' => 'test=bar', 'bar' => ''], ]]]; } + + /** + * @dataProvider compositeIdentifiers + */ + public function testStringify(array $identifiers) + { + foreach ($identifiers as $string => $arr) { + $this->assertEquals(CompositeIdentifierParser::stringify($arr), $string); + } + } + + public function compositeIdentifiers(): array + { + return [[[ + 'a=bd;dc=d' => ['a' => 'bd', 'dc' => 'd'], + 'a=b;c=d foo;d23i=e' => ['a' => 'b', 'c' => 'd foo', 'd23i' => 'e'], + 'a=1;c=2;d=10-30-24' => ['a' => '1', 'c' => '2', 'd' => '10-30-24'], + 'a=test;b=bar;foo;c=123' => ['a' => 'test', 'b' => 'bar;foo', 'c' => '123'], + 'foo=test=bar;;bar=bazzz' => ['foo' => 'test=bar;', 'bar' => 'bazzz'], + 'foo=test=bar;bar=;test=foo' => ['foo' => 'test=bar', 'bar' => '', 'test' => 'foo'], + 'foo=test=bar;bar=' => ['foo' => 'test=bar', 'bar' => ''], + ]]]; + } } diff --git a/tests/Identifier/IdentifierConverterTest.php b/tests/Identifier/IdentifierConverterTest.php index effe2759411..00251d928d7 100644 --- a/tests/Identifier/IdentifierConverterTest.php +++ b/tests/Identifier/IdentifierConverterTest.php @@ -32,6 +32,8 @@ class IdentifierConverterTest extends TestCase public function testCompositeIdentifier() { + $this->markTestSkipped('This behavior is now external to the identifier converter.'); + /** @phpstan-ignore-next-line */ $identifier = 'a=1;c=2;d=2015-04-05'; $class = 'Dummy'; @@ -58,7 +60,7 @@ public function testCompositeIdentifier() public function testSingleDateIdentifier() { - $identifier = '2015-04-05'; + $identifier = ['funkyid' => '2015-04-05']; $class = 'Dummy'; $dateIdentifierPropertyMetadata = (new PropertyMetadata())->withIdentifier(true)->withType(new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateTime::class)); @@ -77,7 +79,7 @@ public function testSingleDateIdentifier() public function testIntegerIdentifier() { - $identifier = '42'; + $identifier = ['id' => '42']; $class = 'Dummy'; $integerIdentifierPropertyMetadata = (new PropertyMetadata())->withIdentifier(true)->withType(new Type(Type::BUILTIN_TYPE_INT)); diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 3c8953f301f..8b66422febf 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\OpenApi\Factory; +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\Bridge\Symfony\Routing\RouterOperationPathResolver; use ApiPlatform\Core\DataProvider\PaginationOptions; use ApiPlatform\Core\JsonSchema\Schema; @@ -148,6 +149,9 @@ public function testInvoke(): void $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $typeFactory->setSchemaFactory($schemaFactory); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $factory = new OpenApiFactory( $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactory, @@ -158,6 +162,7 @@ public function testInvoke(): void $operationPathResolver, $filterLocatorProphecy->reveal(), $subresourceOperationFactoryProphecy->reveal(), + $identifiersExtractorProphecy->reveal(), [], new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ 'header' => [ @@ -525,6 +530,9 @@ public function testOverrideDocumentation() $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $typeFactory->setSchemaFactory($schemaFactory); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $factory = new OpenApiFactory( $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactory, @@ -535,6 +543,7 @@ public function testOverrideDocumentation() $operationPathResolver, $filterLocatorProphecy->reveal(), $subresourceOperationFactoryProphecy->reveal(), + $identifiersExtractorProphecy->reveal(), [], new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ 'header' => [ @@ -614,7 +623,11 @@ public function testSubresourceDocumentation() $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new UnderscorePathSegmentNameGenerator()); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $identifiersExtractor = $identifiersExtractorProphecy->reveal(); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new UnderscorePathSegmentNameGenerator(), $identifiersExtractor); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Question::class, Answer::class])); @@ -634,6 +647,7 @@ public function testSubresourceDocumentation() $operationPathResolver, $filterLocatorProphecy->reveal(), $subresourceOperationFactory, + $identifiersExtractor, ['jsonld' => ['application/ld+json']], new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ 'header' => [ diff --git a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php index bed21b8eca2..8ba832a5007 100644 --- a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php +++ b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\OpenApi\Serializer; +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\DataProvider\PaginationOptions; use ApiPlatform\Core\JsonSchema\SchemaFactory; use ApiPlatform\Core\JsonSchema\TypeFactory; @@ -95,6 +96,9 @@ public function testNormalize() $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $typeFactory->setSchemaFactory($schemaFactory); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $factory = new OpenApiFactory( $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactory, @@ -105,6 +109,7 @@ public function testNormalize() $operationPathResolver, $filterLocatorProphecy->reveal(), $subresourceOperationFactoryProphecy->reveal(), + $identifiersExtractorProphecy->reveal(), [], new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ 'header' => [ diff --git a/tests/Operation/Factory/SubresourceOperationFactoryTest.php b/tests/Operation/Factory/SubresourceOperationFactoryTest.php index c811b159097..59e644a14d1 100644 --- a/tests/Operation/Factory/SubresourceOperationFactoryTest.php +++ b/tests/Operation/Factory/SubresourceOperationFactoryTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Operation\Factory; +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; @@ -27,6 +28,7 @@ use ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; /** * @author Antoine Bluchet @@ -62,7 +64,10 @@ public function testCreate() $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity')->shouldBeCalled()->willReturn('dummy_entities'); $pathSegmentNameGeneratorProphecy->getSegmentName('anotherSubresource', false)->shouldBeCalled()->willReturn('another_subresource'); - $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $pathSegmentNameGeneratorProphecy->reveal()); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $pathSegmentNameGeneratorProphecy->reveal(), $identifiersExtractorProphecy->reveal()); $this->assertEquals([ 'api_dummy_entities_subresource_get_subresource' => [ @@ -71,7 +76,7 @@ public function testCreate() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresource.{_format}', @@ -83,8 +88,8 @@ public function testCreate() 'resource_class' => DummyEntity::class, 'shortNames' => ['dummyEntity', 'relatedDummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['subresource', RelatedDummyEntity::class, false], + 'id' => [DummyEntity::class, 'id', true], + 'subresource' => [RelatedDummyEntity::class, 'id', false], ], 'route_name' => 'api_dummy_entities_subresource_another_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresource/another_subresource.{_format}', @@ -96,9 +101,9 @@ public function testCreate() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['subresource', RelatedDummyEntity::class, false], - ['anotherSubresource', DummyEntity::class, false], + 'id' => [DummyEntity::class, 'id', true], + 'subresource' => [RelatedDummyEntity::class, 'id', false], + 'anotherSubresource' => [DummyEntity::class, 'id', false], ], 'route_name' => 'api_dummy_entities_subresource_another_subresource_subcollections_get_subresource', 'path' => '/dummy_entities/{id}/subresource/another_subresource/subcollections.{_format}', @@ -110,7 +115,7 @@ public function testCreate() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subcollections_get_subresource', 'path' => '/dummy_entities/{id}/subcollections.{_format}', @@ -122,8 +127,8 @@ public function testCreate() 'resource_class' => DummyEntity::class, 'shortNames' => ['dummyEntity', 'relatedDummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['subcollection', RelatedDummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], + 'subcollection' => [RelatedDummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subcollections_another_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subcollections/{subcollection}/another_subresource.{_format}', @@ -135,9 +140,9 @@ public function testCreate() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['subcollection', RelatedDummyEntity::class, true], - ['anotherSubresource', DummyEntity::class, false], + 'id' => [DummyEntity::class, 'id', true], + 'subcollection' => [RelatedDummyEntity::class, 'id', true], + 'anotherSubresource' => [DummyEntity::class, 'id', false], ], 'route_name' => 'api_dummy_entities_subcollections_another_subresource_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subcollections/{subcollection}/another_subresource/subresource.{_format}', @@ -180,7 +185,10 @@ public function testCreateByOverriding() $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity')->shouldBeCalled()->willReturn('dummy_entities'); $pathSegmentNameGeneratorProphecy->getSegmentName('anotherSubresource', false)->shouldBeCalled()->willReturn('another_subresource'); - $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $pathSegmentNameGeneratorProphecy->reveal()); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $pathSegmentNameGeneratorProphecy->reveal(), $identifiersExtractorProphecy->reveal()); $this->assertEquals([ 'api_dummy_entities_subresource_get_subresource' => [ @@ -189,7 +197,7 @@ public function testCreateByOverriding() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresource.{_format}', @@ -201,8 +209,8 @@ public function testCreateByOverriding() 'resource_class' => DummyEntity::class, 'shortNames' => ['dummyEntity', 'relatedDummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['subresource', RelatedDummyEntity::class, false], + 'id' => [DummyEntity::class, 'id', true], + 'subresource' => [RelatedDummyEntity::class, 'id', false], ], 'route_name' => 'api_dummy_entities_subresource_another_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresource/another_subresource.{_format}', @@ -214,9 +222,9 @@ public function testCreateByOverriding() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['subresource', RelatedDummyEntity::class, false], - ['anotherSubresource', DummyEntity::class, false], + 'id' => [DummyEntity::class, 'id', true], + 'subresource' => [RelatedDummyEntity::class, 'id', false], + 'anotherSubresource' => [DummyEntity::class, 'id', false], ], 'route_name' => 'api_dummy_entities_subresource_another_subresource_subcollections_get_subresource', 'path' => '/dummy_entities/{id}/subresource/another_subresource/subcollections.{_format}', @@ -228,7 +236,7 @@ public function testCreateByOverriding() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subcollections_get_subresource', 'path' => '/dummy_entities/{id}/foobars', @@ -240,8 +248,8 @@ public function testCreateByOverriding() 'resource_class' => DummyEntity::class, 'shortNames' => ['dummyEntity', 'relatedDummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['subcollection', RelatedDummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], + 'subcollection' => [RelatedDummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subcollections_another_subresource_get_subresource', 'path' => '/dummy_entities/{id}/foobars/{subcollection}/another_foobar.{_format}', @@ -253,9 +261,9 @@ public function testCreateByOverriding() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['subcollection', RelatedDummyEntity::class, true], - ['anotherSubresource', DummyEntity::class, false], + 'id' => [DummyEntity::class, 'id', true], + 'subcollection' => [RelatedDummyEntity::class, 'id', true], + 'anotherSubresource' => [DummyEntity::class, 'id', false], ], 'route_name' => 'api_dummy_entities_subcollections_another_subresource_subresource_get_subresource', 'path' => '/dummy_entities/{id}/foobars/{subcollection}/another_foobar/subresource.{_format}', @@ -286,11 +294,15 @@ public function testCreateWithMaxDepth() $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity')->shouldBeCalled()->willReturn('dummy_entities'); $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresource'); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $subresourceOperationFactory = new SubresourceOperationFactory( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $pathSegmentNameGeneratorProphecy->reveal() + $pathSegmentNameGeneratorProphecy->reveal(), + $identifiersExtractorProphecy->reveal() ); $this->assertEquals([ @@ -300,7 +312,7 @@ public function testCreateWithMaxDepth() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresource.{_format}', @@ -346,11 +358,15 @@ public function testCreateWithMaxDepthMultipleSubresources() $pathSegmentNameGeneratorProphecy->getSegmentName('secondSubresource', false)->shouldBeCalled()->willReturn('second_subresources'); $pathSegmentNameGeneratorProphecy->getSegmentName('moreSubresource', false)->shouldBeCalled()->willReturn('mode_subresources'); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $subresourceOperationFactory = new SubresourceOperationFactory( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $pathSegmentNameGeneratorProphecy->reveal() + $pathSegmentNameGeneratorProphecy->reveal(), + $identifiersExtractorProphecy->reveal() ); $this->assertEquals([ @@ -360,7 +376,7 @@ public function testCreateWithMaxDepthMultipleSubresources() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresources.{_format}', @@ -372,7 +388,7 @@ public function testCreateWithMaxDepthMultipleSubresources() 'resource_class' => DummyValidatedEntity::class, 'shortNames' => ['dummyValidatedEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_second_subresource_get_subresource', 'path' => '/dummy_entities/{id}/second_subresources.{_format}', @@ -384,8 +400,8 @@ public function testCreateWithMaxDepthMultipleSubresources() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyValidatedEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['secondSubresource', DummyValidatedEntity::class, false], + 'id' => [DummyEntity::class, 'id', true], + 'secondSubresource' => [DummyValidatedEntity::class, 'id', false], ], 'route_name' => 'api_dummy_entities_second_subresource_more_subresource_get_subresource', 'path' => '/dummy_entities/{id}/second_subresources/mode_subresources.{_format}', @@ -430,11 +446,15 @@ public function testCreateWithMaxDepthMultipleSubresourcesSameMaxDepth() $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresources'); $pathSegmentNameGeneratorProphecy->getSegmentName('secondSubresource', false)->shouldBeCalled()->willReturn('second_subresources'); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $subresourceOperationFactory = new SubresourceOperationFactory( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $pathSegmentNameGeneratorProphecy->reveal() + $pathSegmentNameGeneratorProphecy->reveal(), + $identifiersExtractorProphecy->reveal() ); $this->assertEquals([ @@ -444,7 +464,7 @@ public function testCreateWithMaxDepthMultipleSubresourcesSameMaxDepth() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresources.{_format}', @@ -456,7 +476,7 @@ public function testCreateWithMaxDepthMultipleSubresourcesSameMaxDepth() 'resource_class' => DummyValidatedEntity::class, 'shortNames' => ['dummyValidatedEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_second_subresource_get_subresource', 'path' => '/dummy_entities/{id}/second_subresources.{_format}', @@ -485,11 +505,15 @@ public function testCreateSelfReferencingSubresources() $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity')->shouldBeCalled()->willReturn('dummy_entities'); $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresources'); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $subresourceOperationFactory = new SubresourceOperationFactory( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $pathSegmentNameGeneratorProphecy->reveal() + $pathSegmentNameGeneratorProphecy->reveal(), + $identifiersExtractorProphecy->reveal() ); $this->assertEquals([ @@ -499,7 +523,7 @@ public function testCreateSelfReferencingSubresources() 'resource_class' => DummyEntity::class, 'shortNames' => ['dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresources.{_format}', @@ -537,11 +561,15 @@ public function testCreateWithDifferentMaxDepthSelfReferencingSubresources() $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresources'); $pathSegmentNameGeneratorProphecy->getSegmentName('secondSubresource', false)->shouldBeCalled()->willReturn('second_subresources'); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $subresourceOperationFactory = new SubresourceOperationFactory( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $pathSegmentNameGeneratorProphecy->reveal() + $pathSegmentNameGeneratorProphecy->reveal(), + $identifiersExtractorProphecy->reveal() ); $this->assertEquals([ @@ -551,7 +579,7 @@ public function testCreateWithDifferentMaxDepthSelfReferencingSubresources() 'resource_class' => DummyEntity::class, 'shortNames' => ['dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresources.{_format}', @@ -563,8 +591,8 @@ public function testCreateWithDifferentMaxDepthSelfReferencingSubresources() 'resource_class' => DummyEntity::class, 'shortNames' => ['dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['subresource', DummyEntity::class, false], + 'id' => [DummyEntity::class, 'id', true], + 'subresource' => [DummyEntity::class, 'id', false], ], 'route_name' => 'api_dummy_entities_subresource_second_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresources/second_subresources.{_format}', @@ -576,7 +604,7 @@ public function testCreateWithDifferentMaxDepthSelfReferencingSubresources() 'resource_class' => DummyEntity::class, 'shortNames' => ['dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_second_subresource_get_subresource', 'path' => '/dummy_entities/{id}/second_subresources.{_format}', @@ -606,11 +634,15 @@ public function testCreateWithEnd() $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity')->shouldBeCalled()->willReturn('dummy_entities'); $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', true)->shouldBeCalled()->willReturn('subresource'); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $subresourceOperationFactory = new SubresourceOperationFactory( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $pathSegmentNameGeneratorProphecy->reveal() + $pathSegmentNameGeneratorProphecy->reveal(), + $identifiersExtractorProphecy->reveal() ); $result = $subresourceOperationFactory->create(DummyEntity::class); @@ -621,7 +653,7 @@ public function testCreateWithEnd() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresources_get_subresource', 'path' => '/dummy_entities/{id}/subresource.{_format}', @@ -633,8 +665,8 @@ public function testCreateWithEnd() 'resource_class' => DummyEntity::class, 'shortNames' => ['dummyEntity', 'relatedDummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], - ['subresource', RelatedDummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], + 'subresource' => [RelatedDummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresources_item_get_subresource', 'path' => '/dummy_entities/{id}/subresource/{subresource}.{_format}', @@ -664,11 +696,15 @@ public function testCreateWithEndButNoCollection() $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity')->shouldBeCalled()->willReturn('dummy_entities'); $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresource'); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $subresourceOperationFactory = new SubresourceOperationFactory( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $pathSegmentNameGeneratorProphecy->reveal() + $pathSegmentNameGeneratorProphecy->reveal(), + $identifiersExtractorProphecy->reveal() ); $result = $subresourceOperationFactory->create(DummyEntity::class); @@ -679,7 +715,7 @@ public function testCreateWithEndButNoCollection() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresource.{_format}', @@ -710,11 +746,15 @@ public function testCreateWithRootResourcePrefix() $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity')->shouldBeCalled()->willReturn('dummy_entities'); $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresource'); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $subresourceOperationFactory = new SubresourceOperationFactory( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $pathSegmentNameGeneratorProphecy->reveal() + $pathSegmentNameGeneratorProphecy->reveal(), + $identifiersExtractorProphecy->reveal() ); $this->assertEquals([ @@ -724,7 +764,7 @@ public function testCreateWithRootResourcePrefix() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresource_get_subresource', 'path' => '/root_resource_prefix/dummy_entities/{id}/subresource.{_format}', @@ -759,11 +799,15 @@ public function testCreateSelfReferencingSubresourcesWithSubresources() $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresources'); $pathSegmentNameGeneratorProphecy->getSegmentName('otherSubresource', false)->shouldBeCalled()->willReturn('other_subresources'); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $subresourceOperationFactory = new SubresourceOperationFactory( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $pathSegmentNameGeneratorProphecy->reveal() + $pathSegmentNameGeneratorProphecy->reveal(), + $identifiersExtractorProphecy->reveal() ); $this->assertEquals([ @@ -773,7 +817,7 @@ public function testCreateSelfReferencingSubresourcesWithSubresources() 'resource_class' => DummyEntity::class, 'shortNames' => ['dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_subresource_get_subresource', 'path' => '/dummy_entities/{id}/subresources.{_format}', @@ -785,7 +829,7 @@ public function testCreateSelfReferencingSubresourcesWithSubresources() 'resource_class' => RelatedDummyEntity::class, 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], 'identifiers' => [ - ['id', DummyEntity::class, true], + 'id' => [DummyEntity::class, 'id', true], ], 'route_name' => 'api_dummy_entities_other_subresource_get_subresource', 'path' => '/dummy_entities/{id}/other_subresources.{_format}', diff --git a/tests/PathResolver/OperationPathResolverTest.php b/tests/PathResolver/OperationPathResolverTest.php new file mode 100644 index 00000000000..81c9b77f38f --- /dev/null +++ b/tests/PathResolver/OperationPathResolverTest.php @@ -0,0 +1,34 @@ + + * + * 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\Core\Tests\PathResolver; + +use ApiPlatform\Core\Api\OperationType; +use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; +use ApiPlatform\Core\PathResolver\OperationPathResolver; +use PHPUnit\Framework\TestCase; + +class OperationPathResolverTest extends TestCase +{ + public function testResolveItemOperationPath() + { + $operationPathResolver = new OperationPathResolver(new UnderscorePathSegmentNameGenerator()); + $this->assertEquals('/foos/{id}.{_format}', $operationPathResolver->resolveOperationPath('Foo', [], OperationType::ITEM, 'get')); + } + + public function testResolveItemOperationPathIdentifiedBy() + { + $operationPathResolver = new OperationPathResolver(new UnderscorePathSegmentNameGenerator()); + $this->assertSame('/short_names/{isbn}.{_format}', $operationPathResolver->resolveOperationPath('ShortName', ['identifiers' => ['isbn']], OperationType::ITEM, 'get')); + } +} diff --git a/tests/Serializer/SerializerContextBuilderTest.php b/tests/Serializer/SerializerContextBuilderTest.php index d73238214f2..565ed59a82a 100644 --- a/tests/Serializer/SerializerContextBuilderTest.php +++ b/tests/Serializer/SerializerContextBuilderTest.php @@ -99,6 +99,11 @@ public function testCreateFromRequest() $request->attributes->replace(['_api_resource_class' => 'FooWithPatch', '_api_item_operation_name' => 'patch', '_api_format' => 'json', '_api_mime_type' => 'application/json']); $expected = ['item_operation_name' => 'patch', 'resource_class' => 'FooWithPatch', 'request_uri' => '/foowithpatch/1', 'operation_type' => 'item', 'api_allow_update' => true, 'uri' => 'http://localhost/foowithpatch/1', 'output' => null, 'input' => null, 'deep_object_to_populate' => true, 'skip_null_values' => true, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); + + $request = Request::create('/bars/1/foos'); + $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml', '_api_subresource_context' => ['identifiers' => ['id' => ['Foo', 'id']]], 'id' => '1']); + $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'subresource_identifiers' => ['id' => '1'], 'subresource_resources' => ['Foo' => ['id' => '1']]]; + $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } public function testThrowExceptionOnInvalidRequest() diff --git a/tests/Serializer/SerializerFilterContextBuilderTest.php b/tests/Serializer/SerializerFilterContextBuilderTest.php index 72f8dbe4495..bae2bf5d5d4 100644 --- a/tests/Serializer/SerializerFilterContextBuilderTest.php +++ b/tests/Serializer/SerializerFilterContextBuilderTest.php @@ -150,6 +150,10 @@ public function testCreateFromRequestWithoutAttributes() $attributes = [ 'resource_class' => DummyGroup::class, 'collection_operation_name' => 'get', + 'identifiers' => [ + 'id' => [DummyGroup::class, 'id'], + ], + 'has_composite_identifier' => false, 'receive' => true, 'respond' => true, 'persist' => true, diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index 3bd73151eb7..d40722e9a2c 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Tests\Swagger\Serializer; use ApiPlatform\Core\Api\FilterCollection; +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\Api\OperationAwareFormatsProviderInterface; use ApiPlatform\Core\Api\OperationMethodResolverInterface; use ApiPlatform\Core\Api\OperationType; @@ -50,6 +51,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Psr\Container\ContainerInterface; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -65,6 +67,7 @@ class DocumentationNormalizerV2Test extends TestCase { use ProphecyTrait; + use ExpectDeprecationTrait; private const OPERATION_FORMATS = [ 'input_formats' => ['jsonld' => ['application/ld+json']], @@ -73,10 +76,11 @@ class DocumentationNormalizerV2Test extends TestCase /** * @group legacy - * @expectedDeprecation Passing an instance of ApiPlatform\Core\Api\UrlGeneratorInterface to ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer::__construct() is deprecated since version 2.1 and will be removed in 3.0. */ public function testLegacyConstruct(): void { + $this->expectDeprecation('Passing an instance of ApiPlatform\Core\Api\UrlGeneratorInterface to ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer::__construct() is deprecated since version 2.1 and will be removed in 3.0.'); + $normalizer = new DocumentationNormalizer( $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), @@ -142,13 +146,37 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $operationMethodResolver, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -409,6 +437,9 @@ private function doTestNormalizeWithNameConverter(bool $legacy = false): void $typeFactory->setSchemaFactory($schemaFactory); } + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -424,7 +455,19 @@ private function doTestNormalizeWithNameConverter(bool $legacy = false): void 'application', '/oauth/v2/token', '/oauth/v2/auth', - ['scope param'] + ['scope param'], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -521,6 +564,9 @@ public function testNormalizeWithApiKeysEnabled(): void ], ]; + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -537,7 +583,18 @@ public function testNormalizeWithApiKeysEnabled(): void '', '', [], - $apiKeysConfiguration + $apiKeysConfiguration, + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -648,13 +705,37 @@ public function testNormalizeWithOnlyNormalizationGroups(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -848,13 +929,37 @@ public function testNormalizeNotAddExtraBodyParameters(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1025,13 +1130,37 @@ public function testNormalizeWithSwaggerDefinitionName(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1123,13 +1252,37 @@ public function testNormalizeWithOnlyDenormalizationGroups(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1311,13 +1464,37 @@ public function testNormalizeWithNormalizationAndDenormalizationGroups(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1491,13 +1668,37 @@ public function testNormalizeSkipsNotReadableAndNotWritableProperties(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1721,7 +1922,26 @@ public function testConstructWithInvalidFilterLocator(): void null, $this->prophesize(OperationPathResolverInterface::class)->reveal(), null, - new \ArrayObject() + new \ArrayObject(), + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $this->prophesize(IdentifiersExtractorInterface::class)->reveal() ); } @@ -1732,13 +1952,37 @@ public function testSupports(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $documentation = new Documentation(new ResourceNameCollection([Dummy::class]), 'Test API', 'This is a test API.', '1.2.3'); @@ -1768,13 +2012,37 @@ public function testNormalizeWithNoOperations(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1810,13 +2078,37 @@ public function testNormalizeWithCustomMethod(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1894,13 +2186,37 @@ public function testNormalizeWithNestedNormalizationGroups(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2081,6 +2397,9 @@ private function doTestNormalizeWithFilters($filterLocator): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -2089,7 +2408,26 @@ private function doTestNormalizeWithFilters($filterLocator): void null, $operationPathResolver, null, - $filterLocator + $filterLocator, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2227,7 +2565,13 @@ private function doTestNormalizeWithSubResource(OperationAwareFormatsProviderInt $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new UnderscorePathSegmentNameGenerator()); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new UnderscorePathSegmentNameGenerator(), $identifiersExtractorProphecy->reveal()); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); $normalizer = new DocumentationNormalizer( $resourceMetadataFactory, @@ -2251,7 +2595,12 @@ private function doTestNormalizeWithSubResource(OperationAwareFormatsProviderInt 'page', false, 'itemsPerPage', - $formatsProvider ?? ['json' => ['application/json'], 'csv' => ['text/csv']] + $formatsProvider ?? ['json' => ['application/json'], 'csv' => ['text/csv']], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2365,13 +2714,37 @@ public function testNormalizeWithPropertySwaggerContext(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2456,13 +2829,37 @@ public function testNormalizeWithPaginationClientEnabled(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2577,6 +2974,9 @@ private function doTestNormalizeWithCustomFormatsDefinedAtOperationLevel(Operati $operationPathResolver = new OperationPathResolver(new UnderscorePathSegmentNameGenerator()); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -2598,7 +2998,12 @@ private function doTestNormalizeWithCustomFormatsDefinedAtOperationLevel(Operati 'page', false, 'itemsPerPage', - $formatProvider + $formatProvider, + false, + 'pagination', + [], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2771,13 +3176,37 @@ private function doTestNormalizeWithInputAndOutputClass(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + '', + [], + [], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2977,13 +3406,37 @@ public function testNormalizeWithDefaultProperty($expectedDefault, $expectedExam $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, null, - $operationPathResolver + $operationPathResolver, + null, + null, + null, + false, + '', + '', + '', + '', + [], + [], + null, + true, + 'page', + false, + 'itemsPerPage', + [], + false, + '', + [], + [], + $identifiersExtractorProphecy->reveal() ); $result = $normalizer->normalize($documentation, DocumentationNormalizer::FORMAT); diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php index 71aaebe162c..d09cc53a429 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Tests\Swagger\Serializer; use ApiPlatform\Core\Api\FilterCollection; +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\Api\OperationAwareFormatsProviderInterface; use ApiPlatform\Core\Api\OperationMethodResolverInterface; use ApiPlatform\Core\Api\OperationType; @@ -120,6 +121,9 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -145,7 +149,9 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -477,6 +483,9 @@ private function doTestNormalizeWithNameConverter(bool $legacy = false): void $typeFactory->setSchemaFactory($schemaFactory); } + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactory, $propertyNameCollectionFactory, @@ -502,7 +511,9 @@ private function doTestNormalizeWithNameConverter(bool $legacy = false): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -611,6 +622,9 @@ public function testNormalizeWithApiKeysEnabled(): void ], ]; + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -636,7 +650,9 @@ public function testNormalizeWithApiKeysEnabled(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -752,6 +768,9 @@ public function testNormalizeWithOnlyNormalizationGroups(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -777,7 +796,9 @@ public function testNormalizeWithOnlyNormalizationGroups(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -968,6 +989,9 @@ public function testNormalizeWithOpenApiDefinitionName(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -993,7 +1017,9 @@ public function testNormalizeWithOpenApiDefinitionName(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1090,6 +1116,9 @@ public function testNormalizeWithOnlyDenormalizationGroups(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -1115,7 +1144,9 @@ public function testNormalizeWithOnlyDenormalizationGroups(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1315,6 +1346,9 @@ public function testNormalizeWithNormalizationAndDenormalizationGroups(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -1340,7 +1374,9 @@ public function testNormalizeWithNormalizationAndDenormalizationGroups(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1615,7 +1651,9 @@ public function testConstructWithInvalidFilterLocator(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $this->prophesize(IdentifiersExtractorInterface::class)->reveal() ); } @@ -1625,6 +1663,7 @@ public function testSupports(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), @@ -1651,7 +1690,9 @@ public function testSupports(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $documentation = new Documentation(new ResourceNameCollection([Dummy::class]), 'Test API', 'This is a test API.', '1.2.3'); @@ -1685,6 +1726,9 @@ public function testNormalizeWithNoOperations(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -1710,7 +1754,9 @@ public function testNormalizeWithNoOperations(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1745,6 +1791,9 @@ public function testNormalizeWithCustomMethod(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -1770,7 +1819,9 @@ public function testNormalizeWithCustomMethod(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -1847,6 +1898,9 @@ public function testNormalizeWithNestedNormalizationGroups(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -1872,7 +1926,9 @@ public function testNormalizeWithNestedNormalizationGroups(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2074,6 +2130,9 @@ private function doTestNormalizeWithFilters($filterLocator): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -2099,7 +2158,9 @@ private function doTestNormalizeWithFilters($filterLocator): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2256,7 +2317,13 @@ private function doTestNormalizeWithSubResource(OperationAwareFormatsProviderInt $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new UnderscorePathSegmentNameGenerator()); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new UnderscorePathSegmentNameGenerator(), $identifiersExtractorProphecy->reveal()); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); $normalizer = new DocumentationNormalizer( $resourceMetadataFactory, @@ -2283,7 +2350,9 @@ private function doTestNormalizeWithSubResource(OperationAwareFormatsProviderInt $formatProvider ?? [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2407,6 +2476,9 @@ public function testNormalizeWithPropertyOpenApiContext(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -2432,7 +2504,9 @@ public function testNormalizeWithPropertyOpenApiContext(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2522,6 +2596,9 @@ public function testNormalizeWithPaginationClientEnabled(): void $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -2547,7 +2624,9 @@ public function testNormalizeWithPaginationClientEnabled(): void [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2651,6 +2730,9 @@ public function testNormalizeWithPaginationCustomDefaultAndMaxItemsPerPage(): vo $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -2676,7 +2758,9 @@ public function testNormalizeWithPaginationCustomDefaultAndMaxItemsPerPage(): vo [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2789,6 +2873,9 @@ public function testLegacyNormalizeWithPaginationCustomDefaultAndMaxItemsPerPage $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -2814,7 +2901,9 @@ public function testLegacyNormalizeWithPaginationCustomDefaultAndMaxItemsPerPage [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ @@ -2943,6 +3032,9 @@ private function doTestNormalizeWithCustomFormatsDefinedAtOperationLevel(Operati $operationPathResolver = new OperationPathResolver(new UnderscorePathSegmentNameGenerator()); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + $normalizer = new DocumentationNormalizer( $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), @@ -2967,7 +3059,9 @@ private function doTestNormalizeWithCustomFormatsDefinedAtOperationLevel(Operati $formatsProvider ?? [], false, 'pagination', - ['spec_version' => 3] + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal() ); $expected = [ diff --git a/tests/Util/RequestAttributesExtractorTest.php b/tests/Util/RequestAttributesExtractorTest.php index 9288ba0986c..ed0b52705ec 100644 --- a/tests/Util/RequestAttributesExtractorTest.php +++ b/tests/Util/RequestAttributesExtractorTest.php @@ -33,6 +33,8 @@ public function testExtractCollectionAttributes() 'receive' => true, 'respond' => true, 'persist' => true, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -49,6 +51,8 @@ public function testExtractItemAttributes() 'receive' => true, 'respond' => true, 'persist' => true, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -65,6 +69,8 @@ public function testExtractReceive() 'receive' => false, 'respond' => true, 'persist' => true, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -78,6 +84,8 @@ public function testExtractReceive() 'receive' => true, 'respond' => true, 'persist' => true, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -91,6 +99,8 @@ public function testExtractReceive() 'receive' => true, 'respond' => true, 'persist' => true, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -107,6 +117,8 @@ public function testExtractRespond() 'receive' => true, 'respond' => false, 'persist' => true, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -120,6 +132,8 @@ public function testExtractRespond() 'receive' => true, 'respond' => true, 'persist' => true, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -133,6 +147,8 @@ public function testExtractRespond() 'receive' => true, 'respond' => true, 'persist' => true, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -149,6 +165,8 @@ public function testExtractPersist() 'receive' => true, 'respond' => true, 'persist' => false, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -162,6 +180,8 @@ public function testExtractPersist() 'receive' => true, 'respond' => true, 'persist' => true, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -175,6 +195,8 @@ public function testExtractPersist() 'receive' => true, 'respond' => true, 'persist' => true, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -203,6 +225,26 @@ public function testExtractPreviousDataAttributes() 'respond' => true, 'persist' => true, 'previous_data' => $object, + 'identifiers' => ['id' => ['Foo', 'id']], + 'has_composite_identifier' => false, + ], + RequestAttributesExtractor::extractAttributes($request) + ); + } + + public function testExtractIdentifiers() + { + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_identifiers' => ['test'], '_api_has_composite_identifier' => true]); + + $this->assertEquals( + [ + 'resource_class' => 'Foo', + 'item_operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'identifiers' => ['test' => ['Foo', 'test']], + 'has_composite_identifier' => true, ], RequestAttributesExtractor::extractAttributes($request) );