Skip to content

feat: BackedEnum resources #6309

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,11 @@ public function create(string $resourceClass, string $property, array $options =
return $this->handleNotFound($parentPropertyMetadata, $resourceClass, $property);
}

if ($reflectionEnum) {
if ($reflectionEnum->hasCase($property)) {
$reflectionCase = $reflectionEnum->getCase($property);
if ($attributes = $reflectionCase->getAttributes(ApiProperty::class)) {
return $this->createMetadata($attributes[0]->newInstance(), $parentPropertyMetadata);
}
if ($reflectionEnum && $reflectionEnum->hasCase($property)) {
$reflectionCase = $reflectionEnum->getCase($property);
if ($attributes = $reflectionCase->getAttributes(ApiProperty::class)) {
return $this->createMetadata($attributes[0]->newInstance(), $parentPropertyMetadata);
}

return $this->handleNotFound($parentPropertyMetadata, $resourceClass, $property);
}

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

foreach (array_merge(Reflection::ACCESSOR_PREFIXES, Reflection::MUTATOR_PREFIXES) as $prefix) {
$methodName = $prefix.ucfirst($property);
if (!$reflectionClass->hasMethod($methodName)) {
if (!$reflectionClass->hasMethod($methodName) && !$reflectionEnum?->hasMethod($methodName)) {
continue;
}

$reflectionMethod = $reflectionClass->getMethod($methodName);
$reflectionMethod = $reflectionClass->hasMethod($methodName) ? $reflectionClass->getMethod($methodName) : $reflectionEnum?->getMethod($methodName);
if (!$reflectionMethod->isPublic()) {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Metadata\Resource\Factory;

use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;

/**
* Triggers resource deprecations.
*
* @internal
*/
final class BackedEnumResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
{
public const PROVIDER = 'api_platform.state_provider.backed_enum';

public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated)
{
}

public function create(string $resourceClass): ResourceMetadataCollection
{
$resourceMetadataCollection = $this->decorated->create($resourceClass);
if (!is_a($resourceClass, \BackedEnum::class, true)) {
return $resourceMetadataCollection;
}

foreach ($resourceMetadataCollection as $i => $resourceMetadata) {
$newOperations = [];
foreach ($resourceMetadata->getOperations() as $operationName => $operation) {
$newOperations[$operationName] = $operation;

if (null !== $operation->getProvider()) {
continue;
}

$newOperations[$operationName] = $operation->withProvider(self::PROVIDER);
}

$newGraphQlOperations = [];
foreach ($resourceMetadata->getGraphQlOperations() as $operationName => $operation) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not work if there are no GraphQlOperations

$newGraphQlOperations[$operationName] = $operation;

if (null !== $operation->getProvider()) {
continue;
}

$newGraphQlOperations[$operationName] = $operation->withProvider(self::PROVIDER);
}

$resourceMetadataCollection[$i] = $resourceMetadata->withOperations(new Operations($newOperations))->withGraphQlOperations($newGraphQlOperations);
}

return $resourceMetadataCollection;
}
}
7 changes: 7 additions & 0 deletions src/Metadata/Resource/Factory/LinkFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ public function createLinksFromIdentifiers(Metadata $operation): array

$link = (new Link())->withFromClass($resourceClass)->withIdentifiers($identifiers);
$parameterName = $identifiers[0];
if ('value' === $parameterName && enum_exists($resourceClass)) {
$parameterName = 'id';
}

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

if (!$hasIdProperty && !$identifiers && enum_exists($resourceClass)) {
return ['value'];
}

return $identifiers;
}

Expand Down
7 changes: 6 additions & 1 deletion src/Metadata/Resource/Factory/OperationDefaultsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ private function getResourceWithDefaults(string $resourceClass, string $shortNam

private function getDefaultHttpOperations($resource): iterable
{
if (enum_exists($resource->getClass())) {
return new Operations([new GetCollection(paginationEnabled: false), new Get()]);
}

if (($defaultOperations = $this->defaults['operations'] ?? null) && null === $resource->getOperations()) {
$operations = [];

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

private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource
{
$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')];
$graphQlOperations = [];
foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $operation) {
foreach ($operations as $operation) {
[$key, $operation] = $this->getOperationWithDefaults($resource, $operation);
$graphQlOperations[$key] = $operation;
}
Expand Down
69 changes: 69 additions & 0 deletions src/State/Provider/BackedEnumProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\State\Provider;

use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

final class BackedEnumProvider implements ProviderInterface
{
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$resourceClass = $operation->getClass();
if (!$resourceClass || !is_a($resourceClass, \BackedEnum::class, true)) {
throw new RuntimeException('This resource is not an enum');
}

if ($operation instanceof CollectionOperationInterface) {
return $resourceClass::cases();
}

$id = $uriVariables['id'] ?? null;
if (null === $id) {
throw new NotFoundHttpException('Not Found');
}

if ($enum = $this->resolveEnum($resourceClass, $id)) {
return $enum;
}

throw new NotFoundHttpException('Not Found');
}

/**
* @param class-string $resourceClass
*/
private function resolveEnum(string $resourceClass, string|int $id): ?\BackedEnum
{
$reflectEnum = new \ReflectionEnum($resourceClass);
$type = (string) $reflectEnum->getBackingType();

if ('int' === $type) {
if (!is_numeric($id)) {
return null;
}
$enum = $resourceClass::tryFrom((int) $id);
} else {
$enum = $resourceClass::tryFrom($id);
}

// @deprecated enums will be indexable only by value in 4.0
$enum ??= array_reduce($resourceClass::cases(), static fn ($c, \BackedEnum $case) => $id === $case->name ? $case : $c, null);

return $enum;
}
}
4 changes: 4 additions & 0 deletions src/Symfony/Bundle/Resources/config/metadata/resource.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory.not_exposed_operation.inner" />
</service>

<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">
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory.backed_enum.inner" />
</service>

<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">
<argument type="service" id="api_platform.metadata.resource.link_factory" />
<argument type="service" id="api_platform.path_segment_name_generator" />
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Bundle/Resources/config/state/provider.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
<argument type="service" id="translator" on-invalid="null" />
</service>

<service id="api_platform.state_provider.backed_enum" class="ApiPlatform\State\Provider\BackedEnumProvider">
<tag name="api_platform.state_provider" key="ApiPlatform\State\Provider\BackedEnumProvidevr" />
<tag name="api_platform.state_provider" key="api_platform.state_provider.backed_enum" />
</service>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the record @GwendolenLynch instead of decorating the provider chain, I choose to declare this as an operation provider instead. This will be called inside the ReadProvider:

try {
$data = $this->provider->provide($operation, $uriVariables, $context);
} catch (ProviderNotFoundException $e) {
$data = null;
}

It's easier for userland to decorate our enum provider (or any item provider) instead of hooking into the main decoration chain.


<service id="api_platform.error_listener" class="ApiPlatform\Symfony\EventListener\ErrorListener">
<argument key="$controller">api_platform.symfony.main_controller</argument>
<argument key="$logger" type="service" id="logger" on-invalid="null" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Tests\Fixtures\TestBundle\ApiResource;

use ApiPlatform\Metadata\ApiResource;

#[ApiResource]
enum BackedEnumIntegerResource: int
{
case Yes = 1;
case No = 2;
case Maybe = 3;

public function getDescription(): string
{
return match ($this) {
self::Yes => 'We say yes',
self::No => 'Computer says no',
self::Maybe => 'Let me think about it',
};
}
}
33 changes: 33 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/BackedEnumStringResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Tests\Fixtures\TestBundle\ApiResource;

use ApiPlatform\Metadata\ApiResource;

#[ApiResource]
enum BackedEnumStringResource: string
{
case Yes = 'yes';
case No = 'no';
case Maybe = 'maybe';

public function getDescription(): string
{
return match ($this) {
self::Yes => 'We say yes',
self::No => 'Computer says no',
self::Maybe => 'Let me think about it',
};
}
}
14 changes: 11 additions & 3 deletions tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,18 @@
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\GraphQl\Query;

#[ApiResource(normalizationContext: ['groups' => ['get']])]
#[GetCollection(provider: Availability::class.'::getCases')]
#[Get(provider: Availability::class.'::getCase')]
#[ApiResource(
normalizationContext: ['groups' => ['get']],
operations: [
new GetCollection(provider: Availability::class.'::getCases'),
new Get(provider: Availability::class.'::getCase'),
],
graphQlOperations: [
new Query(provider: Availability::class.'getCase'),
]
)]
enum Availability: int
{
use BackedEnumTrait;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
#[Get(provider: AvailabilityStatus::class.'::getCase')]
enum AvailabilityStatus: string
{
use BackedEnumTrait;
use BackedEnumStringTrait;

case Pending = 'pending';
case Reviewed = 'reviewed';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Tests\Fixtures\TestBundle\ApiResource\Issue6264;

use ApiPlatform\Metadata\Operation;
use Symfony\Component\Serializer\Attribute\Groups;

trait BackedEnumStringTrait
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For further readers, this was duplicated from the BackedEnumTrait as graphql "guesses" the type from an union:

// guess union/intersect types: check each type until finding a valid one
foreach ($propertyTypes as $propertyType) {
if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) {
$fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration;
// stop at the first valid type
break;
}
}

I'm not a huge fan, ideally we should provide a fix at some point there, even though when representing resources one should avoid scalar union types in my opinion.

{
public static function values(): array
{
return array_map(static fn (\BackedEnum $feature) => $feature->value, self::cases());
}

public function getId(): string
{
return $this->value;
}

#[Groups(['get'])]
public function getValue(): string
{
return $this->value;
}

public static function getCases(): array
{
return self::cases();
}

/**
* @param array<string, string> $uriVariables
*/
public static function getCase(Operation $operation, array $uriVariables): ?self
{
return array_reduce(self::cases(), static fn ($c, \BackedEnum $case) => $case->value == $uriVariables['id'] ? $case : $c, null);
}
}
Loading
Loading