Skip to content

Commit e4d9ddd

Browse files
committed
feat(metadata): cascade resource to operation
1 parent 4ecde01 commit e4d9ddd

10 files changed

+159
-32
lines changed

src/Metadata/ApiResource.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@
3131
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
3232
class ApiResource extends Metadata
3333
{
34+
use CascadeToOperationsTrait;
3435
use WithResourceTrait;
3536

37+
/**
38+
* @var Operations<HttpOperation>
39+
*/
3640
protected ?Operations $operations;
3741

3842
/**

src/Metadata/CascadeFromResource.php

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 <dunglas@gmail.com>
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;
15+
16+
/**
17+
* @internal
18+
*
19+
* @phpstan-require-extends ApiResource
20+
*/
21+
trait CascadeFromResource
22+
{
23+
use WithResourceTrait;
24+
25+
public function cascadeFromResource(ApiResource $apiResource, array $ignoredOptions = []): static
26+
{
27+
return $this->copyFrom($apiResource, $ignoredOptions);
28+
}
29+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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;
15+
16+
use ApiPlatform\Metadata\Exception\RuntimeException;
17+
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
18+
19+
/**
20+
* @internal
21+
*
22+
* @phpstan-require-extends ApiResource
23+
*/
24+
trait CascadeToOperationsTrait
25+
{
26+
public function cascadeToOperations(): static
27+
{
28+
if (!$this instanceof ApiResource) {
29+
throw new RuntimeException('Not an API resource');
30+
}
31+
32+
if (!($operations = $this->getOperations() ?? [])) {
33+
return $this;
34+
}
35+
36+
$operations = $this->getMutatedOperations($operations, $this);
37+
38+
return (clone $this)->withOperations(new Operations($operations));
39+
}
40+
41+
public function cascadeToGraphQlOperations(): static
42+
{
43+
if (!$this instanceof ApiResource) {
44+
throw new RuntimeException('Not an API resource');
45+
}
46+
47+
if (!($operations = $this->getGraphQlOperations() ?? [])) {
48+
return $this;
49+
}
50+
51+
return (clone $this)->withGraphQlOperations($this->getMutatedOperations($operations, $this));
52+
}
53+
54+
/**
55+
* @param Operations<HttpOperation>|list<GraphQlOperation> $operations
56+
*
57+
* @return array[string, HttpOperation]|list<GraphQlOperation>
58+
*/
59+
private function getMutatedOperations(iterable $operations, ApiResource $apiResource): iterable
60+
{
61+
$modifiedOperations = [];
62+
foreach ($operations as $key => $operation) {
63+
$modifiedOperations[$key] = $operation->cascadeFromResource($apiResource);
64+
}
65+
66+
return $modifiedOperations;
67+
}
68+
}

src/Metadata/Operation.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
*/
2121
abstract class Operation extends Metadata
2222
{
23+
use CascadeFromResource;
2324
use WithResourceTrait;
2425

2526
/**
@@ -861,7 +862,7 @@ class: $class,
861862
);
862863
}
863864

864-
public function withOperation($operation)
865+
public function withOperation(self $operation): static
865866
{
866867
return $this->copyFrom($operation);
867868
}

src/Metadata/Operations.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@
1515

1616
/**
1717
* An Operation dictionnary.
18+
*
19+
* @template T of Operation
1820
*/
1921
final class Operations implements \IteratorAggregate, \Countable
2022
{
23+
/**
24+
* @var array{string, T}
25+
*/
2126
private array $operations = [];
2227

2328
/**
@@ -42,6 +47,9 @@ public function __construct(array $operations = [])
4247
$this->sort();
4348
}
4449

50+
/**
51+
* @return Traversable<string, T>
52+
*/
4553
public function getIterator(): \Traversable
4654
{
4755
return (function (): \Generator {
@@ -101,4 +109,14 @@ public function sort(): self
101109

102110
return $this;
103111
}
112+
113+
public function keys(): array
114+
{
115+
$a = [];
116+
foreach ($this->operations as [$name, $op]) {
117+
$a[] = $name;
118+
}
119+
120+
return $a;
121+
}
104122
}

src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,7 @@ public function create(string $resourceClass): ResourceMetadataCollection
7070

7171
// No item operation has been found on all resources for resource class: generate one on the last resource
7272
// Helpful to generate an IRI for a resource without declaring the Get operation
73-
/** @var HttpOperation $operation */
74-
[$key, $operation] = $this->getOperationWithDefaults(resource: $resource, operation: new NotExposed(), generated: true, ignoredOptions: ['uriTemplate', 'uriVariables']); // @phpstan-ignore-line $resource is defined if count > 0
73+
[$key, $operation] = $this->getOperationWithDefaults($resource, new NotExposed(), true, ['uriTemplate', 'uriVariables']); // @phpstan-ignore-line $resource is defined if count > 0
7574

7675
if (!$this->linkFactory->createLinksFromIdentifiers($operation)) {
7776
$operation = $operation->withUriTemplate(self::$skolemUriTemplate);

src/Metadata/Resource/Factory/OperationDefaultsTrait.php

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
use ApiPlatform\Validator\Exception\ValidationException;
3737
use Psr\Log\LoggerInterface;
3838

39+
/**
40+
* @internal since api-platform 4.2
41+
*/
3942
trait OperationDefaultsTrait
4043
{
4144
private CamelCaseToSnakeCaseNameConverter $camelCaseToSnakeCaseNameConverter;
@@ -96,7 +99,10 @@ private function getResourceWithDefaults(string $resourceClass, string $shortNam
9699
return $this->addGlobalDefaults($resource);
97100
}
98101

99-
private function getDefaultHttpOperations($resource): iterable
102+
/**
103+
* @return Operations<HttpOperation>|array<int,HttpOperation>
104+
*/
105+
private function getDefaultHttpOperations(ApiResource $resource): iterable
100106
{
101107
if (enum_exists($resource->getClass())) {
102108
return new Operations([new GetCollection(paginationEnabled: false), new Get()]);
@@ -175,30 +181,14 @@ private function completeGraphQlOperations(ApiResource $resource): ApiResource
175181
return $resource->withGraphQlOperations($graphQlOperations);
176182
}
177183

184+
/**
185+
* @param list<string> $ignoredOptions
186+
*
187+
* @return array<int,mixed>
188+
*/
178189
private function getOperationWithDefaults(ApiResource $resource, Operation $operation, bool $generated = false, array $ignoredOptions = []): array
179190
{
180-
// Inherit from resource defaults
181-
foreach (get_class_methods($resource) as $methodName) {
182-
if (!str_starts_with($methodName, 'get')) {
183-
continue;
184-
}
185-
186-
if (\in_array(lcfirst(substr($methodName, 3)), $ignoredOptions, true)) {
187-
continue;
188-
}
189-
190-
if (!method_exists($operation, $methodName) || null !== $operation->{$methodName}()) {
191-
continue;
192-
}
193-
194-
if (null === ($value = $resource->{$methodName}())) {
195-
continue;
196-
}
197-
198-
$operation = $operation->{'with'.substr($methodName, 3)}($value);
199-
}
200-
201-
$operation = $operation->withExtraProperties(array_merge(
191+
$operation = $operation->cascadeFromResource($resource, $ignoredOptions)->withExtraProperties(array_merge(
202192
$resource->getExtraProperties(),
203193
$operation->getExtraProperties(),
204194
$generated ? ['generated_operation' => true] : []
@@ -229,6 +219,7 @@ private function getOperationWithDefaults(ApiResource $resource, Operation $oper
229219
}
230220

231221
$operationName = $operation->getName() ?? $this->getDefaultOperationName($operation, $resource->getClass());
222+
// $operation = $operation->withName($operationName);
232223

233224
return [
234225
$operationName,
@@ -249,6 +240,7 @@ private function getDefaultOperationName(HttpOperation $operation, string $resou
249240
'_api_%s_%s%s',
250241
$path ?: ($operation->getShortName() ?? $this->getDefaultShortname($resourceClass)),
251242
strtolower($operation->getMethod()),
252-
$operation instanceof CollectionOperationInterface ? '_collection' : '');
243+
$operation instanceof CollectionOperationInterface ? '_collection' : ''
244+
);
253245
}
254246
}

src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,25 @@ class: AttributeResource::class,
7171
provider: AttributeResourceProvider::class,
7272
operations: [
7373
'_api_AttributeResource_get' => new Get(
74-
shortName: 'AttributeResource', class: AttributeResource::class, normalizationContext: ['skip_null_values' => true], priority: 1, provider: AttributeResourceProvider::class,
74+
shortName: 'AttributeResource',
75+
class: AttributeResource::class,
76+
normalizationContext: ['skip_null_values' => true],
77+
priority: 1,
78+
provider: AttributeResourceProvider::class,
7579
),
7680
'_api_AttributeResource_put' => new Put(
77-
shortName: 'AttributeResource', class: AttributeResource::class, normalizationContext: ['skip_null_values' => true], priority: 2, provider: AttributeResourceProvider::class,
81+
shortName: 'AttributeResource',
82+
class: AttributeResource::class,
83+
normalizationContext: ['skip_null_values' => true],
84+
priority: 2,
85+
provider: AttributeResourceProvider::class,
7886
),
7987
'_api_AttributeResource_delete' => new Delete(
80-
shortName: 'AttributeResource', class: AttributeResource::class, normalizationContext: ['skip_null_values' => true], priority: 3, provider: AttributeResourceProvider::class,
88+
shortName: 'AttributeResource',
89+
class: AttributeResource::class,
90+
normalizationContext: ['skip_null_values' => true],
91+
priority: 3,
92+
provider: AttributeResourceProvider::class,
8193
),
8294
],
8395
graphQlOperations: $this->getDefaultGraphqlOperations('AttributeResource', AttributeResource::class, AttributeResourceProvider::class)

src/Metadata/WithResourceTrait.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,19 @@
1313

1414
namespace ApiPlatform\Metadata;
1515

16+
/**
17+
* @internal since api-platform/metadata 4.2
18+
*/
1619
trait WithResourceTrait
1720
{
18-
protected function copyFrom(Metadata $resource): static
21+
protected function copyFrom(Metadata $resource, array $ignoredOptions = []): static
1922
{
2023
$self = clone $this;
2124
foreach (get_class_methods($resource) as $method) {
2225
if (
2326
method_exists($self, $method)
2427
&& preg_match('/^(?:get|is|can)(.*)/', (string) $method, $matches)
28+
&& (!$ignoredOptions || !\in_array(lcfirst($matches[1]), $ignoredOptions, true))
2529
&& null === $self->{$method}()
2630
&& null !== $val = $resource->{$method}()
2731
) {

tests/Fixtures/TestBundle/Entity/AttributeResource.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
#[Put]
3232
#[Delete]
3333
#[ApiResource(
34-
'/dummy/{dummyId}/attribute_resources/{identifier}{._format}',
34+
uriTemplate: '/dummy/{dummyId}/attribute_resources/{identifier}{._format}',
3535
inputFormats: ['json' => ['application/merge-patch+json']],
3636
status: 301,
3737
provider: AttributeResourceProvider::class,

0 commit comments

Comments
 (0)