Skip to content

Commit 63ccfd5

Browse files
feat: BackedEnum resources (#6309)
* fix(metadata): Only add GET operations for enums when ApiResource doesn't specify operations * feat(state): backed enum provider * fix(metadata): enum resource identifier default to value * fix(metadata): get method metadata for BackedEnums * test: resource with enum properties schema * what I would like * test: backed enums --------- Co-authored-by: soyuka <soyuka@users.noreply.github.com>
1 parent c216731 commit 63ccfd5

19 files changed

+912
-60
lines changed

src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,11 @@ public function create(string $resourceClass, string $property, array $options =
5959
return $this->handleNotFound($parentPropertyMetadata, $resourceClass, $property);
6060
}
6161

62-
if ($reflectionEnum) {
63-
if ($reflectionEnum->hasCase($property)) {
64-
$reflectionCase = $reflectionEnum->getCase($property);
65-
if ($attributes = $reflectionCase->getAttributes(ApiProperty::class)) {
66-
return $this->createMetadata($attributes[0]->newInstance(), $parentPropertyMetadata);
67-
}
62+
if ($reflectionEnum && $reflectionEnum->hasCase($property)) {
63+
$reflectionCase = $reflectionEnum->getCase($property);
64+
if ($attributes = $reflectionCase->getAttributes(ApiProperty::class)) {
65+
return $this->createMetadata($attributes[0]->newInstance(), $parentPropertyMetadata);
6866
}
69-
70-
return $this->handleNotFound($parentPropertyMetadata, $resourceClass, $property);
7167
}
7268

7369
if ($reflectionClass->hasProperty($property)) {
@@ -79,11 +75,11 @@ public function create(string $resourceClass, string $property, array $options =
7975

8076
foreach (array_merge(Reflection::ACCESSOR_PREFIXES, Reflection::MUTATOR_PREFIXES) as $prefix) {
8177
$methodName = $prefix.ucfirst($property);
82-
if (!$reflectionClass->hasMethod($methodName)) {
78+
if (!$reflectionClass->hasMethod($methodName) && !$reflectionEnum?->hasMethod($methodName)) {
8379
continue;
8480
}
8581

86-
$reflectionMethod = $reflectionClass->getMethod($methodName);
82+
$reflectionMethod = $reflectionClass->hasMethod($methodName) ? $reflectionClass->getMethod($methodName) : $reflectionEnum?->getMethod($methodName);
8783
if (!$reflectionMethod->isPublic()) {
8884
continue;
8985
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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\Resource\Factory;
15+
16+
use ApiPlatform\Metadata\Operations;
17+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
18+
19+
/**
20+
* Triggers resource deprecations.
21+
*
22+
* @internal
23+
*/
24+
final class BackedEnumResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
25+
{
26+
public const PROVIDER = 'api_platform.state_provider.backed_enum';
27+
28+
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated)
29+
{
30+
}
31+
32+
public function create(string $resourceClass): ResourceMetadataCollection
33+
{
34+
$resourceMetadataCollection = $this->decorated->create($resourceClass);
35+
if (!is_a($resourceClass, \BackedEnum::class, true)) {
36+
return $resourceMetadataCollection;
37+
}
38+
39+
foreach ($resourceMetadataCollection as $i => $resourceMetadata) {
40+
$newOperations = [];
41+
foreach ($resourceMetadata->getOperations() as $operationName => $operation) {
42+
$newOperations[$operationName] = $operation;
43+
44+
if (null !== $operation->getProvider()) {
45+
continue;
46+
}
47+
48+
$newOperations[$operationName] = $operation->withProvider(self::PROVIDER);
49+
}
50+
51+
$newGraphQlOperations = [];
52+
foreach ($resourceMetadata->getGraphQlOperations() as $operationName => $operation) {
53+
$newGraphQlOperations[$operationName] = $operation;
54+
55+
if (null !== $operation->getProvider()) {
56+
continue;
57+
}
58+
59+
$newGraphQlOperations[$operationName] = $operation->withProvider(self::PROVIDER);
60+
}
61+
62+
$resourceMetadataCollection[$i] = $resourceMetadata->withOperations(new Operations($newOperations))->withGraphQlOperations($newGraphQlOperations);
63+
}
64+
65+
return $resourceMetadataCollection;
66+
}
67+
}

src/Metadata/Resource/Factory/LinkFactory.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ public function createLinksFromIdentifiers(Metadata $operation): array
5959

6060
$link = (new Link())->withFromClass($resourceClass)->withIdentifiers($identifiers);
6161
$parameterName = $identifiers[0];
62+
if ('value' === $parameterName && enum_exists($resourceClass)) {
63+
$parameterName = 'id';
64+
}
6265

6366
if (1 < \count($identifiers)) {
6467
$parameterName = 'id';
@@ -155,6 +158,10 @@ private function getIdentifiersFromResourceClass(string $resourceClass): array
155158
return ['id'];
156159
}
157160

161+
if (!$hasIdProperty && !$identifiers && enum_exists($resourceClass)) {
162+
return ['value'];
163+
}
164+
158165
return $identifiers;
159166
}
160167

src/Metadata/Resource/Factory/OperationDefaultsTrait.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ private function getResourceWithDefaults(string $resourceClass, string $shortNam
8888

8989
private function getDefaultHttpOperations($resource): iterable
9090
{
91+
if (enum_exists($resource->getClass())) {
92+
return new Operations([new GetCollection(paginationEnabled: false), new Get()]);
93+
}
94+
9195
if (($defaultOperations = $this->defaults['operations'] ?? null) && null === $resource->getOperations()) {
9296
$operations = [];
9397

@@ -108,8 +112,9 @@ private function getDefaultHttpOperations($resource): iterable
108112

109113
private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource
110114
{
115+
$operations = enum_exists($resource->getClass()) ? [new QueryCollection(paginationEnabled: false), new Query()] : [new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')];
111116
$graphQlOperations = [];
112-
foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $operation) {
117+
foreach ($operations as $operation) {
113118
[$key, $operation] = $this->getOperationWithDefaults($resource, $operation);
114119
$graphQlOperations[$key] = $operation;
115120
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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\State\Provider;
15+
16+
use ApiPlatform\Metadata\CollectionOperationInterface;
17+
use ApiPlatform\Metadata\Exception\RuntimeException;
18+
use ApiPlatform\Metadata\Operation;
19+
use ApiPlatform\State\ProviderInterface;
20+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
21+
22+
final class BackedEnumProvider implements ProviderInterface
23+
{
24+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
25+
{
26+
$resourceClass = $operation->getClass();
27+
if (!$resourceClass || !is_a($resourceClass, \BackedEnum::class, true)) {
28+
throw new RuntimeException('This resource is not an enum');
29+
}
30+
31+
if ($operation instanceof CollectionOperationInterface) {
32+
return $resourceClass::cases();
33+
}
34+
35+
$id = $uriVariables['id'] ?? null;
36+
if (null === $id) {
37+
throw new NotFoundHttpException('Not Found');
38+
}
39+
40+
if ($enum = $this->resolveEnum($resourceClass, $id)) {
41+
return $enum;
42+
}
43+
44+
throw new NotFoundHttpException('Not Found');
45+
}
46+
47+
/**
48+
* @param class-string $resourceClass
49+
*/
50+
private function resolveEnum(string $resourceClass, string|int $id): ?\BackedEnum
51+
{
52+
$reflectEnum = new \ReflectionEnum($resourceClass);
53+
$type = (string) $reflectEnum->getBackingType();
54+
55+
if ('int' === $type) {
56+
if (!is_numeric($id)) {
57+
return null;
58+
}
59+
$enum = $resourceClass::tryFrom((int) $id);
60+
} else {
61+
$enum = $resourceClass::tryFrom($id);
62+
}
63+
64+
// @deprecated enums will be indexable only by value in 4.0
65+
$enum ??= array_reduce($resourceClass::cases(), static fn ($c, \BackedEnum $case) => $id === $case->name ? $case : $c, null);
66+
67+
return $enum;
68+
}
69+
}

src/Symfony/Bundle/Resources/config/metadata/resource.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory.not_exposed_operation.inner" />
3030
</service>
3131

32+
<service id="api_platform.metadata.resource.metadata_collection_factory.backed_enum" class="ApiPlatform\Metadata\Resource\Factory\BackedEnumResourceMetadataCollectionFactory" decorates="api_platform.metadata.resource.metadata_collection_factory" decoration-priority="500" public="false">
33+
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory.backed_enum.inner" />
34+
</service>
35+
3236
<service id="api_platform.metadata.resource.metadata_collection_factory.uri_template" class="ApiPlatform\Metadata\Resource\Factory\UriTemplateResourceMetadataCollectionFactory" decorates="api_platform.metadata.resource.metadata_collection_factory" decoration-priority="500" public="false">
3337
<argument type="service" id="api_platform.metadata.resource.link_factory" />
3438
<argument type="service" id="api_platform.path_segment_name_generator" />

src/Symfony/Bundle/Resources/config/state/provider.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
<argument type="service" id="translator" on-invalid="null" />
2626
</service>
2727

28+
<service id="api_platform.state_provider.backed_enum" class="ApiPlatform\State\Provider\BackedEnumProvider">
29+
<tag name="api_platform.state_provider" key="ApiPlatform\State\Provider\BackedEnumProvidevr" />
30+
<tag name="api_platform.state_provider" key="api_platform.state_provider.backed_enum" />
31+
</service>
32+
2833
<service id="api_platform.error_listener" class="ApiPlatform\Symfony\EventListener\ErrorListener">
2934
<argument key="$controller">api_platform.symfony.main_controller</argument>
3035
<argument key="$logger" type="service" id="logger" on-invalid="null" />
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
18+
#[ApiResource]
19+
enum BackedEnumIntegerResource: int
20+
{
21+
case Yes = 1;
22+
case No = 2;
23+
case Maybe = 3;
24+
25+
public function getDescription(): string
26+
{
27+
return match ($this) {
28+
self::Yes => 'We say yes',
29+
self::No => 'Computer says no',
30+
self::Maybe => 'Let me think about it',
31+
};
32+
}
33+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
18+
#[ApiResource]
19+
enum BackedEnumStringResource: string
20+
{
21+
case Yes = 'yes';
22+
case No = 'no';
23+
case Maybe = 'maybe';
24+
25+
public function getDescription(): string
26+
{
27+
return match ($this) {
28+
self::Yes => 'We say yes',
29+
self::No => 'Computer says no',
30+
self::Maybe => 'Let me think about it',
31+
};
32+
}
33+
}

tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@
1616
use ApiPlatform\Metadata\ApiResource;
1717
use ApiPlatform\Metadata\Get;
1818
use ApiPlatform\Metadata\GetCollection;
19+
use ApiPlatform\Metadata\GraphQl\Query;
1920

20-
#[ApiResource(normalizationContext: ['groups' => ['get']])]
21-
#[GetCollection(provider: Availability::class.'::getCases')]
22-
#[Get(provider: Availability::class.'::getCase')]
21+
#[ApiResource(
22+
normalizationContext: ['groups' => ['get']],
23+
operations: [
24+
new GetCollection(provider: Availability::class.'::getCases'),
25+
new Get(provider: Availability::class.'::getCase'),
26+
],
27+
graphQlOperations: [
28+
new Query(provider: Availability::class.'getCase'),
29+
]
30+
)]
2331
enum Availability: int
2432
{
2533
use BackedEnumTrait;

tests/Fixtures/TestBundle/ApiResource/Issue6264/AvailabilityStatus.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
#[Get(provider: AvailabilityStatus::class.'::getCase')]
2323
enum AvailabilityStatus: string
2424
{
25-
use BackedEnumTrait;
25+
use BackedEnumStringTrait;
2626

2727
case Pending = 'pending';
2828
case Reviewed = 'reviewed';
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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\Tests\Fixtures\TestBundle\ApiResource\Issue6264;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use Symfony\Component\Serializer\Attribute\Groups;
18+
19+
trait BackedEnumStringTrait
20+
{
21+
public static function values(): array
22+
{
23+
return array_map(static fn (\BackedEnum $feature) => $feature->value, self::cases());
24+
}
25+
26+
public function getId(): string
27+
{
28+
return $this->value;
29+
}
30+
31+
#[Groups(['get'])]
32+
public function getValue(): string
33+
{
34+
return $this->value;
35+
}
36+
37+
public static function getCases(): array
38+
{
39+
return self::cases();
40+
}
41+
42+
/**
43+
* @param array<string, string> $uriVariables
44+
*/
45+
public static function getCase(Operation $operation, array $uriVariables): ?self
46+
{
47+
return array_reduce(self::cases(), static fn ($c, \BackedEnum $case) => $case->value == $uriVariables['id'] ? $case : $c, null);
48+
}
49+
}

0 commit comments

Comments
 (0)