Skip to content
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

Support for Enums #2254

Closed
ragboyjr opened this issue Oct 14, 2018 · 19 comments · Fixed by #5120
Closed

Support for Enums #2254

ragboyjr opened this issue Oct 14, 2018 · 19 comments · Fixed by #5120
Milestone

Comments

@ragboyjr
Copy link
Contributor

ragboyjr commented Oct 14, 2018

edit by soyuka
With PHP 8.1 enums coming to PHP we want to implement the following into API Platform:

end edit

This issue is more for documentation on how to include enums with API Platform and maybe some discussion on how this could potentially be brought into the core.

I've been able to implement enums by doing the following:

  1. Create a class which represents the enum, there is a base enum class that looks like this:

    abstract class Enum
    {
        protected static $allowedValues = [];
        protected $value;
    
        public function __construct($value) {
            if (!\in_array($value, static::$allowedValues)) {
                throw new \InvalidArgumentException("Invalid value '${value}' for enum. Allowed values are: " . \implode(', ', static::$allowedValues));
            }
    
            $this->value = $value;
        }
    
        public static function getAllValues() {
            return self::$allowedValues;
        }
    
        public function __invoke() {
            return $this->value;
        }
    
        public function __toString() {
            return (string) $this->value;
        }
    }

    where the subclass would look like:

    final class Direction extends Enum
    {
        const UP = 'up';
        const DOWN = 'down';
    
        protected static $allowedValues = [self::UP, self::DOWN];
    
        public static function up(): self {
            return new self(self::UP);
        }
        public static function down(): self {
            return new self(self::DOWN);
        }
    }
  2. Create and Register a custom doctrine type for the enum:

    abstract class EnumType extends StringType
    {
        public function convertToDatabaseValue($value, AbstractPlatform $platform) {
            if ($value instanceof Enum) {
                return (string) $value;
            }
    
            throw new \InvalidArgumentException('Value must be an Enum instance.');
        }
    
        public function requiresSQLCommentHint(AbstractPlatform $platform) {
            return false;
        }
    }
    
    final class DirectionEnumType extends EnumType
    {
        public function convertToPHPValue($value, AbstractPlatform $platform) {
            return new Direction($value);
        }
    
        public function getName() {
            return 'direction_enum';
        }
    }

    In symfony it would look like:

     doctrine:
       dbal:
         types:
           channel_enum:
             class: App\Doctrine\Type\DirectionEnumType
  3. Create a Normalizer for the enum and register it after the object normalizer

    class EnumNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface
    {
        /**
         * {@inheritdoc}
         */
        public function normalize($object, $format = null, array $context = array()) {
            /** @var Enum $object */
            return $object();
        }
    
        /**
         * {@inheritdoc}
         */
        public function supportsNormalization($data, $format = null) {
            return $data instanceof Enum;
        }
    
        /**
         * {@inheritdoc}
         */
        public function denormalize($data, $class, $format = null, array $context = array()) {
            return new $class($data);
        }
    
        /**
         * {@inheritdoc}
         */
        public function supportsDenormalization($data, $type, $format = null) {
            return is_subclass_of($type, Enum::class);
        }
    
        public function hasCacheableSupportsMethod(): bool {
            return __CLASS__ === \get_class($this); // copied from DateTimeNormalizer, not quite sure what this is for.
        }
    }
    services:
      App\Serializer\Normalizer\EnumNormalizer:
        tags:
          - {name: serializer.normalizer, priority: -916 }
  4. Create a trait and class to utilize the enum!

    trait WithDirection
    {
        /**
         * @ORM\Column(type="direction_enum")
         * @ApiProperty(swaggerContext={"enum"={"up", "down"}})
         */
        private $direction;
    
        public function getDirection(): Direction {
            return $this->direction;
        }
    }
    
    class AcmeEntity
    {
        use WithDirection;
    
        public function __construct(Direction $direction) {
            $this->direction = $direction;
        }
    }

With all of those steps in place, you should be able to have strict enum classes to use in PHP that are saved and retrieved from the db properly, documented in the swagger API, and are properly serialized/deserialized from a string to the proper enum class and back when using the API.

I'm not sure of the best way API Platform could simplify this process except for maybe providing the default enum implementation which would allow API Platform to include the enum normalizer and possibly the doctrine mapping types..?

@soullivaneuh
Copy link
Contributor

Not sure if anything can be simplify for a custom class considered as an enum.

At least, it may add support for an enum library like this wonderful one? https://github.com/greg0ire/enum :-)

@dunglas
Copy link
Member

dunglas commented Oct 17, 2018

The Schema Generator component already supports https://github.com/myclabs/php-enum, if we add support for an enum lib in core, it would be better to start with the same one for consistency.

Note: I've nothing against greg0ire's one!

@ragboyjr
Copy link
Contributor Author

Totally think we should be using a standard package like myclabs/php-enum, I happened to not in my app for other reasons.

@toofff
Copy link
Contributor

toofff commented Oct 17, 2018

You can also use DoctrineEnumType for the part on Doctrine

@soullivaneuh
Copy link
Contributor

@dunglas I didn't know about already enable support. So it makes sense to use the same library, indeed.

@alanpoulain
Copy link
Member

See also: https://github.com/elao/PhpEnums
It gives you all you want for API Platform:

  • a Doctrine type,
  • a validator,
  • a normalizer,
  • a Faker provider for Alice.

@Antarian
Copy link

@alanpoulain based on their readme, they have also added:
- An API Platform OpenApi/Swagger type for documentation generation.
Will know more when I will switch from myclabs implementation.

@soyuka
Copy link
Member

soyuka commented Feb 23, 2021

symfony/symfony#40241 waiting for this then we're adding them

@rkeet
Copy link

rkeet commented Jan 2, 2022

Been looking around, found this issue and that the Doctrine team is also working on support from the ORM itself. Thought you guys might like that here :) There's also an interesting discussion (leading to the earlier PR).

@rkeet
Copy link

rkeet commented Jan 8, 2022

Hi guys, ORM folks worked real quick. https://github.com/doctrine/orm/pull/9304/files is now merged.

Updated your composer dependency to: "doctrine/orm": "^2.11.x-dev", will let you get started with enums in ApiPlatform.

Bug that I've run into: item normalization does not recognize enums, so for POST / PUT methods using enums does not work out of the box.

Test Backed Enum:

enum Alpha2: string
{
    case EN = 'en';
    case NL = 'nl';
}

Entity using BackedEnum:

class Message implements Entity
{
    private int $id;
    public UuidInterface $uuid;

    public function __construct(
        public Alpha2 $language,
        public string $code,
        public string $text
    ) {
        $this->uuid = Uuid::uuid4();
    }
/// stuff
}

Doctrine Entity mapping:

<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
                  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                  xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
    <entity name="App\Message\Message" table="messages">
        <id name="id" type="integer">
            <generator strategy="IDENTITY"/>
            <options>
                <option name="unsigned">1</option>
            </options>
        </id>

        <field name="uuid" type="uuid" unique="true"/>
        <field name="language" type="string" length="2" enum-type="App\Language\Alpha2"/>
        <field name="code"/>
        <field name="text"/>

        <unique-constraints>
            <unique-constraint columns="language,code" name="unique_code_per_language" />
        </unique-constraints>
    </entity>
</doctrine-mapping>

(Notice in the above: <field name="language" type="string" length="2" enum-type="App\Language\Alpha2"/>)

When data is populated with fixtures, there is no problem. My assumption is because when doing a new Message() the $language must already be an enum Alpha2 instance, not a string.

When running requests of POST and PUT it fails on normalization. Beginning of output:

    │  JSON body:
    │  {
    │      "type": "https:\/\/tools.ietf.org\/html\/rfc2616#section-10",
    │      "title": "An error occurred",
    │      "detail": "Failed to denormalize attribute \"language\" value for class \"App\\Message\\Message\": Expected argument of type \"App\\
Language\\Alpha2\", \"string\" given at property path \"language\".",
    │      "trace": [
    │          {
    │              "namespace": "",
    │              "short_class": "",
    │              "class": "",
    │              "type": "",
    │              "function": "",
    │              "file": "\/var\/www\/html\/vendor\/symfony\/serializer\/Exception\/NotNormalizableValueException.php",
    │              "line": 31,
    │              "args": []
    │          },
    │          {
    │              "namespace": "Symfony\\Component\\Serializer\\Exception",
    │              "short_class": "NotNormalizableValueException",
    │              "class": "Symfony\\Component\\Serializer\\Exception\\NotNormalizableValueException",
    │              "type": "::",
    │              "function": "createForUnexpectedDataType",
    │              "file": "\/var\/www\/html\/vendor\/symfony\/serializer\/Normalizer\/AbstractObjectNormalizer.php",
    │              "line": 414,
    │              "args": [
    │                  [
    │                      "string",
    │                      "Failed to denormalize attribute \"language\" value for class \"App\\Message\\Message\": Expected argument of type \
"App\\Language\\Alpha2\", \"string\" given at property path \"language\"."
    │                  ],
    │                  [

// lots more, not adding value to above

I've got no solution at the moment, haven't debugged it. Saw Doctrine folks merge their thing and thought to immediately give it a whirl to see if it would work.

Hopefully this helps out. Might have a look later today (CET), but no promises ;)

@rkeet
Copy link

rkeet commented Jan 8, 2022

Been looking into the above, again, not sure if this will help ;)

I'm currently thinking the values not being set is due to the Symfony PropertyInfo bundle. Reasoning: handling a PUT or POST which receives a string value for an Enum, like I showed above, it eventually comes to ApiPlatform\Core\Serializer\AbstractItemNormalizer::createAttributeValue() (here)

On that line it executes: $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));, which results in:

image

(highlighted few of the properties in screenshot to show it concerns same requests as mentioned earlier)

I'm thinking that here there should have been recognition that the property on the resolved resource_class should have been of they Enum, but it comes back as string.

Hope it helps, I've got to do things now, not behind a computer :)

@jensstalder
Copy link

For me it appeared to all work. Except for denormalization.
Cannot assign int to property App\Entity\MessageRecipient::$role of type App\Enum\MessageRecipientRoleEnum

I got it working though by allowing int for the setter and handling that case:

public function setRole(int|MessageRecipientRoleEnum $role): self
   {
       if (is_int($role)) {
           $role = MessageRecipientRoleEnum::from($role);
       }
       $this->role = $role;

       return $this;
   }

But that is a bit of a hack, right?

@bpolaszek
Copy link
Contributor

Does (or will) the core provide built-in exposures or recommendations to expose enum cases? On the front-end side, cases could be renderered as <select> for instance, and I'm looking for a consistent way to grab enum cases from the API. Best could be an ApiResource annotation or similar, I assume.

@dunglas
Copy link
Member

dunglas commented Jan 26, 2022

Yes we'll definitely support enums out of the box (and also in Schema Generator). I cannot give an ETA yet however.

@jensstalder
Copy link

jensstalder commented Oct 5, 2022

Maybe this will help?

So for those who have been a bit frustrated that this is not probably supported yet. I seem to have found a workaround. Keep in mind that this will not create fancy enum objects and instead simply interprets the backing type. (Obviously only works for backed enums).

1. Create a EnumMetadataFactory.php: This will allow openAPI and (api platform for that matter) to generate proper types

services:
    App\ApiPlatform\EnumMetadataFactory:
        decorates: "api_platform.metadata.property.metadata_factory"
        arguments: ['@App\ApiPlatform\EnumMetadataFactory.inner']
        decoration_priority: 0
<?php

declare(strict_types=1);

namespace App\ApiPlatform;

use ApiPlatform\Core\Exception\PropertyNotFoundException;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use Symfony\Component\PropertyInfo\Type;

final class EnumMetadataFactory implements PropertyMetadataFactoryInterface
{
    private PropertyMetadataFactoryInterface|null $decorated;

    public function __construct(PropertyMetadataFactoryInterface $decorated = null)
    {
        $this->decorated = $decorated;
    }

    /**
     * {@inheritdoc}
     *
     * @param array<mixed> $options
     */
    public function create(string $resourceClass, string $property, array $options = []): ApiProperty
    {
        if (null === $this->decorated) {
            $propertyMetadata = new ApiProperty();
        } else {
            try {
                $propertyMetadata = $this->decorated->create($resourceClass, $property, $options);
            } catch (PropertyNotFoundException $propertyNotFoundException) {
                $propertyMetadata = new ApiProperty();
            }
        }

        if (!class_exists($resourceClass)) {
            return $propertyMetadata;
        }
        $reflectionClass = new \ReflectionClass($resourceClass);
        $defaultProperties = $reflectionClass->getDefaultProperties();

        // sets enum and description for openapi_context and graphql based on named enum type
        if (property_exists($resourceClass, $property)) {
            $reflectionProperty = $reflectionClass->getProperty($property);
            $propertyType = $reflectionProperty->getType();
            if ($propertyType instanceof \ReflectionNamedType) {
                $propertyClassName = $propertyType->getName();
                if (enum_exists($propertyClassName)) {
                    $descriptionParts = [];
                    if ($propertyMetadata->getDescription()) {
                        $descriptionParts[] = $propertyMetadata->getDescription();
                        $descriptionParts[] = '';
                    }
                    $enum = [];
                    foreach ($propertyClassName::cases() as $case) {
                        $enum[] = $case->value;
                        $descriptionParts[] = '- *'.$case->value.'*: '.(method_exists($propertyClassName, 'getDescription') ? $propertyClassName::getDescription($case) : '');
                    }
                    $openapiContext = [
                        'enum' => $enum,
                        'type' => 'string',
                    ];
                    $propertyMetadata = $propertyMetadata->withOpenapiContext($openapiContext);
                    $propertyMetadata = $propertyMetadata->withDescription(implode("\n", $descriptionParts));
                }
            }
        }

        // corrects enum default to primitive value (will also effect example value)
        if (\array_key_exists($property, $defaultProperties) && null !== $defaultProperties[$property]) {
            $default = $defaultProperties[$property];
            if (is_object($default) && enum_exists(get_class($default)) && property_exists($default, 'value')) {
                $propertyMetadata = $propertyMetadata->withDefault($default->value);
            }
        }

        return $propertyMetadata;
    }
}

Don't forget to clear the cache with php bin/console cache:clear

2. Configure your field with the enumType property: Makes the setter also accept backed types so that mutations work.

    #[ORM\Column(type: 'string', enumType: BasicStatus::class, options: ['default' => 'incomplete'])]
    #[Groups(['requirement:read', 'requirement:write'])]
    private BasicStatus $status = BasicStatus::INCOMPLETE;

   public function getStatus(): BasicStatus
    {
        return $this->status;
    }

    public function setStatus(BasicStatus|string $status): self
    {
        if (is_string($status)) {
            $status = BasicStatus::from($status);
        }
        $this->status = $status;

        return $this;
    }

3. all done Types should be generated correctly based on open API spec. And things like typescript generators correctly interpret the enum as actual enum types:

Screen Shot 2022-10-05 at 11 42 34

Optional

For extra credit add a getDescription() method to allow for better automatic description genneration:

<?php

declare(strict_types=1);

namespace App\Enum;

enum BasicStatus: string
{
   case INCOMPLETE = 'incomplete';
   case ACTIVE = 'active';
   case REMOVED = 'removed';
   public static function getDescription(BasicStatus $value): string
   {
       return match ($value) {
           BasicStatus::INCOMPLETE => 'Unfertig', // not shown publicly aka draft
           BasicStatus::ACTIVE => 'Aktiv', // shown publicly
           BasicStatus::REMOVED => 'Gelöscht',
       };
   }
}

Infos

  • Don't install doctrine/orm version 2.13.2 there is a double serialization problem open with that version. stick with 2.13.1 if there has not been a fix yet.
  • I only checked with api version 2.7.1 I did not check with 3.0 yet
    Hope this helps
  • For graphQL you would need to add custom types to each enum. I have something working half-automaticaly for older versions of api platform, but not for >=2.7 yet.
  • 'type' => 'string', should actually fetch the actual backing type. My example expects all backed enums to be strings.

@dheineman
Copy link

Yes we'll definitely support enums out of the box (and also in Schema Generator). I cannot give an ETA yet however.

@dunglas can you provide any more information about the plans for native enum support in API-platform now that version 3 has been released?

@alanpoulain
Copy link
Member

I'm working on it 🙂

@soyuka
Copy link
Member

soyuka commented Nov 21, 2022

#5199 #5011 #5199

@orangevinz
Copy link

orangevinz commented Apr 15, 2024

In my case I wanted to use strings (Enum names) as allowed values but store integers in the database.

enum Gender: int
{
    case MALE = 1;
    case FEMALE = 2;
    case OTHER = 3;
}
#[ApiResource]
final class Profile
{
    #[ApiProperty(description: 'Profile gender.')]
    #[ORM\Column(type: Types::SMALLINT, enumType: Gender::class)]
    public ?Gender $gender = null;
}

A custom normalizer to handle string values in and out

use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Serializer\Encoder\NormalizationAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class EnumNormalizer implements DenormalizerInterface, DenormalizerAwareInterface, NormalizerInterface, NormalizationAwareInterface
{
    use NormalizerAwareTrait;
    use DenormalizerAwareTrait;

    public function supportsDenormalization($data, $type, $format = null, array $context = []): bool
    {
        return enum_exists($type) && is_string($data);
    }

    public function getSupportedTypes(?string $format): array
    {
        return [
            \UnitEnum::class => true,
        ];
    }

    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
    {
        // Check if the provided type is an enum
        if (!enum_exists($type)) {
            throw new \InvalidArgumentException('Type must be an enum');
        }

        // Convert the string data to uppercase to match the enum case names
        $enumValue = strtoupper($data);

        // Check if the enum value exists within the specified enum type
        if (!defined("{$type}::{$enumValue}")) {
            // Gather available enum cases to enhance the error message
            $availableCases = array_map(function ($case) { return strtolower($case->name); }, $type::cases());

            throw new BadRequestHttpException("Invalid value for enum {$type}: {$data}. Must be one of: ".implode(', ', $availableCases));
        }

        // Return the enum case constant
        return constant("{$type}::{$enumValue}");
    }

    public function normalize(
        mixed $object,
        ?string $format = null,
        array $context = []
    ): null|array|\ArrayObject|bool|float|int|string {
        return strtolower($object->name);
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof \UnitEnum;
    }
}

Thanks to @jensstalder , I changes the enums values to the Enum->name

image

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;

final class EnumMetadataFactory implements PropertyMetadataFactoryInterface
{
    public function __construct(private readonly ?PropertyMetadataFactoryInterface $decorated = null) {}

    public function create(string $resourceClass, string $property, array $options = []): ApiProperty
    {
        $propertyMetadata = $this->createPropertyMetadata($resourceClass, $property, $options);
        $reflectionClass = new \ReflectionClass($resourceClass);

        if (property_exists($resourceClass, $property)) {
            $reflectionProperty = $reflectionClass->getProperty($property);
            $propertyType = $reflectionProperty->getType();

            if ($propertyType instanceof \ReflectionNamedType && enum_exists($propertyType->getName())) {
                $propertyMetadata = $this->handleEnumProperty($propertyMetadata, $propertyType->getName());
            }
        }

        $defaultProperties = $reflectionClass->getDefaultProperties();
        if (\array_key_exists($property, $defaultProperties) && null !== $defaultProperties[$property]) {
            $propertyMetadata = $this->handleDefaultProperty($propertyMetadata, $defaultProperties[$property]);
        }

        return $propertyMetadata;
    }

    private function createPropertyMetadata(string $resourceClass, string $property, array $options): ApiProperty
    {
        return $this->decorated ? $this->decorated->create($resourceClass, $property, $options) : new ApiProperty();
    }

    private function handleEnumProperty(ApiProperty $propertyMetadata, string $enumClass): ApiProperty
    {
        $enum = array_map(fn ($case) => strtolower($case->name), $enumClass::cases());
        $descriptionParts = array_map(fn ($case) => '- *'.$case->name.'*: '.(method_exists($enumClass, 'getDescription') ? $enumClass::getDescription($case) : ''), $enumClass::cases());

        $openapiContext = [
            'enum' => $enum,
            'type' => 'string',
        ];

        return $propertyMetadata
            ->withOpenapiContext($openapiContext)
            ->withDescription(implode("\n", $descriptionParts))
        ;
    }

    private function handleDefaultProperty(ApiProperty $propertyMetadata, $default): ApiProperty
    {
        if (is_object($default) && enum_exists(get_class($default)) && property_exists($default, 'value')) {
            return $propertyMetadata->withDefault($default->value);
        }

        return $propertyMetadata;
    }
}

Now I can use strings as input data

curl --location --request POST '/profiles' \
--header 'Content-Type: application/json' \
--data '{
    "gender": "female"
}'

And retrieve them as string

{
    "@context": "/contexts/Profile",
    "@id": "/profiles",
    "@type": "hydra:Collection",
    "hydra:totalItems": 11,
    "hydra:member": [
        {
            "@id": "/profiles/1",
            "@type": "Profile",
            "gender": "male"
        },
        {
            "@id": "/profiles/2",
            "@type": "Profile",
            "gender": "female"
        },
    ]
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.