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:
- An HTTP client fulfilling the PSR-18
ClientInterface - A Request Factory fulfilling the PSR-17
RequestFactoryInterface - A Stream Factory fulfilling the PSR-17
StreamFactoryInterface
This class would have anything it needs to:
- Make a request (PSR-7) using the Request Factory and Stream Factory
- Send a request using HTTP client
- 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);
}
}
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?
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.
So given the base PSR-18 client, it can be decorated like this:
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.