Abstracting API calls with Symfony serializer

Abstracting API integrations using tagged services and the Symfony serializer in order to speed up implementations of multiple and future endpoints

Abstracting API calls with Symfony serializer

As you can guess from reading the title this is not a `getting started with` article but it's about putting the Symfony components to work in order to solve day to day obstacles.

But what did we solve?

In our case, a client requested for a set of tools to be integrated into their site. So simply put we needed to collect data, push it to an endpoint which on its turn calculates something and returns the result that needs to be displayed, nothing fancy there.

But implementing this in an ordinary fashion (separated one by one) is ok if you are quite certain it's just 1 or 2 endpoints and there will be no future expansions. But let's be honest is that ever the case?

Anyway, in our case there where about 5 endpoints and a high probability that that number will increase in the near future. Implementing them one by one, in a similar way, would create a lot of duplicate logic and its a boring repetitive task as well.

The one problem with abstracting is that is not simple duplicate code but duplicate logic so at first it might seem that the implementation for each call is different: different endpoint, data, and response. But it is the overall idea that is the same. So what we want is something that can cope with all the different formats but abstracts the general idea of:  "data to an endpoint to response".

But since talk is cheap, it is time to show the code.

I'll go over the concept by implementing an imaginary API endpoint so it's easier to understand and maybe use some ideas from it.

The basic concept consists of the following chunks:

  • The API endpoints (clients)
  • Request data models
  • Response handlers

The API endpoints (clients)

Each endpoint has its own URL, method (POST, GET, ...) and response format. In order to abstract this, we need to create a client class that takes these options and is capable of handling them in a correct way. Once we have this class an instance per endpoint will be created and registered in the DI container.

First off we will wrap these options in a value object to prevent breaking the contract of the ClientInterface if some edge cases are introduced later on. This object is also created and registered in the DI container (can be done from config if you want).

<?php

class RequestOptions
{
    /** @var string */
    private $method;

    /** @var string */
    private $route;

    /** @var string */
    private $responseClass;

    public function __construct(string $method, string $route, string $responseClass) {

    // set props and define getters and setters ...

 

The client class which gets this config injected is the core of the abstraction. For each endpoint we will integrate, an instance of this needs to be registered in the DI container.

<?php

use RequestOptions;
use ResponseHandler;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

class ApiClient implements ApiClientInterface
{
    /** @var Serializer */
    private $serializer;

    /** @var UrlGeneratorInterface */
    private $urlGenerator;

    /** @var ResponseHandler */
    private $responseHandler;

    /** @var RequestOptions */
    private $requestOptions;

    public function __construct(
        Serializer $serializer,
        RequestOptions $requestOptions,
        ResponseHandler $responseHandler,
        UrlGeneratorInterface $urlGenerator
    ) {
       // set all props ...
    }

    /**
     * @var $data mixed
     */
    public function request($data)
    {
        $bodyParams = $this->serializer->normalize($data, null, ['groups' => ['body']]);
        $urlParams = $this->serializer->normalize($data, null, ['groups' => ['urlParam']]);

        $url = $this->urlGenerator->generate($this->requestOptions->getRoute(), $urlParams);

        try {
            $response = $this->executeRequest($url, $bodyParams);
        } catch (ClientException $exception) {
            $response = $exception->getResponse();
        }

        return $this->responseHandler->handle($response, [
            'response_class' => $this->requestOptions->getResponseClass(),
        ]);

    private function executeRequest(string $url, $data): Response
    {
        $client = new Client();

        if (Request::METHOD_POST === $this->requestOptions->getMethod()) {
            return $client->post($url, ['json' => $data]);
        }

        return $client->get($url, ['query' => $data]);
    }
}

Let's break this class down to better understand each part of it.

So as you noticed it exposes only one method: request. Which takes a mixed object as an argument. The type of this object cannot be defined up front since it differs for each endpoint.

To prepare the data in order to push it to an endpoint we make use of the normalize functionality of the Symfony serializer which turns an object into a key/value array.

$bodyParams = $this->serializer->normalize($data, null, ['groups' => ['body']]);
$urlParams = $this->serializer->normalize($data, null, ['groups' => ['urlParam']]);

As you notice we split our data by making use of serialization groups: 'body' and 'urlParam'.  This is because sometimes an endpoint takes arguments embedded in the URL like: /api/v1/user/update/{userId} and the rest of it in the body of the request.

The groups defined on the models act as metadata so the client knows where to put them. For the body params, the process is quite straight forward, just convert the array to a JSON string and you are good to go. But for the ones embedded in URLs, we make use of Symfony's URL generator. By defining the URL with placeholders that match the property names of the data object we can have the URL generator worry about putting them in the right place.

$url = $this->urlGenerator->generate($this->requestParams->getRoute(), $urlParams);

Now that the URL and the data are ready we can make the API call, I'm not going into that since it is nothing more than just a simple call. The response that is returned is then passed on to a  group of response handler each responsible for a certain type, but more on that later.

Request data models

Below you can see an example object that would hold API data. It's just a simple plain object, the only thing noticeable are the annotations that indicate which params need to be embedded in the URL and which into the body of the request.
<?php

use Symfony\Component\Serializer\Annotation\Groups;

class ExampleModel
{
    /**
     * @var int
     * @Groups({"urlParam"})
     */
    private $id;

    /**
     * @var string
     * @Groups({"body"})
     */
    private $name;

    /**
     * @var string
     * @Groups({"body"})
     */
    private $email;

    // Getters and setters ...

Response handlers

Most of the endpoints will return a JSON string which we can just deserialize into an object. But to make things more flexible we make use of a pool of services where each one is capable of handling a specific type of response. For example JSON, PDF, .... 

We will make use of tagged services, to inject all our handlers and loop over them to find one that supports the current format.

class ResponseHandler
{
    /**
     * @var ResponseHandlerInterface[]
     */
    private $handlers;

    public function __construct(iterable $handlers = [])
    {
        $this->handlers = $handlers;
    }

    public function handle(Response $response, array $options = []): ApiResponseInterface
    {
        $contentType = $response->getHeader('Content-Type')[0] ?? '';

        foreach ($this->handlers as $handler) {
            if (!$handler instanceof ResponseHandlerInterface || !$handler->supportsType($contentType)) {
                continue;
            }

            return $handler->handle($response->getBody()->getContents(), $options);
        }

        throw new \InvalidArgumentException(sprintf('No response handler registered for content type %s', $contentType));
    }
}
Below is an example of the JSON response handler. As you can see, it gets the response content and an array of options which in this case will hold the class name of the object we will deserialize the data into.
class JsonResponseHandler implements ResponseHandlerInterface
{
    public const SUPPORTED_TYPE = 'application/json';

    /** @var SerializerInterface */
    private $serializer;

    public function __construct(SerializerInterface $serializer)
    {
        $this->serializer = $serializer;
    }

    public function supportsType(string $contentType): bool
    {
        return false !== strpos($contentType, self::SUPPORTED_TYPE);
    }

    public function handle($responseContent, array $options = []): ApiResponseInterface
    {
        /** @var ApiResponseInterface $response */
        $response = $this->serializer->deserialize($responseContent, ApiResponse::class, 'json');

        /** @var ApiResponseDataInterface $responseData */
        $responseData = $this->serializer->deserialize($responseContent, $options['response_class'], 'json');

        $response->setData($responseData);

        return $response;
    }

Wrapping up

As you can see, this takes quite some work to set up so for integrating just a few endpoints it would add a lot of overhead. But when implementing a lot of them you could even take it a step further and generate these services based on a YAML file that describes the response classes and endpoint urls.