Using HTTP client abstraction in eZ Platform

by Mario Blažek -

With the appearance of the PSR-7 standard defined by the Framework Interoperability Group, PHP language received the first standardized way of interacting with HTTP messages and URIs. This standard was mostly accepted by libraries and frameworks. Symfony did not accept this PSR in the framework, as it would require a big rewrite of its by now proven and a stable component called the HttpFoundation Component. They supported it through the PSR-7 Bridge. This PSR triggered the appearance of some other standards like PSR-17 which describes a common standard for factories that create PSR-7 compliant HTTP objects and PSR-18 which we will discuss in more detail in this blog post.

The need for HTTP client abstraction

The long-lasting PHP ecosystem brought so many good HTTP clients to life – from the simple curl extension and PHP streams to Guzzle, Buzz or the newest addition – Symfony HttpClient Component. Altogether, you just can’t select a bad option. While using the PSR-17 compliant factories, you still need to depend on concrete HTTP client implementation. That is not such a problem until you realize that your colleague is utilizing the HTTP client of his choice. I don’t remember that we ever sat together, discussed HTTP client usage and agreed on the default one – it always came down to a matter of personal preference. Here’s an even worse scenario – your HTTP client of choice just decided to introduce a new namespace. At the end of a project, there is hardly any time to rewrite the code to use the single HTTP client implementation consistently.

Future looks bright and abstracted

A group of people around Márk Sági-Kazár started defining an interface for the HTTP client, called HTTPlug. There was a workshop at Web Summer Camp about HTTPlug by Mark and David, and you can watch it here. In HTTPlug you always tie your code to an HttpClient interface and not to a concrete implementation, which we all want. All you need to do is install your client of choice and a corresponding HTTPlug adapter for the specific client. The idea of HTTPlug was so good that the PHP-FIG adopted its client interface as PSR-18 “HTTP Client” specification.

PSR-18 defines how to send PSR-7 requests without binding your code to a specific client implementation. This is especially interesting for reusable libraries that need to send HTTP requests but don’t care about specific client implementations. PSR-18 defines a very simple \Psr\Http\Client\ClientInterface interface with a single sendRequest() method. It takes PSR-7 compatible Request object and returns the PSR-7 compatible Response object while defining the common ground for errors in the form of \Psr\Http\Client\ClientExceptionInterface.

HTTP abstraction in eZ Platform

Now when we are familiar with the HTTPlug and PSR-18 it is time to put it into practice. First, we need to install the HTTPlug integration for Symfony Framework – the HTTPlug Bundle – and configure it with our HTTP client implementation of choice. I like the curl, so I’m going to use the Curl client implementation. Require those two packages via composer:

composer require "php-http/httplug-bundle:^1.15.2" "php-http/curl-client:^1.7.1"

Activate HTTPlug Bundle inside AppKernel’s registerBundles() method by adding this line:

new Http\HttplugBundle\HttplugBundle();

The next thing we need to do is to tell the HTTPlug which client and factory implementations we are going to use. For now, we will stick with Curl implementation. Add these lines to the app/config/config.yml file: 

# Httplug configuration
httplug:
    plugins:
        logger: ~
    clients:
        app:
            factory: httplug.factory.curl
            plugins: [httplug.plugin.logger]

We are ready to make requests to the remote API.

The Cat API

For the purpose of this blog post, we are going to use the well-known Cat API. To be specific, we will consume only one endpoint List the Categories. The first and mandatory step to work with the Cat API is to create your personal API key. This can be done very easily – just go to the signup page, enter some valid email and description, an email with a token should arrive soon to your mailbox. Our responsibility is to send this valid token as an x-api-key header in every request we make. The API token and categories endpoint documentation is everything we need from the Cat API. From this point, we can proceed to write the eZ Platform integration.

Consumer service

As a starting point, I’m going to use the clean eZ Platform installation installed via Composer. Let’s create a class which will contain PHP code for interacting with the Cat API. Inside the AppBundle create a new directory with the name Http. This is going to be our home directory for our CatApiClient class. We will need three dependencies for this class:

  • \Http\Client\HttpClient - HTTP client implementation
  • \Http\Message\MessageFactory - a message factory for creating the PSR-7 compliant HTTP request object
  • \eZ\Publish\Core\MVC\ConfigResolverInterface - ConfigResolver instance for retrieving the API token from siteaccess aware configuration

So the complete implementation will look like this:

<?php

declare(strict_types=1);

namespace AppBundle\Http;

use eZ\Publish\Core\MVC\ConfigResolverInterface;
use Http\Client\HttpClient;
use Http\Message\MessageFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class CatApiClient
{
    /**
    * @var \eZ\Publish\Core\MVC\ConfigResolverInterface
    */
    private $configResolver;

    /**
    * @var \Http\Client\HttpClient
    */
    private $httpClient;

    /**
    * @var \Http\Message\MessageFactory
    */
    private $messageFactory;

    public function __construct(
        ConfigResolverInterface $configResolver,
        HttpClient $httpClient,
        MessageFactory $messageFactory
    ) {
        $this->configResolver = $configResolver;
        $this->httpClient = $httpClient;
        $this->messageFactory = $messageFactory;
    }

    public function getCategories(): array
    {
        $request = $this->messageFactory
            ->createRequest(
                Request::METHOD_GET,
                $this->configResolver->getParameter('cats_api.url', 'app'),
                [
                    'x-api-key' => $this->configResolver->getParameter('cats_api.token', 'app'),
                ]
        );

        try {
            $response = $this->httpClient->sendRequest($request);
        } catch (\Exception $exception) {
            return [];
        }

        if ($response->getStatusCode() !== Response::HTTP_OK) {
            return [];
        }

        return json_decode(
            (string)$response->getBody(),
            true
        );
    }
}

Steps are very simple and descriptive. It is worth noting that code will work on any other HTTP client implementation that is supported within the HTTPlug library. The next step is to register our consumer service with the Symfony’ Service Container:

# Learn more about services, parameters and containers at
# https://symfony.com/doc/current/service_container.html
parameters:
    app.default.cats_api.token: 'your-token-here'
    app.default.cats_api.url: 'https://api.thecatapi.com/v1/categories'

services:
    app.http.cat_api_client:
        class: AppBundle\Http\CatApiClient
        public: true
        arguments:
            - '@ezpublish.config.resolver'
            - '@httplug.client.app'
            - '@httplug.message_factory'

Notice that the HTTP client service name is related to our httplug.client.app configuration key. We are ready to use our consumer service within the eZ Platform.

eZ Platform integration

On the eZ Platform side, we will simply output the list of available categories received from the Cat API. For this purpose, we are going to create a simple controller and output rendered Twig template. To the CatApiController will depend on our CatApiClient:

<?php

declare(strict_types=1);

namespace AppBundle\Controller;

use AppBundle\Http\CatApiClient;
use eZ\Publish\Core\MVC\Symfony\Controller\Controller;
use eZ\Publish\Core\MVC\Symfony\View\ContentView;

final class CatApiController extends Controller
{
    /**
    * @var \AppBundle\Http\CatApiClient
    */
    protected $client;

    public function __construct(CatApiClient $client)
    {
         $this->client = $client;
    }

    public function __invoke(ContentView $view): ContentView
    {
        $view->setParameters(
            [
                'categories' => $this->client->getCategories(),
            ]
       );

    return $view;
}
}

Register it as a service:

services:
    app.controller.cat_api:
        class: AppBundle\Controller\CatApiController
        parent: ezpublish.controller.base
        arguments:
            - '@app.http.cat_api_client'

Create new Twig template under app\Resources\views\themes\standard\default\content directory, called full.html.twig:

{% extends '@ezdesign/pagelayout.html.twig' %}

{% block content %}
    <h1>TheCatAPI - Cats as a Service, Everyday is Caturday.</h1>

    <h3>Available categories</h3>

    {% if categories|length > 0 %}
        <ul>
        {% for category in categories %}
            <li>ID: {{ category.id }} name: <strong>{{ category.name }}</strong></li>
        {% endfor %}
        </ul>
    {% else %}
        <p>There is no available categories.</p>
    {% endif %}
{% endblock %}

Define the view configuration for our controller – to keep it simple, it is going to be mapped for the root location, the location with the identifier 2.

ezpublish:
    system:
        site_group:
            content_view:
                full:
                    main_page:
                        template: '@ezdesign/default/content/full.html.twig'
                        controller: 'app.controller.cat_api'
                        match:
                            Id\Location: 2

And that’s it. If you open the browser, you should see the list of available categories from the Cat API. With HTTPlug bundle comes very neat Symfony Profiler integration where you can track all the relevant HTTP calls.

http 1
http 3

In the next blog post, we will show you how to do the same within Netgen Layouts.

Switch to another client implementation

Now when we are using the HTTPlug interface, it is effortless to switch to another HTTP client implementation. Imagine we want to switch to Guzzle6 HTTP client. The easiest way to do that is to simply replace the factory setting under httplug to httplug.factory.guzzle6, but first we need to install Guzzle6 compatible adapter:

composer require "php-http/guzzle6-adapter:~1.0"

And now the config will look like this:

# Httplug configuration
httplug:
    plugins:
        logger: ~
    clients:
        app:
            factory: httplug.factory.guzzle6
            plugins: [httplug.plugin.logger]

We should now be able to refresh the main page and still receive the same results, but this time we are using Guzzle6 instead of curl client. Fantastic. Userland code is untouched, and everything works. We can even go further by using both clients in a single project:

# Httplug configuration
httplug:
    plugins:
        logger: ~
    clients:
        app:
            factory: httplug.factory.guzzle6
            plugins: [httplug.plugin.logger]
        second:
            factory: httplug.factory.curl
            plugins: [httplug.plugin.logger]

This will generate two client services httplug.client.app and httplug.client.second, which can be injected wherever is needed.

PSR-18 and beyond

All that hard work of HTTPlug people resulted in PHP-FIG adopted HTTP client standard in the form of PSR-18. This is a huge benefit for the whole PHP ecosystem, and the standards are the way we strive to – having a single interface to write your code against complies with all practices and methodologies of a Clean code.

Comments

This site uses cookies. Some of these cookies are essential, while others help us improve your experience by providing insights into how the site is being used.

For more detailed information on the cookies we use, please check our Privacy Policy.

  • Necessary cookies enable core functionality. The website cannot function properly without these cookies, and can only be disabled by changing your browser preferences.