Love HATEOAS with Symfony and API Platform
Thomas Eudes10 min read
HATEOAS (Hypermedia As The Engine Of Application State) is a part of the REST standard that can help you create more robust and maintainable web services. By including hypermedia links within its responses, a server can indicate the possible actions that the clients can perform depending on the state of the resources. The implementation of HATEOAS provides several benefits, among which:
- decoupling your clients from your APIs, as the client is able to navigate the API without relying on hard-coded URLs,
- improved maintainability of the app, by removing code duplication between client and server,
- a centralization of business logic within the backend, limiting the front-end’s responsibility to the displaying of the resources,
Among the possible drawbacks of adding HATEOAS to your services, let’s mention:
- the performance impact due to the increased size of the response and the processing time, especially the dynamic generation of the links,
- the lack of standardized ways to format the links (though json+ld and hal formats try to address this issue),
- the difficulty to implement this principle and to migrate your clients and servers from a regular architecture to a HATEOAS-driven one,
This article aims to tackle this last point in the case of a Symfony app using the API Platform bundle. Symfony is a popular PHP framework that provides tools for building complex web applications. API Platform is a Symfony bundle especially suited for the development of RESTful APIs. Making your application HATEOAS-compliant can help you to take it to the next level. The usage instructions from the HATEOAS library for Symfony did not mention how to make it work with API Platform, but fortunately, implementing the logic yourself is quite simple.
Going forward, I will assume that you have substantial knowledge of Symfony and API Platform and that you already know how to create resources, routes, and services. Building on this base I will show how to modify the declaration of a resource by using attributes to define HATEOAS operations, and how to customize the API Platform normalizer to handle these attributes and add the links to the response.
Let’s dive into the world of HATEOAS! You are going to LOVE the trip!
Outline
- HATEOAS with Symfony/API Platform
- Create the HATEOAS link attribute
- Decorate the API Platform serializer
- Handle the HATEOAS links
- Make the link conditional
- Conclusion
HATEOAS with Symfony/API Platform
Our goal is to configure our API so that it provides additional data to its responses: the available operations that the client can use to interact with the resources. For example, a response could look like this:
{
"id": 1,
"name": "Jerry",
"specy": "mouse",
"color": "brown",
"_links": {
"update": {
"method": "PUT",
"href": "http://localhost:8000/api/pets/1"
},
"copy": {
"method": "POST",
"href": "http://localhost:8000/api/pets/1/copy"
},
"delete": {
"method": "DELETE",
"href": "http://localhost:8000/api/pets/1"
}
}
}
With the usual data on this resource, a consumer of this API can find a _links
section providing information on what endpoints he can call to perform some actions on the resource.
We want to add some extra fields to the objects the application exposes. What we are going to do is hook into the serialization process, so that every entity about to be returned by the API will be expanded with the relevant links. We will configure what link to associate to each object thanks to php attributes used to annotate our entity classes.
Let’s start from a simple API resource Pet
, on which we want to add HATEOAS links:
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\PetRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PetRepository::class)]
#[ApiResource]
class Pet
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
public function getId(): int
{
return $this->id;
}
}
We are going to modify this resource’s definition to indicate to the serializer that it needs to add some operations to the API response.
Create the HATEOAS link attribute
First things first, we need to create the custom attribute that will allow us to add a HATEOAS link to our entity. For that, we will use php attributes, available since php 8. If you are using an older version of php, you could get a similar result by creating a custom Doctrine annotation.
Start by creating a new class and mark it with the Attribute
attribute (yes):
namespace App\Attribute;
use Attribute;
#[Attribute()] #This is the important part to make this class an attribute
class HateoasLink
{
public string $name;
public string $method;
public string $route;
public function __construct(string $name, string $method, string $route)
{
$this->name = $name;
$this->method = $method;
$this->route = $route;
}
}
We added three attributes (no pun intended) to this class: $name
will be the name of the link, $method
will be the HTTP verb used to call the route, $route
will be the name of the route called by the client.
That’s all we need to add an HATEOAS link to our API resource! Let’s do that. By running php bin/console debug:router
, we know that the route to update a pet resource is called api_pets_put_item
. We can add this route to the available operations to interact with our resource:
use App\Attribute\HateoasLink;
#[HateoasLink("update", "PUT", "api_pets_put_item")]
class Pet
{
}
And that’s it! Now we have to use this attribute so that the HATEOAS operations are exposed by the API.
Decorate the API Platform serializer
To do that, we are going to decorate the default normalizer, following the API Platform doc. Add the following lines in the config/services.yaml
file:
services:
'App\Serializer\HateoasNormalizer':
decorates: "api_platform.jsonld.normalizer.item"
This normalizer will work only for JSON-LD format, if you want to process JSON data too, you have to decorate another service:
services:
# Need a different name to avoid duplicate YAML key
app.serializer.normalizer.item.json:
class: 'App\Serializer\HateoasNormalizer'
decorates: "api_platform.serializer.normalizer.item"
Then, create a class HateoasNormalizer
. The next one may seem a little bit complicated, but we are just decorating the default normalizer by implementing all the required methods. For now, these methods just call the ones from the decorated service:
namespace App\Serializer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;
final class HateoasNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
{
private $decorated;
public function __construct(NormalizerInterface $decorated)
{
if (!$decorated instanceof DenormalizerInterface) {
throw new \InvalidArgumentException(
sprintf('The decorated normalizer must implement the %s.', DenormalizerInterface::class)
);
}
$this->decorated = $decorated;
}
public function supportsNormalization($data, $format = null)
{
return $this->decorated->supportsNormalization($data, $format);
}
public function normalize($object, $format = null, array $context = [])
{
return $this->decorated->normalize($object, $format, $context);
}
public function supportsDenormalization($data, $type, $format = null)
{
return $this->decorated->supportsDenormalization($data, $type, $format);
}
public function denormalize($data, string $type, string $format = null, array $context = [])
{
return $this->decorated->denormalize($data, $type, $format, $context);
}
public function setSerializer(SerializerInterface $serializer)
{
if ($this->decorated instanceof SerializerAwareInterface) {
$this->decorated->setSerializer($serializer);
}
}
}
Handle the HATEOAS links
Now we can customize the behavior of our normalizer. First, we need to retrieve the HateoasLink
attributes of the object being serialized. For that, we make use of the ReflectionClass
class and its method getAttributes
. This method will return an array of ReflectionAttribute<HateoasLink>
, so we map it with the newInstance
method to get HateoasLink
objects. The code looks like this:
/**
* @return array<HateoasLink>
*/
private function getHateoasLinks(mixed $object): array
{
$reflectionClass = new ReflectionClass(get_class($object));
$hateoasLinks = array();
foreach ($reflectionClass->getAttributes(HateoasLink::class) as $reflectionAttribute) {
array_push($hateoasLinks, $reflectionAttribute->newInstance());
}
return $hateoasLinks;
}
Then, we have to normalize the HATEOAS links into something that the client can read.
private function getNormalizedLinks(mixed $object): array
{
$normalizedLinks = array();
$hateoasLinks = $this->getHateoasLinks($object);
foreach ($hateoasLinks as $hateoasLink) {
$normalizedLinks[$hateoasLink->name] = ["method" => $hateoasLink->method, "href" => "http://localhost:8000"];
}
return $normalizedLinks;
}
Finally we can modify the normalize
method to add the operations to the normalized object:
public function normalize($object, $format = null, array $context = [])
{
$data = $this->decorated->normalize($object, $format, $context);
$normalizedLinks = $this->getNormalizedLinks($object);
if (count($normalizedLinks)) {
$data['_links'] = $normalizedLinks;
}
return $data;
}
Try it! If you make a call to your API to retrieve a Pet
resource, you should get something like this:
{
"id": 1,
"_links": {
"update": {
"method": "PUT",
"href": "http://localhost:8000"
}
}
}
The next step is to customize the href
property to turn it into an actual link that the client can follow to update the pet resource. In our HateoasLink
attribute, we passed the route name, "api_pets_put_item"
. To generate an URL from this route name, we will also need some parameters (the id of the pet resource). Let’s add a $params
attribute to our HateoasLink
:
class HateoasLink
{
public string $name;
public string $method;
public string $route;
public array $params;
public function __construct(string $name, string $method, string $route, array $params = array())
{
$this->name = $name;
$this->method = $method;
$this->route = $route;
$this->params = $params;
}
}
Let’s also add the needed parameters to the attribute on the Pet
resource:
#[HateoasLink("update", "PUT", "api_pets_put_item", ["id" => "object.getId()"])]
class Pet
{
}
To convert this into an URL, we need an instance of a RouterInterface
. We also need an ExpressionLanguage
, an object that will help us evaluate the string we passed as route parameter. Update the HateoasNormalizer
as follow:
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
final class HateoasNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
{
private $decorated;
private $router;
private $expressionLanguage;
public function __construct(NormalizerInterface $decorated, RouterInterface $router)
{
if (!$decorated instanceof DenormalizerInterface) {
throw new \InvalidArgumentException(
sprintf('The decorated normalizer must implement the %s.', DenormalizerInterface::class)
);
}
$this->decorated = $decorated;
$this->router = $router;
$this->expressionLanguage = new ExpressionLanguage();
}
}
Add this method to the class:
private function resolveLinkParams(array $linkParams, $object): array
{
$params = array();
foreach ($linkParams as $key => $param) {
$params[$key] = $this->expressionLanguage
->evaluate($param, array("object" => $object));
}
return $params;
}
Finally, in the getNormalizedLinks
method, use the router to generate an URL as follow:
/**
* @param array<HateoasLink> $hateoasLinks
*/
private function getNormalizedLinks(mixed $object): array
{
$normalizedLinks = array();
$hateoasLinks = $this->getHateoasLinks($object);
foreach ($hateoasLinks as $hateoasLink) {
$url = $this->router->generate(
$hateoasLink->route,
$this->resolveLinkParams($hateoasLink->params, $object)
);
$normalizedLinks[$hateoasLink->name] = [
"method" => $hateoasLink->method,
"href" => "http://localhost:8000".$url
];
}
return $normalizedLinks;
}
That’s all! The resource exposed by your API should now look like this:
{
"id": 1,
"_links": {
"update": {
"method": "PUT",
"href": "http://localhost:8000/api/pets/1"
}
}
}
Make the link conditional
The last thing we need to do is to dynamically add the operation to the resource depending on its state. The idea is to expose the operation only if it is available so that the client can check the presence or absence of the HATEOAS link to know if it can perform the action.
To do that, we can add an attribute public string $condition;
to the HateoasLink
class. Let’s then add a condition on the Pet
’s attribute. For example, if the update
operation is limited to the owner of the Pet
, we can add an $owner
property on the resource, then update the HATEOAS link:
#[HateoasLink(
"update",
"PUT",
"api_pets_put_item",
["id" => "object.getId()"],
"object.getOwner() == currentUser"
)]
Let’s now evaluate this string with the ExpressionLanguage
in the HateoasNormalizer
. We could imagine that the condition depends on the identity of the connected user, so we can inject an instance of Security
into our normalizer, and add this method:
private function resolveCondition(string $condition, mixed $object): bool
{
return $this->expressionLanguage->evaluate(
$condition,
array("object" => $object, "currentUser" => $this->security->getUser())
);
}
All we have left to do is to update the getNormalizedLinks
method to conditionnally add the link to the normalized object if the condition is matched:
private function getNormalizedLinks(mixed $object): array
{
$normalizedLinks = array();
$hateoasLinks = $this->getHateoasLinks($object);
foreach ($hateoasLinks as $hateoasLink) {
if ($this->resolveCondition($hateoasLink->condition, $object)) {
$url = $this->router->generate(
$hateoasLink->route,
$this->resolveLinkParams($hateoasLink->params, $object)
);
$normalizedLinks[$hateoasLink->name] = [
"method" => $hateoasLink->method,
"href" => "http://localhost:8000".$url
];
}
}
return $normalizedLinks;
}
We now have a resource exposing all the available operations to interact with it, depending on its state. Congratulations!
Conclusion
In this article, I showed how to use the API Platform normalizer to add extra information on your resources and implement HATEOAS into your app. It should be really easy now to configure your resources declaration to add more links and operations, by just adding one line of annotation. You can also make the dynamic generation of the links more complex by adding other fields to the HateoasLink annotation and add some more customization to the displaying of the operations. Overall, you made your API more flexible and easier to interact with, and closer to the perfectly RESTful API.