Skip to content

Commit c86bb1d

Browse files
authored
Rework to improve and simplify identifiers management (#3825)
* Move logic of identifiers extractor upwards, use only identifier converter downwards * Implement identifiers configuration See also: https://github.com/api-platform/core/blob/master/docs/adr/0001-resource-identifiers.md * Self review * temp
1 parent f3c544a commit c86bb1d

File tree

53 files changed

+1470
-299
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1470
-299
lines changed

features/main/custom_identifier.feature

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,21 @@ Feature: Using custom identifier on resource
101101
When I send a "DELETE" request to "/custom_identifier_dummies/1"
102102
Then the response status code should be 204
103103
And the response should be empty
104+
105+
@createSchema
106+
Scenario: Get a resource
107+
Given there is a custom multiple identifier dummy
108+
When I send a "GET" request to "/custom_multiple_identifier_dummies/1/2"
109+
Then the response status code should be 200
110+
And the response should be in JSON
111+
And the JSON should be equal to:
112+
"""
113+
{
114+
"@context": "/contexts/CustomMultipleIdentifierDummy",
115+
"@id": "/custom_multiple_identifier_dummies/1/2",
116+
"@type": "CustomMultipleIdentifierDummy",
117+
"firstId": 1,
118+
"secondId": 2,
119+
"name": "Orwell"
120+
}
121+
"""

features/main/operation.feature

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,22 @@ Feature: Operation support
6464
Scenario: Get a 404 response for the disabled item operation
6565
When I send a "GET" request to "/disable_item_operations/1"
6666
Then the response status code should be 404
67+
68+
@createSchema
69+
Scenario: Get a book by its ISBN
70+
Given there is a book
71+
When I send a "GET" request to "books/by_isbn/9780451524935"
72+
Then the response status code should be 200
73+
And the response should be in JSON
74+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
75+
And the JSON should be equal to:
76+
"""
77+
{
78+
"@context": "/contexts/Book",
79+
"@id": "/books/1",
80+
"@type": "Book",
81+
"name": "1984",
82+
"isbn": "9780451524935",
83+
"id": 1
84+
}
85+
"""

src/Annotation/ApiResource.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
* @Attribute("attributes", type="array"),
2929
* @Attribute("cacheHeaders", type="array"),
3030
* @Attribute("collectionOperations", type="array"),
31+
* @Attribute("compositeIdentifier", type="bool"),
3132
* @Attribute("denormalizationContext", type="array"),
3233
* @Attribute("deprecationReason", type="string"),
3334
* @Attribute("description", type="string"),
@@ -217,7 +218,8 @@ public function __construct(
217218
?string $sunset = null,
218219
?array $swaggerContext = null,
219220
?array $validationGroups = null,
220-
?int $urlGenerationStrategy = null
221+
?int $urlGenerationStrategy = null,
222+
?bool $compositeIdentifier = null
221223
) {
222224
if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support
223225
[$publicProperties, $configurableAttributes] = self::getConfigMetadata();

src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,16 @@ private function buildAggregation(array $identifiers, array $context, array $exe
121121

122122
$topAggregationBuilder = $topAggregationBuilder ?? $previousAggregationBuilder;
123123

124-
[$identifier, $identifierResourceClass] = $context['identifiers'][$remainingIdentifiers - 1];
125-
$previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property'];
124+
if (\is_string(key($context['identifiers']))) {
125+
$contextIdentifiers = array_keys($context['identifiers']);
126+
$identifier = $contextIdentifiers[$remainingIdentifiers - 1];
127+
$identifierResourceClass = $context['identifiers'][$identifier][0];
128+
$previousAssociationProperty = $contextIdentifiers[$remainingIdentifiers] ?? $context['property'];
129+
} else {
130+
@trigger_error('Identifiers should match the convention introduced in ADR 0001-resource-identifiers, this behavior will be removed in 3.0.', E_USER_DEPRECATED);
131+
[$identifier, $identifierResourceClass] = $context['identifiers'][$remainingIdentifiers - 1];
132+
$previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property'];
133+
}
126134

127135
$manager = $this->managerRegistry->getManagerForClass($identifierResourceClass);
128136
if (!$manager instanceof DocumentManager) {

src/Bridge/Doctrine/Orm/SubresourceDataProvider.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,16 @@ private function buildQuery(array $identifiers, array $context, QueryNameGenerat
130130

131131
$topQueryBuilder = $topQueryBuilder ?? $previousQueryBuilder;
132132

133-
[$identifier, $identifierResourceClass] = $context['identifiers'][$remainingIdentifiers - 1];
134-
$previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property'];
133+
if (\is_string(key($context['identifiers']))) {
134+
$contextIdentifiers = array_keys($context['identifiers']);
135+
$identifier = $contextIdentifiers[$remainingIdentifiers - 1];
136+
$identifierResourceClass = $context['identifiers'][$identifier][0];
137+
$previousAssociationProperty = $contextIdentifiers[$remainingIdentifiers] ?? $context['property'];
138+
} else {
139+
@trigger_error('Identifiers should match the convention introduced in ADR 0001-resource-identifiers, this behavior will be removed in 3.0.', E_USER_DEPRECATED);
140+
[$identifier, $identifierResourceClass] = $context['identifiers'][$remainingIdentifiers - 1];
141+
$previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property'];
142+
}
135143

136144
$manager = $this->managerRegistry->getManagerForClass($identifierResourceClass);
137145

src/Bridge/Symfony/Bundle/Resources/config/api.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
<argument>%api_platform.enable_docs%</argument>
4444
<argument>%api_platform.graphql.graphiql.enabled%</argument>
4545
<argument>%api_platform.graphql.graphql_playground.enabled%</argument>
46+
<argument type="service" id="api_platform.identifiers_extractor.cached"></argument>
4647

4748
<tag name="routing.loader" />
4849
</service>
@@ -288,6 +289,7 @@
288289
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
289290
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
290291
<argument type="service" id="api_platform.path_segment_name_generator" />
292+
<argument type="service" id="api_platform.identifiers_extractor.cached" />
291293
</service>
292294

293295
<service id="api_platform.subresource_operation_factory.cached" class="ApiPlatform\Core\Operation\Factory\CachedSubresourceOperationFactory" decorates="api_platform.subresource_operation_factory" decoration-priority="-10" public="false">

src/Bridge/Symfony/Bundle/Resources/config/openapi.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
<argument type="service" id="api_platform.operation_path_resolver" />
4343
<argument type="service" id="api_platform.filter_locator" />
4444
<argument type="service" id="api_platform.subresource_operation_factory" />
45+
<argument type="service" id="api_platform.identifiers_extractor.cached" />
4546
<argument>%api_platform.formats%</argument>
4647
<argument type="service" id="api_platform.openapi.options" />
4748
<argument type="service" id="api_platform.pagination_options" />

src/Bridge/Symfony/Bundle/Resources/config/swagger.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
<argument>%api_platform.collection.pagination.enabled_parameter_name%</argument>
3434
<argument type="collection" />
3535
<argument>%api_platform.swagger.versions%</argument>
36+
<argument type="service" id="api_platform.identifiers_extractor.cached" />
3637
<tag name="serializer.normalizer" priority="-790" />
3738
</service>
3839

src/Bridge/Symfony/Routing/ApiLoader.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Core\Bridge\Symfony\Routing;
1515

16+
use ApiPlatform\Core\Api\IdentifiersExtractorInterface;
1617
use ApiPlatform\Core\Api\OperationType;
1718
use ApiPlatform\Core\Exception\InvalidResourceException;
1819
use ApiPlatform\Core\Exception\RuntimeException;
@@ -56,8 +57,9 @@ final class ApiLoader extends Loader
5657
private $graphQlPlaygroundEnabled;
5758
private $entrypointEnabled;
5859
private $docsEnabled;
60+
private $identifiersExtractor;
5961

60-
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)
62+
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)
6163
{
6264
/** @var string[]|string $paths */
6365
$paths = $kernel->locateResource('@ApiPlatformBundle/Resources/config/routing');
@@ -74,6 +76,7 @@ public function __construct(KernelInterface $kernel, ResourceNameCollectionFacto
7476
$this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled;
7577
$this->entrypointEnabled = $entrypointEnabled;
7678
$this->docsEnabled = $docsEnabled;
79+
$this->identifiersExtractor = $identifiersExtractor;
7780
}
7881

7982
/**
@@ -128,6 +131,8 @@ public function load($data, $type = null): RouteCollection
128131
'_format' => null,
129132
'_stateless' => $operation['stateless'] ?? $resourceMetadata->getAttribute('stateless'),
130133
'_api_resource_class' => $operation['resource_class'],
134+
'_api_identifiers' => $operation['identifiers'],
135+
'_api_has_composite_identifier' => false,
131136
'_api_subresource_operation_name' => $operation['route_name'],
132137
'_api_subresource_context' => [
133138
'property' => $operation['property'],
@@ -222,6 +227,8 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas
222227
}
223228
}
224229

230+
$operation['identifiers'] = (array) ($operation['identifiers'] ?? $resourceMetadata->getAttribute('identifiers', $this->identifiersExtractor ? $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass) : ['id']));
231+
$operation['has_composite_identifier'] = \count($operation['identifiers']) > 1 ? $resourceMetadata->getAttribute('composite_identifier', true) : false;
225232
$path = trim(trim($resourceMetadata->getAttribute('route_prefix', '')), '/');
226233
$path .= $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName);
227234

@@ -232,6 +239,8 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas
232239
'_format' => null,
233240
'_stateless' => $operation['stateless'],
234241
'_api_resource_class' => $resourceClass,
242+
'_api_identifiers' => $operation['identifiers'],
243+
'_api_has_composite_identifier' => $operation['has_composite_identifier'],
235244
sprintf('_api_%s_operation_name', $operationType) => $operationName,
236245
] + ($operation['defaults'] ?? []),
237246
$operation['requirements'] ?? [],

src/Bridge/Symfony/Routing/IriConverter.php

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use ApiPlatform\Core\Exception\InvalidIdentifierException;
2727
use ApiPlatform\Core\Exception\ItemNotFoundException;
2828
use ApiPlatform\Core\Exception\RuntimeException;
29+
use ApiPlatform\Core\Identifier\CompositeIdentifierParser;
2930
use ApiPlatform\Core\Identifier\IdentifierConverterInterface;
3031
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
3132
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
@@ -60,11 +61,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName
6061
$this->subresourceDataProvider = $subresourceDataProvider;
6162
$this->identifierConverter = $identifierConverter;
6263
$this->resourceClassResolver = $resourceClassResolver;
63-
64-
if (null === $identifiersExtractor) {
65-
@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);
66-
$this->identifiersExtractor = new IdentifiersExtractor($propertyNameCollectionFactory, $propertyMetadataFactory, $propertyAccessor ?? PropertyAccess::createPropertyAccessor());
67-
}
64+
$this->identifiersExtractor = $identifiersExtractor ?: new IdentifiersExtractor($propertyNameCollectionFactory, $propertyMetadataFactory, $propertyAccessor ?? PropertyAccess::createPropertyAccessor());
6865
$this->resourceMetadataFactory = $resourceMetadataFactory;
6966
}
7067

@@ -148,11 +145,14 @@ public function getIriFromResourceClass(string $resourceClass, int $referenceTyp
148145
public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = null): string
149146
{
150147
$routeName = $this->routeNameResolver->getRouteName($resourceClass, OperationType::ITEM);
148+
$metadata = $this->resourceMetadataFactory->create($resourceClass);
151149

152-
try {
153-
$identifiers = $this->generateIdentifiersUrl($identifiers, $resourceClass);
150+
if (\count($identifiers) > 1 && true === $metadata->getAttribute('composite_identifier', true)) {
151+
$identifiers = ['id' => CompositeIdentifierParser::stringify($identifiers)];
152+
}
154153

155-
return $this->router->generate($routeName, ['id' => implode(';', $identifiers)], $this->getReferenceType($resourceClass, $referenceType));
154+
try {
155+
return $this->router->generate($routeName, $identifiers, $this->getReferenceType($resourceClass, $referenceType));
156156
} catch (RoutingExceptionInterface $e) {
157157
throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e);
158158
}
@@ -170,30 +170,6 @@ public function getSubresourceIriFromResourceClass(string $resourceClass, array
170170
}
171171
}
172172

173-
/**
174-
* Generate the identifier url.
175-
*
176-
* @throws InvalidArgumentException
177-
*
178-
* @return string[]
179-
*/
180-
private function generateIdentifiersUrl(array $identifiers, string $resourceClass): array
181-
{
182-
if (0 === \count($identifiers)) {
183-
throw new InvalidArgumentException(sprintf('No identifiers defined for resource of type "%s"', $resourceClass));
184-
}
185-
186-
if (1 === \count($identifiers)) {
187-
return [(string) reset($identifiers)];
188-
}
189-
190-
foreach ($identifiers as $name => $value) {
191-
$identifiers[$name] = sprintf('%s=%s', $name, $value);
192-
}
193-
194-
return array_values($identifiers);
195-
}
196-
197173
private function getReferenceType(string $resourceClass, ?int $referenceType): ?int
198174
{
199175
if (null === $referenceType && null !== $this->resourceMetadataFactory) {

src/Bridge/Symfony/Routing/RouteNameResolver.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ private function isSameSubresource(array $context, array $currentContext): bool
6767
$subresources = array_keys($context['subresource_resources']);
6868
$currentSubresources = [];
6969

70-
foreach ($currentContext['identifiers'] as $identifierContext) {
71-
$currentSubresources[] = $identifierContext[1];
70+
foreach ($currentContext['identifiers'] as [$class]) {
71+
$currentSubresources[] = $class;
7272
}
7373

7474
return $currentSubresources === $subresources;

src/DataProvider/OperationDataProviderTrait.php

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use ApiPlatform\Core\Exception\InvalidIdentifierException;
1717
use ApiPlatform\Core\Exception\RuntimeException;
18+
use ApiPlatform\Core\Identifier\CompositeIdentifierParser;
1819
use ApiPlatform\Core\Identifier\IdentifierConverterInterface;
1920

2021
/**
@@ -75,7 +76,15 @@ private function getSubresourceData($identifiers, array $attributes, array $cont
7576
throw new RuntimeException('Subresources not supported');
7677
}
7778

78-
return $this->subresourceDataProvider->getSubresource($attributes['resource_class'], $identifiers, $attributes['subresource_context'] + $context, $attributes['subresource_operation_name']);
79+
// TODO: SubresourceDataProvider wants: ['id' => ['id' => 1], 'relatedDummies' => ['id' => 2]], identifiers is ['id' => 1, 'relatedDummies' => 2]
80+
$subresourceIdentifiers = [];
81+
foreach ($attributes['identifiers'] as $parameterName => [$class, $property]) {
82+
if (false !== ($attributes['identifiers'][$parameterName][2] ?? null)) {
83+
$subresourceIdentifiers[$parameterName] = [$property => $identifiers[$parameterName]];
84+
}
85+
}
86+
87+
return $this->subresourceDataProvider->getSubresource($attributes['resource_class'], $subresourceIdentifiers, $attributes['subresource_context'] + $context, $attributes['subresource_operation_name']);
7988
}
8089

8190
/**
@@ -85,38 +94,32 @@ private function getSubresourceData($identifiers, array $attributes, array $cont
8594
*/
8695
private function extractIdentifiers(array $parameters, array $attributes)
8796
{
88-
if (isset($attributes['item_operation_name'])) {
89-
if (!isset($parameters['id'])) {
90-
throw new InvalidIdentifierException('Parameter "id" not found');
91-
}
92-
93-
$id = $parameters['id'];
94-
95-
if (null !== $this->identifierConverter) {
96-
return $this->identifierConverter->convert((string) $id, $attributes['resource_class']);
97-
}
97+
$identifiersKeys = $attributes['identifiers'] ?? ['id' => [$attributes['resource_class'], 'id']];
98+
$identifiers = [];
9899

99-
return $id;
100-
}
100+
$identifiersNumber = \count($identifiersKeys);
101+
foreach ($identifiersKeys as $parameterName => $identifiedBy) {
102+
if (!isset($parameters[$parameterName])) {
103+
if ($attributes['has_composite_identifier']) {
104+
$identifiers = CompositeIdentifierParser::parse($parameters['id']);
105+
if (($currentIdentifiersNumber = \count($identifiers)) !== $identifiersNumber) {
106+
throw new InvalidIdentifierException(sprintf('Expected %d identifiers, got %d', $identifiersNumber, $currentIdentifiersNumber));
107+
}
101108

102-
if (!isset($attributes['subresource_context'])) {
103-
throw new RuntimeException('Either "item_operation_name" or "collection_operation_name" must be defined, unless the "_api_receive" request attribute is set to false.');
104-
}
109+
return $identifiers;
110+
}
105111

106-
$identifiers = [];
112+
// TODO: Subresources tuple may have a third item representing if it is a "collection", this behavior will be removed in 3.0
113+
if (false === ($identifiedBy[2] ?? null)) {
114+
continue;
115+
}
107116

108-
foreach ($attributes['subresource_context']['identifiers'] as $key => [$id, $resourceClass, $hasIdentifier]) {
109-
if (false === $hasIdentifier) {
110-
continue;
117+
throw new InvalidIdentifierException(sprintf('Parameter "%s" not found', $parameterName));
111118
}
112119

113-
$identifiers[$id] = $parameters[$id];
114-
115-
if (null !== $this->identifierConverter) {
116-
$identifiers[$id] = $this->identifierConverter->convert((string) $identifiers[$id], $resourceClass);
117-
}
120+
$identifiers[$parameterName] = $parameters[$parameterName];
118121
}
119122

120-
return $identifiers;
123+
return $this->identifierConverter->convert($identifiers, $attributes['resource_class']);
121124
}
122125
}

src/GraphQl/Resolver/Stage/ReadStage.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ private function getSubresource(string $rootResolvedClass, array $rootResolvedFi
172172
$resolvedIdentifiers = [];
173173
$rootIdentifiers = array_keys($rootResolvedFields);
174174
foreach ($rootIdentifiers as $rootIdentifier) {
175-
$resolvedIdentifiers[] = [$rootIdentifier, $rootResolvedClass];
175+
$resolvedIdentifiers[$rootIdentifier] = [$rootResolvedClass, $rootIdentifier];
176176
}
177177

178178
return $this->subresourceDataProvider->getSubresource($subresourceClass, $rootResolvedFields, $normalizationContext + [

0 commit comments

Comments
 (0)