How to expose Enums with API Platform
Published on February 08, 2023
Pre-reading note: if you are unfamiliar with any of the terms, pause your reading and check their meaning before continuing!
They've been around for almost a year, and they've been long awaited! We are not speaking about Justin Bieber’s new album but enumerations. If you don’t know them, enums are a way of defining a restricted set of possible values for a type. This can be particularly useful when defining a domain model as they do not require any code overhead.
<?php
enum Suit
{
case Hearts;
case Diamonds;
case Clubs;
case Spades;
}
function doStuff(Suit $s)
{
// ...
}
doStuff(Suit::Spades);
doStuff('Spades'); // this will fail
It is also possible to associate their constants with values.
<?php
enum Suit: string
{
case Hearts = 'H';
case Diamonds = 'D';
case Clubs = 'C';
case Spades = 'S';
}
We invite you to read the documentation to learn about the other mechanisms offered by enumerations, their methods, their constants... Read the twelve sections carefully and take the time to understand them, even if it means testing each situation yourself! Using enumerations with API Platform becomes very attractive very quickly, discover more in detail why:
Enums with API Platform #
Let's look at a classic case of a BlogPost with a status :
<?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;
}
If we make a POST request to create a 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
}'
We get a valid BlogPost.
{
"@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
}
This is already a very good thing, but we would like to give the possibility to know the available statuses, and to expose them using our API.
Give the possibility to know the list of possible values #
We will turn our Status class into a resource.
<?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;
}
To serve as a provider, we will use the Status enumeration itself.
<?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");
}
}
The static method cases do not belong to userland. It is implied here that it is defined by the language, and is not quite governed by the same rules. We have to redefine it ourselves so that it can be used as a provider method, otherwise an incompatibility of expected arguments will be raised by PHP.
We can now list the statuses with the following HTTP request:
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
}
And obtain a specific value using the constants :
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"
}
You may also want them back with their value.
// ...
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);
}
// ...
Serialize enumerated values #
Enumerations work in a rather special way. Each case, when obtained, is technically an instance of the enumeration. Passing through the Serializer, we would then have an infinite loop (if it were not limited to three sublevels by default) where each box in turn exposes the other available boxes.
We have to restrict this recursion using serialization groups (see this conference of Mathias Arlaud on the subject).
<?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']);
}
}
You can now obtain the list of statuses:
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
}
And retrieve a single status:
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"
}
Result #
To perform our initial POST request, we can now reference its resource using its 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"
}'
As a result :
{
"@context":"/contexts/BlogPost",
"@id":"/blog_posts/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"@type":"BlogPost",
"id":"3fa85f64-5717-4562-b3fc-2c963f66afa6",
"title":"string",
"status":"/statuses/DRAFT"
}
Factoring the code #
The three methods in our enumeration are very generic so we can use traits to avoid repeating ourselves in our other enumerations.
<?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;
}
and
<?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);
}
}
This is a good way to showcase your lists!
Formatting and documenting your exhibited lists #
Since the beginning of this article, I have been diligent about using the HTTP Accept header for retrieving my enumerations. In particular with the Media-Type application/ld+json for the use of Json Linked Data. This header allows content negotiation. This is important because REST provides a solution for retrieving the requested resource in a way that matches its capabilities, nature, and the client's desire. To respect this, we want to :
- Be able to represent / render data where it is found
- Be able to encapsulate the data with a renderer and send both (the raw data and the rendered data)
- Be able to send raw data with metadata to allow the client to choose the rendering engine
Here we propose the last point.
The application/ld+json format is an RDF format.
RDF is a key element of Linked-Data.
Linked-Data has been proposed as a set of best practices for publishing data on the Web.
It was introduced by Tim Berners-Lee and is based on four main principles.
RDF is mentioned in the third principle as one of the standards that provides useful information.
The aim is that the information is useful not only to humans using browsers (for whom HTML would be sufficient) but also to other agents that can automatically process this data (clients with HTTP APIs).
We don't yet know, as consumers of the API, what the value field in our Status resource means or represents. But since we have a REST API, offering an RDF format, in particular JSON-LD, we want to feed it so that a tool sharing the same standards can discover in an automated way how to exploit our data.
To do this, and to comply with RDF, we need to choose a format that respects the established rules as well as a "vocabulary" / "ontology", to ensure that the offered data can be exploited as intended. RDF vocabularies are commonly used to define concepts and relationships at the level of the web (Schema.org), a branch of industry (NASA, The Automotive Ontology, AddictO Vocab...) or an organisation (the European Union, Volkswagen).
Within the Schema.org vocabulary, there is a definition of enumerations. https://schema.org/Enumeration. In order to make our resources expressive, we will add vocabulary usage to our metadata. Let's take the opportunity to add a description to our BlogPost class.
<?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
)
{
}
}
Then our Status list
<?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;
}
Not forgetting the 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");
}
}
This allows API Platform to document our resource.
Here is what will be added to our OpenAPI documentation (I've pasted an excerpt to show only the essentials here):
"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 will also be able to provide the HYDRA documentation (I have pasted an excerpt to show only the essentials here).
"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"
},
Let's summarise #
With everything we have done, when we retrieve a particular status, we get this JSON-LD :
{
"@context": "/contexts/Status",
"@id": "/statuses/DRAFT",
"@type": "Status",
"value": "en cours"
}
We don't yet know, as consumers of the API, what the value field means or represents. But since we have a REST API, offering an RDF format, specifically JSON-LD, we know that we can learn more using the context key.
So by making a GET request to this URL, we'll get information about this property and resource.
{
"@context": {
"@vocab": "https://localhost:8000/docs.jsonld#",
"hydra": "http://www.w3.org/ns/hydra/core#",
"value": "Status/value"
}
}
We get the information that all the values are described by the vocabulary defined at the URL https://localhost:8000/docs.jsonld#.
This URL is the Hydra documentation seen earlier.
In other words, it has allowed us to obtain a Hypermedia API.
If API Platform is compatible, the documentation could be a bit more automated, and this work is in progress thanks to Alan Poulain, here: https://github.com/api-platform/core/pull/5120 and there
https://github.com/api-platform/core/issues/2254.
Feel free to go and test and show your enthusiasm for his work!
Going further #
If you need to further integrate your enumerations (readable, Doctrine, translate, bitwise operation, forms and Faker), I also recommend the excellent PhpEnums library https://github.com/Elao/PhpEnums made by the Elao team.
Good code to you!