Le blog

Exposez vos Enums avec API Platform

Publié le 14 novembre 2022

Note d'avant lecture : si des termes vous sont inconnus, mettez votre lecture en pause et allez vérifier leur signification avant de reprendre !

Ils sont là depuis près d'un an, et ils ont longtemps été attendus ! Ce ne sont pas les derniers titres de Vianney mais les énumérations. Si vous les découvrez avec cet article, les énumérations sont un moyen de définir un ensemble restreint de valeurs possibles pour un type. Cela peut être particulièrement utile lors de la définition d'un modèle de domaine puisqu'ils ne nécessitent pas de surcouche de code.

<?php

enum Suit
{
    case Hearts;
    case Diamonds;
    case Clubs;
    case Spades;
}

function doStuff(Suit $s)
{
    // ...
}

doStuff(Suit::Spades);
doStuff('Spades'); // ceci échouera

Il est également possible d'associer leurs constantes à des valeurs.

<?php

enum Suit: string
{
    case Hearts = 'H';
    case Diamonds = 'D';
    case Clubs = 'C';
    case Spades = 'S';
}

Je vous invite à lire la documentation afin de prendre connaissance des autres mécanismes que proposent les énumérations, leurs méthodes, leurs constantes... Lisez bien les douze sections en prenant le temps de les comprendre, quitte à tester vous-même chaque situation présentée ! Utiliser les énumérations avec API Platform devient très vite attrayant, découvrez plus en détails pourquoi :

Les Enums avec API Platform #

Voyons un cas classique d'un BlogPost possédant un statut :

<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Symfony\Component\Uid\Uuid;

#[ApiResource, GetCollection, Get, Post]
class BlogPost
{
    public function __construct(
        public Uuid $id,
        public string $title,
        public Status $status
    )
    {
    }
}
<?php

declare(strict_types=1);

namespace App\ApiResource;

enum Status: int
{
    case DRAFT = 0;
    case PUBLISHED = 1;
    case ARCHIVED = 2;
}

Si nous effectuons une requête POST pour créer un BlogPost :

curl -k -X 'POST' \
  'https://localhost/blog_posts' \
  -H 'Accept: application/ld+json' \
  -H 'Content-Type: application/ld+json' \
  -d '{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "title": "string",
  "status": 0
}'

Nous obtenons bien un BlogPost valide.

{
  "@context": "/contexts/BlogPost",
  "@id": "/blog_posts/3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "@type": "http://schema.org/Blogpost",
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "title": "string",
  "status": 0
}

C'est déjà une très bonne chose, mais nous aimerions donner la possibilité de connaître les statuts disponibles, et de les exposer à l'aide de notre API.

Donner la possibilité de connaître la liste des valeurs possible #

Nous allons transformer notre classe Status en ressource.

<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;

#[
    ApiResource,
    GetCollection, 
    Get,
]
enum Status: int
{
    case DRAFT = 0;
    case PUBLISHED = 1;
    case ARCHIVED = 2;
}
Aperçu de SwaggerUI avec les nouveaux EndPoint pour les status." class="wp-image-5882

Pour servir de provider, nous allons utiliser l'énumération Status lui-même.

<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Operation;

#[
    ApiResource,
    GetCollection(provider: Status::class.'::getCases'), 
    Get(provider: Status::class.'::getCase'),
]
enum Status: int
{
    case DRAFT = 0;
    case PUBLISHED = 1;
    case ARCHIVED = 2;

    public function getId()
    {
        return $this->name;
    }

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

    public static function getCase(Operation $operation, array $uriVariables)
    {
        $name = $uriVariables['id'] ?? null;

        return constant(self::class . "::$name");
    }
}

La méthode statique cases n'appartient pas au userland. Il est sous-entendu ici qu'elle est définie par le langage, et n'est pas tout à fait régie par les mêmes règles.
Nous devons la redéfinir nous-même afin qu'elle puisse être utilisée comme méthode de provider, sinon une incompatibilité des arguments attendus sera soulevée par PHP.

Nous pouvons à présent lister les statuts avec la requête HTTP suivante :

curl -k --request GET 'https://localhost/statuses' --header 'Content-Type: application/ld+json'
{
  "@context": "/contexts/Status",
  "@id": "/statuses",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/statuses/DRAFT",
      "@type": "Status",
      "value": "en cours"
    },
    {
      "@id": "/statuses/PUBLISHED",
      "@type": "Status",
      "value": "publié"
    },
    {
      "@id": "/statuses/ARCHIVED",
      "@type": "Status",
      "value": "archivé"
    }
  ],
  "hydra:totalItems": 3
}

Et en obtenir une valeur spécifique à l'aide des constantes :

curl -k --request GET 'https://localhost/statuses/DRAFT' --header 'Content-Type: application/ld+json'
{
  "@context": "/contexts/Status",
  "@id": "/statuses/DRAFT",
  "@type": "Status",
  "value": "en cours"
}

Vous pourriez aussi vouloir les récupérer avec leur valeur.

    // ...

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

    public static function getCases()
    {
        return array_map(static fn (UnitEnum $enumCase) => $enumCase->value, self::cases());
    }

    public static function getCase(Operation $operation, array $uriVariables)
    {
        return self::tryFrom($uriVariables['id'] ?? null);
    }

    // ...

Sérialiser les valeurs énumérées #

Les énumérations fonctionnent d'une manière assez particulière. Chaque case, lorsqu'il est obtenu est techniquement une instance de l'énumérations. En passant par le Serializer, nous aurions alors une boucle infinie (si elle n'était pas limitée à trois sous-niveaux par défaut) où chaque case expose à son tour les autres cases disponibles.

Nous devons restreindre cette récursion à l'aide des groupes de sérialisation (voir la conférence de notre coopérateur Mathias Arlaud sur le sujet).

<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Operation;
use Symfony\Component\Serializer\Annotation\Groups;

#[
  ApiResource(normalizationContext: ['groups' => ['read']]),
  GetCollection(provider: Status::class.'::getCases'),
  Get(provider: Status::class.'::getCase')
]
enum Status: int
{
    case DRAFT = 0;
    case PUBLISHED = 1;
    case ARCHIVED = 2;

    public function getId()
    {
        return $this->name;
    }

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

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

    public static function getCase(Operation $operation, array $uriVariables)
    {
        return self::tryFrom($uriVariables['id']);
    }
}

Vous pouvez à présent obtenir la liste des statuts :

curl -k -X 'GET' 'https://localhost/statuses' -H 'accept: application/ld+json'
{
  "@context": "/contexts/Status",
  "@id": "/statuses",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/statuses/DRAFT",
      "@type": "Status",
      "value": "en cours"
    },
    {
      "@id": "/statuses/PUBLISHED",
      "@type": "Status",
      "value": "publié"
    },
    {
      "@id": "/statuses/ARCHIVED",
      "@type": "Status",
      "value": "archivé"
    }
  ],
  "hydra:totalItems": 3
}

Et récupérer un status seul :

curl -k -X 'GET' 'https://localhost:8000/statuses/DRAFT' -H 'accept: application/ld+json'
{
  "@context": "/contexts/Status",
  "@id": "/statuses/DRAFT",
  "@type": "Status",
  "value": "en cours"
}

Résultat #

Pour effectuer notre requête POST initiale, nous pouvons désormais faire référence à sa ressource à l'aide de son IRI !

curl -k -X 'POST' \
  'https://localhost/blog_posts' \
  -H 'Accept: application/ld+json' \
  -H 'Content-Type: application/ld+json' \
  -d '{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "title": "string",
  "status": "/statuses/DRAFT"
}'

Avec pour résultat :

{
    "@context":"/contexts/BlogPost",
    "@id":"/blog_posts/3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "@type":"BlogPost",
    "id":"3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "title":"string",
    "status":"/statuses/DRAFT"
}

Factoriser le code #

Les trois méthodes de notre énumération sont très génériques alors nous pouvons utiliser les traits pour éviter de nous répéter au sein de nos autres énumérations.

<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;

#[ApiResource(normalizationContext: ['groups' => ['read']]),
    GetCollection(provider: Status::class.'::getCases'),
    Get(provider: Status::class.'::getCase')
]
enum Status: int
{
    case DRAFT = 0;
    case PUBLISHED = 1;
    case ARCHIVED = 2;

    use EnumApiResourceTrait;
}

Et

<?php

declare(strict_types=1);

namespace App\ApiResource;

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

trait EnumApiResourceTrait
{
    public function getId()
    {
        return $this->name;
    }

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

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

    public static function getCase(Operation $operation, array $uriVariables)
    {
        $name = $uriVariables['id'] ?? null;
        return self::tryFrom($name);
    }
}

C'est un bon moyen d'exposer vos listes !

Se former à API Platform

Formater et documenter vos énumérations exposées #

Depuis le début de cet article, je me suis appliqué à utiliser l'entête HTTP Accept pour la récupération de mes énumérations. En particulier avec le Media-Type application/ld+json pour l'usage de Json Linked Data. Cet entête permet la négociation de contenu. C'est important parce que REST propose une solution de restitution de la ressource demandée de sorte à ce qu'elle corresponde à ses capacités, sa nature, ainsi qu'au désir du client. Pour respecter ceci, nous voulons :

  • Pouvoir représenter / rendre les données où elles se trouvent
  • Pouvoir encapsuler les données avec un moteur de rendu et envoyer les deux (la donnée brute et la donnée rendue)
  • Pouvoir envoyer les données brutes avec des métadonnées afin de laisser le choix du moteur de rendu au client

Ici nous nous proposons sur le dernier point.

Le format application/ld+json est un format RDF.

RDF est un élément clé des données liées (Linked-Data).
Les données liées ont été proposées comme un ensemble de bonnes pratiques pour publier des données sur le Web.
Elles ont été introduites par Tim Berners-Lee et reposent sur quatre grands principes.
RDF est mentionné dans le troisième principe comme l'une des normes qui fournit des informations utiles.

L'objectif est que l'information soit utile non seulement pour les humains qui utilisent des navigateurs (pour lesquels le HTML serait suffisant) mais aussi pour d'autres agents susceptibles de traiter automatiquement ces données (des clients avec des API HTTP).

Nous ne savons pas encore, en tant que consommateur de l'API, ce que signifie ou représente le champs valeur de notre ressource Status. Mais puisque nous avons une API REST, offrant un format RDF, en particulier JSON-LD, nous souhaitons la nourrir pour qu'un outil partageant les mêmes standards puisse découvrir de façon automatisée comment exploiter nos données.

Pour faire ceci, et respecter RDF, il faut choisir un format respectant les règles établies ainsi qu'un "vocabulaire" / une "ontologie", afin de s'assurer que la donnée offerte puisse être exploitée comme prévu. Les vocabulaires RDF sont couramment utilisés pour définir des concepts et des relations à l'échelle du web (Schema.org), d’une branche d’industrie (la NASAThe Automotive Ontology, AddictO Vocab...) ou d’une organisation  (l’Union Européenne, Volkswagen).

En apprendre plus sur le REST

Au sein du vocabulaire Schema.org, il existe une définition des énumérations. https://schema.org/Enumeration. Afin de rendre nos ressources expressives, nous allons ajouter l'usage du vocabulaire dans nos métadonnées. Profitons-en pour ajouter une description à notre classe BlogPost.

<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints\Length;

#[ApiResource(types: ['http://schema.org/Blogpost'], description: "A blog article")]
class BlogPost
{
    public function __construct(
        #[ApiProperty(types: ['http://schema.org/identifier'])]
        public Uuid $id,
        #[ApiProperty(types: ['http://schema.org/name'])]
        #[Length(min: 3)]
        public string $title,
        public Status $status
    )
    {
    }
}

Ensuite notre énumération Status

<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;

#[
    ApiResource(
        types: ['https://schema.org/Enumeration'],
        normalizationContext: ['groups' => ['read']],
        description: "Status used for a blog article"
    ),
    GetCollection(provider: Status::class.'::getCases'),
    Get(provider: Status::class.'::getCase')
]
enum Status: int
{
    case DRAFT = 0;
    case PUBLISHED = 1;
    case ARCHIVED = 2;

    use EnumApiResourceTrait;
}

Sans oublier le trait :

<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Operation;
use Symfony\Component\Serializer\Annotation\Groups;

trait EnumApiResourceTrait
{
    #[ApiProperty(types: ['https://schema.org/identifier'])]
    public function getId()
    {
        return $this->name;
    }

    #[Groups('read')]
    #[ApiProperty(types: ['https://schema.org/name'])]
    public function getValue()
    {
        return $this->value;
    }

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

    public static function getCase(Operation $operation, array $uriVariables)
    {
        $name = $uriVariables['id'] ?? null;
        return self::tryFrom($name) ?? constant(self::class . "::$name");
    }
}

Ce qui permet à API Platform de documenter notre ressource.

Voici ce qui sera ajouté à notre documentation OpenAPI (j'ai collé un extrait pour n'afficher ici que l'essentiel) :

    "components": {
        "schemas": {
            "BlogPost.jsonld": {
                "type": "object",
                "description": "A blog article",
                "deprecated": false,
                "externalDocs": {
                    "url": "http://schema.org/Blogpost"
                },
                "properties": {
                    "id": {
                        "externalDocs": {
                            "url": "http://schema.org/identifier"
                        },
                        "type": "string",
                        "format": "uuid"
                    },
                    "title": {
                        "minLength": 3,
                        "externalDocs": {
                            "url": "http://schema.org/name"
                        },
                        "type": "string"
                    },
                    "status": {
                        "type": "string",
                        "format": "iri-reference"
                    }
                }
            },
            "Status.jsonld-read": {
                "type": "object",
                "description": "Status used for a blog article",
                "deprecated": false,
                "externalDocs": {
                    "url": "https://schema.org/Enumeration"
                },
                "properties": {
                    "@context": {
                        "readOnly": true,
                        "oneOf": [
                            {
                                "type": "string"
                            },
                            {
                                "type": "object",
                                "properties": {
                                    "@vocab": {
                                        "type": "string"
                                    },
                                    "hydra": {
                                        "type": "string",
                                        "enum": [
                                            "http://www.w3.org/ns/hydra/core#"
                                        ]
                                    }
                                },
                                "required": [
                                    "@vocab",
                                    "hydra"
                                ],
                                "additionalProperties": true
                            }
                        ]
                    },
                    "@id": {
                        "readOnly": true,
                        "type": "string"
                    },
                    "@type": {
                        "readOnly": true,
                        "type": "string"
                    },
                    "value": {
                        "readOnly": true,
                        "externalDocs": {
                            "url": "https://schema.org/name"
                        },
                        "type": "string"
                    }
                }
            }

API Platform pourra également fournir la documentation HYDRA (j'ai collé un extrait pour n'afficher ici que l'essentiel).

    "hydra:supportedClass": [
        {
            "@id": "http://schema.org/Blogpost",
            "@type": "hydra:Class",
            "rdfs:label": "BlogPost",
            "hydra:title": "BlogPost",
            "hydra:supportedProperty": [
                {
                    "@type": "hydra:SupportedProperty",
                    "hydra:property": {
                        "@id": "#BlogPost/id",
                        "@type": "rdf:Property",
                        "rdfs:label": "id",
                        "domain": "http://schema.org/Blogpost"
                    },
                    "hydra:title": "id",
                    "hydra:required": false,
                    "hydra:readable": true,
                    "hydra:writeable": true
                },
                {
                    "@type": "hydra:SupportedProperty",
                    "hydra:property": {
                        "@id": "#BlogPost/title",
                        "@type": "rdf:Property",
                        "rdfs:label": "title",
                        "domain": "http://schema.org/Blogpost",
                        "range": "xmls:string"
                    },
                    "hydra:title": "title",
                    "hydra:required": false,
                    "hydra:readable": true,
                    "hydra:writeable": true
                },
                {
                    "@type": "hydra:SupportedProperty",
                    "hydra:property": {
                        "@id": "#BlogPost/status",
                        "@type": "hydra:Link",
                        "rdfs:label": "status",
                        "domain": "http://schema.org/Blogpost",
                        "owl:maxCardinality": 1,
                        "range": "https://schema.org/Enumeration"
                    },
                    "hydra:title": "status",
                    "hydra:required": false,
                    "hydra:readable": true,
                    "hydra:writeable": true
                }
            ],
            "hydra:description": "Blog article"
        },
        {
            "@id": "https://schema.org/Enumeration",
            "@type": "hydra:Class",
            "rdfs:label": "Status",
            "hydra:title": "Status",
            "hydra:supportedProperty": [
                {
                    "@type": "hydra:SupportedProperty",
                    "hydra:property": {
                        "@id": "#Status/value",
                        "@type": "rdf:Property",
                        "rdfs:label": "value",
                        "domain": "https://schema.org/Enumeration",
                        "range": "xmls:integer"
                    },
                    "hydra:title": "value",
                    "hydra:required": false,
                    "hydra:readable": true,
                    "hydra:writeable": false
                }
            ],
            "hydra:supportedOperation": [
                {
                    "@type": [
                        "hydra:Operation",
                        "schema:FindAction"
                    ],
                    "hydra:method": "GET",
                    "hydra:title": "Retrieves a Status resource.",
                    "rdfs:label": "Retrieves a Status resource.",
                    "returns": "https://schema.org/Enumeration"
                }
            ],
            "hydra:description": "Status used for a blog article"
        },

Synthétisons #

Avec tout ce que nous avons fait, lorsque nous récupérons un statut en particulier, nous obtenons ce JSON-LD :

{
  "@context": "/contexts/Status",
  "@id": "/statuses/DRAFT",
  "@type": "Status",
  "value": "en cours"
}

Nous ne savons pas encore, en tant que consommateur de l'API ce que signifie ou représente le champ valeur. Mais puisque nous avons une API REST, offrant un format RDF, en particulier JSON-LD, nous savons que nous pouvons en apprendre plus à l'aide de la clé context. En effectuant une requête GET sur cet URL, nous allons donc obtenir des informations sur cette propriété et cette ressource.

{
    "@context": {
        "@vocab": "https://localhost:8000/docs.jsonld#",
        "hydra": "http://www.w3.org/ns/hydra/core#",
        "value": "Status/value"
    }
}

Nous obtenons l'information que toutes les valeurs sont décrites par le vocabulaire défini à l'URL https://localhost:8000/docs.jsonld#.
Cet URL, c'est la documentation Hydra vue précédemment.

Autrement dit, cela nous a permis d'obtenir une API Hypermédia.

Si API Platform est compatible, la documentation pourrait être un peu plus automatisée, et ce travail est en cours grâce à Alan Poulain, ici : https://github.com/api-platform/core/pull/5120 et là https://github.com/api-platform/core/issues/2254.
N'hésitez pas à aller tester et montrer votre enthousiasme sur son travail !

Aller plus loin #

Si vous avez besoin d'intégrer encore plus loin vos énumérations (readable, Doctrine, translate, bitwise operation, forms et Faker), je vous recommande également l'excellente bibliothèque PhpEnums https://github.com/Elao/PhpEnums réalisée par l'équipe d'Elao.

Bon code à vous !

Se former au style architectural REST

Le blog

Pour aller plus loin