user3390352
user3390352

Reputation: 77

Client agnostic API Wrapper using PSR 7, 17 and 18 instead of Guzzle

PSR

The introduction of PSR-7, PSR-17 and PSR-18 is all part of a plan to make it possible to

build applications that need to send HTTP requests to a server in an HTTP client agnostic way

See PSR-18: The PHP standard for HTTP clients

I have been working with many applications that have historically relied heavily on Guzzle instead of abstract interfaces. Most of these applications make simple API request using GET or POST request containing a JSON body and responses also containing a JSON body or throwing exceptions for HTTP 4xx or 5xx errors.

API Wrapper

This question comes from a recent project where I tried to develop an API package that did not explicitly rely on Guzzle but instead only on the PSR interfaces.

The idea was to make a class ApiWrapper that could be initiated using:

  1. An HTTP client fulfilling the PSR-18 ClientInterface
  2. A Request Factory fulfilling the PSR-17 RequestFactoryInterface
  3. A Stream Factory fulfilling the PSR-17 StreamFactoryInterface

This class would have anything it needs to:

  1. Make a request (PSR-7) using the Request Factory and Stream Factory
  2. Send a request using HTTP client
  3. Handle the response - since we know this will fulfill the PSR-7 ResponseInterface

Such an API wrapper would not rely on any concrete implementation of the above interfaces but it would merely require any implementation of these. Hence the developer would be able to use his or her favorite HTTP client instead of being forced to use a specific client like Guzzle.

Problem

Now, first of all, I truly love Guzzle, this is not a post to dispute the awesomeness of Guzzle, this is just a post asking how to make it possible for the developers to choose the correct http client for their needs.

But the problem is that relying explicitly on Guzzle provides a lot of nice functionality since Guzzle does more than the above. Guzzle also applies a range of handlers and middlewares like following redirects or throwing exceptions for HTTP 4xx responses.

Question

Long description, but here comes the question: How can one deal with common HTTP request handling like following redirects or throwing exceptions for HTTP 4xx responses in a controlled manner (hence yielding the same response regardless of the HTTP client used) without having to specify exactly what HTTP client to use?

Example

Here is an example of the ApiWrapper implementation:

<?php

use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;

/*
 * API Wrapper using PSR-18 ClientInterface, PSR-17 RequestFactoryInterface and PSR-7 RequestInterface
 *
 * Inspired from: https://www.php-fig.org/blog/2018/11/psr-18-the-php-standard-for-http-clients/
 * Require the packages `psr/http-client` and `psr/http-factory`
 *
 * Details about PSR-7 taken from https://www.dotkernel.com/dotkernel3/what-is-psr-7-and-how-to-use-it/
 *
 * Class Name                               Description
 * Psr\Http\Message\MessageInterface        Representation of a HTTP message
 * Psr\Http\Message\RequestInterface        Representation of an outgoing, client-side request.
 * Psr\Http\Message\ServerRequestInterface  Representation of an incoming, server-side HTTP request.
 * Psr\Http\Message\ResponseInterface       Representation of an outgoing, server-side response.
 * Psr\Http\Message\StreamInterface         Describes a data stream
 * Psr\Http\Message\UriInterface            Value object representing a URI.
 * Psr\Http\Message\UploadedFileInterface   Value object representing a file uploaded through an HTTP request.
 */

class ApiWrapper
{
    /**
     * The PSR-18 compliant ClientInterface.
     *
     * @var ClientInterface
     */
    private $psr18HttpClient;

    /**
     * The PSR-17 compliant RequestFactoryInterface.
     *
     * @var RequestFactoryInterface
     */
    private $psr17HttpRequestFactory;

    /**
     * The PSR-17 compliant StreamFactoryInterface.
     *
     * @var StreamFactoryInterface
     */
    private $psr17HttpStreamFactory;

    public function __construct(
        ClientInterface $psr18HttpClient,
        RequestFactoryInterface $psr17HttpRequestFactory,
        StreamFactoryInterface $psr17HttpStreamFactory,
        array $options = []
    ) {
        $this->psr18HttpClient($psr18HttpClient);
        $this->setPsr17HttpRequestFactory($psr17HttpRequestFactory);
        $this->setPsr17HttpStreamFactory($psr17HttpStreamFactory);
    }

    public function psr18HttpClient(ClientInterface $psr18HttpClient): void
    {
        $this->psr18HttpClient = $psr18HttpClient;
    }

    public function setPsr17HttpRequestFactory(RequestFactoryInterface $psr17HttpRequestFactory): void
    {
        $this->psr17HttpRequestFactory = $psr17HttpRequestFactory;
    }

    public function setPsr17HttpStreamFactory(StreamFactoryInterface $psr17HttpStreamFactory): void
    {
        $this->psr17HttpStreamFactory = $psr17HttpStreamFactory;
    }

    public function makeRequest(string $method, $uri, ?array $headers = [], ?string $body = null): RequestInterface
    {
        $request = $this->psr17HttpRequestFactory->createRequest($method, $uri);

        if (! empty($headers)) {
            $request = $this->addHeadersToRequest($request, $headers);
        }

        if (! empty($body)) {
            $stream = $this->createStreamFromString($body);
            $request = $this->addStreamToRequest($request, $stream);
        }

        return $request;
    }

    /**
     * Add headers provided as nested array.
     *
     * Format of headers:
     * [
     *   'accept' => [
     *     'text/html',
     *     'application/xhtml+xml',
     *   ],
     * ]
     * results in the header: accept:text/html, application/xhtml+xml
     * See more details here: https://www.php-fig.org/psr/psr-7/#headers-with-multiple-values
     *
     * @param  \Psr\Http\Message\RequestInterface  $request
     * @param  array  $headers
     * @return \Psr\Http\Message\RequestInterface
     */
    public function addHeadersToRequest(RequestInterface $request, array $headers): RequestInterface
    {
        foreach ($headers as $headerKey => $headerValue) {
            if (is_array($headerValue)) {
                foreach ($headerValue as $key => $value) {
                    if ($key == 0) {
                        $request->withHeader($headerKey, $value);
                    } else {
                        $request->withAddedHeader($headerKey, $value);
                    }
                }
            } else {
                $request->withHeader($headerKey, $headerValue);
            }
        }

        return $request;
    }

    /**
     * Use the PSR-7 complient StreamFactory to create a stream from a simple string.
     *
     * @param  string  $body
     * @return \Psr\Http\Message\StreamInterface
     */
    public function createStreamFromString(string $body): StreamInterface
    {
        return $this->psr17HttpStreamFactory->createStream($body);
    }

    /**
     * Add a PSR 7 Stream to a PSR 7 Request.
     *
     * @param  \Psr\Http\Message\RequestInterface  $request
     * @param  \Psr\Http\Message\StreamInterface  $body
     * @return \Psr\Http\Message\RequestInterface
     */
    public function addStreamToRequest(RequestInterface $request, StreamInterface $body): RequestInterface
    {
        return $request->withBody($body);
    }

    /**
     * Make the actual HTTP request.
     *
     * @param  \Psr\Http\Message\RequestInterface  $request
     * @return \Psr\Http\Message\ResponseInterface
     * @throws \Psr\Http\Client\ClientExceptionInterface
     */
    public function request(RequestInterface $request): ResponseInterface
    {
        // According to PSR-18:
        // A Client MUST throw an instance of Psr\Http\Client\ClientExceptionInterface
        // if and only if it is unable to send the HTTP request at all or if the
        // HTTP response could not be parsed into a PSR-7 response object.

        return $this->psr18HttpClient->sendRequest($request);
    }
}

Upvotes: 5

Views: 2825

Answers (1)

Jason
Jason

Reputation: 4772

Here is my take, largely based on trying out a few approaches.

Any PSR-18 client will have an interface that it must conform to. That interface is essentially just one method - sendRequest(). That method will send a PSR-7 request and return a PSR-7 response.

Most of what goes into the request will be used to construct the PSR-7 request. That would be put together before it hits the sendRequest() of the client. What the PSR-18 specification does not define is the behaviour of the client, such as whether to follow redirects. It does specify that exceptions should not be thrown in the event of a non-2XX response.

That may seem very restrictive, but this client is the end of the line, it is just concerned with the physical sending of the request and the capturing of the response. Everything else about the behaviour of the client can be built into middleware to extend that client.

So what can PSR-18 middleware do?

  • It has access to the original PSR-7 request, so that request can be read and changed.
  • It has access to the PSR-7 response, so it can modify the response, and take actions based on that response.
  • It makes the sendRequest() call, so can apply logic in how that is handled, such as retries, following redirects and so forth.

The PSR-18 specification does not mention middleware, so where would that sit? One way to implement that could be a decorator. The decorator wraps around the base PSR-18 client, adding functionality, but will present itself as a PSR-18 client. This means multiple decorators can be layered on the base client to add any number of features you like.

Here is an example of a PSR-18 decorator. This decorator essentially does nothing, but provides a framework to put logic into.

use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

class Psr18Decorator implements ClientInterface
{
    // ClientInterface

    protected $client;

    // Instantiate with the current PSR-18 client.
    // Options could be added here for configuring the decorator.

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

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        // The request can be processed here.

        // Send the request, just once in this example.

        $response = $this->client->sendRequest($request);

        // The response can be processed or acted on here.

        return $response;
    }

    // This is added so that if a decorator adds new methods,
    // they can be accessed from the top, multiple layers deep.

    public function __call($method, $parameters)
    {
        $result = $this->client->$method(...$parameters);

        return $result === $this->client ? $this : $result;
    }
}

So given the base PSR-18 client, it can be decorated like this:

$decoratedPsr18Client = new Psr18Decorator($basePsr18Client);

Each decorator can be written to handle a single concern. You may, for example, want to throw an exception if the response does not return a 2XX code. A decorator could be written to do that.

Another decorator could handle OAuth tokens, or monitor access to an API so it can be rate limited. Another decorator could follow redirects.

So, do you need to write all these decorators yourself? For now, yes, because there is an unfortunate lack of them around. However, as they are developed and published as packages, they will essentially be reusable code that can be applied to any PSR-18 client.

Guzzle is great, and has a lot of features, and is monolithic in that respect. I believe the PSR-18 approach should allow us to break down all those features into smaller self-contained chunks so they can be applied on an as-needed basis. A decorator management package may help to add these decorators (perhaps ensuring they ordered correctly and compatible with each other) and perhaps handling the decorator custom methods differently to avoid the need for the __call() fallback.

I'm sure there are other approaches, but this one has worked well for me.

Upvotes: 4

Related Questions