Skip to content

Commit dcbbd06

Browse files
authored
feat(metadata): crud on subresource (#4932)
1 parent 37bc691 commit dcbbd06

17 files changed

+437
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
## 3.0.0
55

6+
* Metadata: CRUD on subresource with experimental write support (#4932)
67
* Symfony: 6.1 compatibility and remove 4.4 and 5.4 support (#4851)
78
* Symfony: removed the $exceptionOnNoToken parameter in `ResourceAccessChecker::__construct()` (#4905)
89
* Symfony: use conventional service names for Doctrine state providers and processors (#4859)

features/main/sub_resource.feature

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,3 +561,35 @@ Feature: Sub-resource support
561561
"foo": null
562562
}
563563
"""
564+
565+
@!mongodb
566+
@createSchema
567+
Scenario: The generated crud should allow us to interact with the SubresourceEmployee
568+
Given I add "Content-Type" header equal to "application/ld+json"
569+
And I send a "POST" request to "/subresource_organizations" with body:
570+
"""
571+
{
572+
"name": "Les Tilleuls"
573+
}
574+
"""
575+
Then the response status code should be 201
576+
Given I add "Content-Type" header equal to "application/ld+json"
577+
And I send a "POST" request to "/subresource_organizations/1/subresource_employees" with body:
578+
"""
579+
{
580+
"name": "soyuka"
581+
}
582+
"""
583+
Then the response status code should be 201
584+
And I send a "GET" request to "/subresource_organizations/1/subresource_employees/1"
585+
Then the response status code should be 200
586+
And I send a "GET" request to "/subresource_organizations/1/subresource_employees"
587+
Then the response status code should be 200
588+
Given I add "Content-Type" header equal to "application/ld+json"
589+
And I send a "PUT" request to "/subresource_organizations/1/subresource_employees/1" with body:
590+
"""
591+
{
592+
"name": "ok"
593+
}
594+
"""
595+
Then the response status code should be 200

src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use ApiPlatform\Metadata\Post;
3434
use ApiPlatform\Metadata\Put;
3535
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
36+
use ApiPlatform\State\CreateProvider;
3637
use Psr\Log\LoggerInterface;
3738
use Psr\Log\NullLogger;
3839
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
@@ -130,8 +131,9 @@ private function buildResourceOperations(array $attributes, string $resourceClas
130131
// Loop again and set default operations if none where found
131132
foreach ($resources as $index => $resource) {
132133
$operations = [];
133-
foreach ($resource->getOperations() ?? [new Get(), new GetCollection(), new Post(), new Put(), new Patch(), new Delete()] as $i => $operation) {
134-
[$key, $operation] = $this->getOperationWithDefaults($resource, $operation);
134+
135+
foreach ($resource->getOperations() ?? $this->getDefaultHttpOperations($resource) as $i => $operation) {
136+
[$key, $operation] = $this->getOperationWithDefaults($resource, $operation, $resource->getOperations() ? false : true);
135137
$operations[$key] = $operation;
136138
}
137139

@@ -162,7 +164,7 @@ private function buildResourceOperations(array $attributes, string $resourceClas
162164
return $resources;
163165
}
164166

165-
private function getOperationWithDefaults(ApiResource $resource, Operation $operation): array
167+
private function getOperationWithDefaults(ApiResource $resource, Operation $operation, bool $generated = false): array
166168
{
167169
// Inherit from resource defaults
168170
foreach (get_class_methods($resource) as $methodName) {
@@ -181,7 +183,11 @@ private function getOperationWithDefaults(ApiResource $resource, Operation $oper
181183
$operation = $operation->{'with'.substr($methodName, 3)}($value);
182184
}
183185

184-
$operation = $operation->withExtraProperties(array_merge($resource->getExtraProperties(), $operation->getExtraProperties()));
186+
$operation = $operation->withExtraProperties(array_merge(
187+
$resource->getExtraProperties(),
188+
$operation->getExtraProperties(),
189+
$generated ? ['generated_operation' => true] : []
190+
));
185191

186192
// Add global defaults attributes to the operation
187193
$operation = $this->addGlobalDefaults($operation);
@@ -312,4 +318,14 @@ private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource
312318

313319
return $resource->withGraphQlOperations($graphQlOperations);
314320
}
321+
322+
private function getDefaultHttpOperations($resource): iterable
323+
{
324+
$post = new Post();
325+
if ($resource->getUriTemplate() && !$resource->getProvider()) {
326+
$post = $post->withProvider(CreateProvider::class);
327+
}
328+
329+
return [new Get(), new GetCollection(), $post, new Put(), new Patch(), new Delete()];
330+
}
315331
}

src/Metadata/Resource/Factory/LinkFactory.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Metadata\Resource\Factory;
1515

1616
use ApiPlatform\Api\ResourceClassResolverInterface;
17+
use ApiPlatform\Exception\RuntimeException;
1718
use ApiPlatform\Metadata\ApiResource;
1819
use ApiPlatform\Metadata\Link;
1920
use ApiPlatform\Metadata\Operation;
@@ -24,12 +25,28 @@
2425
/**
2526
* @internal
2627
*/
27-
final class LinkFactory implements LinkFactoryInterface
28+
final class LinkFactory implements LinkFactoryInterface, PropertyLinkFactoryInterface
2829
{
2930
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceClassResolverInterface $resourceClassResolver)
3031
{
3132
}
3233

34+
/**
35+
* {@inheritdoc}
36+
*/
37+
public function createLinkFromProperty(ApiResource|Operation $operation, string $property): Link
38+
{
39+
$metadata = $this->propertyMetadataFactory->create($resourceClass = $operation->getClass(), $property);
40+
$relationClass = $this->getPropertyClassType($metadata->getBuiltinTypes());
41+
if (!$relationClass) {
42+
throw new RuntimeException(sprintf('We could not find a class matching the uriVariable "%s" on "%s".', $property, $resourceClass));
43+
}
44+
45+
$identifiers = $this->resourceClassResolver->isResourceClass($relationClass) ? $this->getIdentifiersFromResourceClass($relationClass) : ['id'];
46+
47+
return new Link(fromClass: $relationClass, toProperty: $property, identifiers: $identifiers, parameterName: $property);
48+
}
49+
3350
/**
3451
* {@inheritdoc}
3552
*/
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Metadata\Resource\Factory;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Link;
18+
use ApiPlatform\Metadata\Operation;
19+
20+
/**
21+
* @internal
22+
*/
23+
interface PropertyLinkFactoryInterface
24+
{
25+
/**
26+
* Create a link for a given property.
27+
*/
28+
public function createLinkFromProperty(ApiResource|Operation $operation, string $property): Link;
29+
}

src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ public function create(string $resourceClass): ResourceMetadataCollection
5353
/** @var HttpOperation */
5454
$operation = $this->configureUriVariables($operation);
5555

56-
if ($operation->getUriTemplate()) {
56+
if (
57+
$operation->getUriTemplate()
58+
&& !($operation->getExtraProperties()['generated_operation'] ?? false)
59+
) {
5760
$operation = $operation->withExtraProperties($operation->getExtraProperties() + ['user_defined_uri_template' => true]);
5861
if (!$operation->getName()) {
5962
$operation = $operation->withName($key);
@@ -90,12 +93,15 @@ public function create(string $resourceClass): ResourceMetadataCollection
9093

9194
private function generateUriTemplate(HttpOperation $operation): string
9295
{
93-
$uriTemplate = sprintf('/%s', $this->pathSegmentNameGenerator->getSegmentName($operation->getShortName()));
96+
$uriTemplate = $operation->getUriTemplate() ?? sprintf('/%s', $this->pathSegmentNameGenerator->getSegmentName($operation->getShortName()));
9497
$uriVariables = $operation->getUriVariables() ?? [];
9598

9699
if ($parameters = array_keys($uriVariables)) {
97100
foreach ($parameters as $parameterName) {
98-
$uriTemplate .= sprintf('/{%s}', $parameterName);
101+
$part = sprintf('/{%s}', $parameterName);
102+
if (false === strpos($uriTemplate, $part)) {
103+
$uriTemplate .= sprintf('/{%s}', $parameterName);
104+
}
99105
}
100106
}
101107

@@ -107,7 +113,10 @@ private function configureUriVariables(ApiResource|HttpOperation $operation): Ap
107113
// We will generate the collection route, don't initialize variables here
108114
if ($operation instanceof HttpOperation && (
109115
[] === $operation->getUriVariables() ||
110-
($operation instanceof CollectionOperationInterface && null === $operation->getUriTemplate())
116+
(
117+
$operation instanceof CollectionOperationInterface
118+
&& null === $operation->getUriTemplate()
119+
)
111120
)) {
112121
if (null === $operation->getUriVariables()) {
113122
return $operation;
@@ -152,7 +161,24 @@ private function configureUriVariables(ApiResource|HttpOperation $operation): Ap
152161
return $operation->withUriVariables($newUriVariables);
153162
}
154163

155-
return $operation;
164+
// When an operation is generated we need to find properties matching it's uri variables
165+
if (!($operation->getExtraProperties()['generated_operation'] ?? false) || !$this->linkFactory instanceof PropertyLinkFactoryInterface) {
166+
return $operation;
167+
}
168+
169+
$diff = array_diff($variables, array_keys($uriVariables));
170+
if (0 === \count($diff)) {
171+
return $operation;
172+
}
173+
174+
// We generated this operation but there're some missing identifiers
175+
$uriVariables = HttpOperation::METHOD_POST === $operation->getMethod() || $operation instanceof CollectionOperationInterface ? [] : $operation->getUriVariables();
176+
177+
foreach ($diff as $key) {
178+
$uriVariables[$key] = $this->linkFactory->createLinkFromProperty($operation, $key);
179+
}
180+
181+
return $operation->withUriVariables($uriVariables);
156182
}
157183

158184
private function normalizeUriVariables(ApiResource|HttpOperation $operation): ApiResource|HttpOperation

src/State/CreateProvider.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\State;
15+
16+
use ApiPlatform\Metadata\Get;
17+
use ApiPlatform\Metadata\HttpOperation;
18+
use ApiPlatform\Metadata\Link;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Post;
21+
use Symfony\Component\PropertyAccess\PropertyAccess;
22+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
23+
24+
/**
25+
* An ItemProvider for POST operations on generated subresources.
26+
*
27+
* @see ApiPlatform\Tests\Fixtures\TestBundle\Entity\SubresourceEmployee
28+
*
29+
* @author Antoine Bluchet <[email protected]>
30+
*
31+
* @experimental
32+
*/
33+
final class CreateProvider implements ProviderInterface
34+
{
35+
public function __construct(private ProviderInterface $decorated, private ?PropertyAccessorInterface $propertyAccessor = null)
36+
{
37+
$this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
38+
}
39+
40+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
41+
{
42+
if (!$uriVariables || !$operation instanceof HttpOperation || null !== $operation->getController()) {
43+
return $this->decorated->provide($operation, $uriVariables, $context);
44+
}
45+
46+
$operationUriVariables = $operation->getUriVariables();
47+
$relationClass = current($operationUriVariables)->getFromClass();
48+
$key = key($operationUriVariables);
49+
$relationUriVariables = [];
50+
51+
foreach ($operationUriVariables as $parameterName => $value) {
52+
if ($key === $parameterName) {
53+
$relationUriVariables['id'] = new Link(identifiers: $value->getIdentifiers(), fromClass: $value->getFromClass(), parameterName: $key);
54+
continue;
55+
}
56+
57+
$relationUriVariables[$parameterName] = $value;
58+
}
59+
60+
$relation = $this->decorated->provide(new Get(uriVariables: $relationUriVariables, class: $relationClass), $uriVariables);
61+
$resource = new ($operation->getClass());
62+
$this->propertyAccessor->setValue($resource, $key, $relation);
63+
64+
return $resource;
65+
}
66+
}

src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
<tag name="api_platform.state_provider" priority="-100" />
139139
</service>
140140
<service id="api_platform.doctrine_mongodb.odm.state.item_provider" alias="ApiPlatform\Doctrine\Odm\State\ItemProvider" />
141+
<service id="api_platform.state.item_provider" alias="ApiPlatform\Doctrine\Odm\State\ItemProvider" />
141142

142143
<service id="api_platform.doctrine.odm.metadata.resource.metadata_collection_factory" class="ApiPlatform\Doctrine\Odm\Metadata\Resource\DoctrineMongoDbOdmResourceCollectionMetadataFactory" decorates="api_platform.metadata.resource.metadata_collection_factory" decoration-priority="40">
143144
<argument type="service" id="doctrine_mongodb" />

src/Symfony/Bundle/Resources/config/doctrine_orm.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@
146146
<tag name="api_platform.state_provider" priority="-100" />
147147
</service>
148148
<service id="api_platform.doctrine.orm.state.item_provider" alias="ApiPlatform\Doctrine\Orm\State\ItemProvider" />
149+
<service id="api_platform.state.item_provider" alias="ApiPlatform\Doctrine\Orm\State\ItemProvider" />
149150

150151
<service id="api_platform.doctrine.orm.search_filter" class="ApiPlatform\Doctrine\Orm\Filter\SearchFilter" public="false" abstract="true">
151152
<argument type="service" id="doctrine" />

src/Symfony/Bundle/Resources/config/elasticsearch.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107

108108
<tag name="api_platform.state_provider" priority="-100" />
109109
</service>
110+
<service id="api_platform.state.item_provider" alias="ApiPlatform\Elasticsearch\State\ItemProvider" />
110111

111112
<service id="ApiPlatform\Elasticsearch\State\CollectionProvider" class="ApiPlatform\Elasticsearch\State\CollectionProvider" public="false">
112113
<argument type="service" id="api_platform.elasticsearch.client" />

0 commit comments

Comments
 (0)