Skip to content
Logo Theodo

Love HATEOAS with Symfony and API Platform

Thomas Eudes10 min read

Symfony, API Platform and REST logos

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:

Among the possible drawbacks of adding HATEOAS to your services, let’s mention:

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

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.

API Platform serialization process.

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.

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);
        }
    }
}

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"
    }
  }
}

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.

Liked this article?