PSR-18
Abstracting HTTP Clients in PHP
PHP Day, Verona - 20.5.2022
© David Buchmann
David Buchmann - david@liip.ch
PHP Engineer, Liip AG, Switzerland
What is the difference between
guzzle/guzzle
and
guzzlehttp/guzzle?
Depending on an implementation
Once upon a time
FOSHttpCache needs to send HTTP requests to varnish for cache invalidation
HTTPlug
=> Márk Sági-Kazár
- HTTP client interfaces
- HTTPlug adapters for all major HTTP clients
- FOSHttpCache 2.0 depends on HTTPlug instead of Guzzle 6 or 7
PHP Framework Interoperability Group (PHP-FIG)
- Initiative of PHP projects to improve interoperability
- Defines PHP Standard Recommendations (PSR)
- PSR-4: Autoloader
App\Model\Product => src/Model/Product.php
- PSR-3: Logger
HTTP Request and Response: PSR-7
=> Matthew Weier O'Phinney
- Interface for request
- Extended interface for server side request
- Interface for response
- UriInterface to manipulate URIs
- For server side applications, this is all you need
- When building a client, you need more!
How to create an object without knowing the class? PSR-17
interface RequestFactoryInterface
{
function createRequest(string $method, $uri)
: RequestInterface;
}
HTTP Message Factories: PSR-17
=> Woody Gilk
- Request factory
- Stream factory
- URI factory
- (Server Request factory, Uploaded File factory, Response factory)
How to send that request? PSR-18
interface ClientInterface
{
function sendRequest(RequestInterface $request)
: ResponseInterface;
}
private RequestFactoryInterface $reqFactory;
private ClientInterface $httpClient;
public function getProduct(string $id): Product
{
$url = '/api/product/'.$id;
$req = $reqFactory->createRequest('GET', $url);
$response = $this->client->sendRequest($req);
if (200 !== $response->getStatusCode()) {
// error handling ...
}
// deserialize body into model...
What is PSR-18?
HTTP Client: PSR-18
=> Tobias Nyholm
- Psr\Http\Client\ClientInterface
- And a bunch of exceptions...
- ClientExceptionInterface: base exception
- RequestExceptionInterface: request invalid
- NetworkExceptionInterface: DNS issue with target host, connection error
Interchangeable: Defined behaviour
- Consumers of the client must know what to expect
- No exceptions for HTTP reponses with error status codes, those are valid responses on HTTP level
- Instead, have your client layer check response codes and throw domain specific exceptions
What about configuration?
- Timeout settings, HTTPS, follow redirects, ...
- Not a decision for each single endpoint
- Configure on the implementation
(e.g. constructor arguments)
- Inject configured client to caller
But...
sendRequest(RequestInterface $request, $config)
- Would make the PSR define configuration options
- What about unknown options?
- Or limit to a known set?
- Instead, if you need different behaviours, inject multiple clients for specific purposes.
Injecting multiple clients
public function __construct(
ClientInterface $publicHttpClient,
ClientInterface $apiHttpClient
) {
...
API client with host and base path configured, authentication already set up
PSR-18 in reusable libraries
composer.json
"require": {
"psr/http-client-implementation": "^1.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4"
}
- Provided by guzzle and a bunch of others
- Install concrete client for development
- Consumers choose the implementation they prefer
Bootstrap
- Let your consumers inject the client and request factory objects
- We will talk about autodiscovery a bit later
What about the cute elephant?
Httplug is still useful
- Httplug Client interface extends PSR-18 interface, allowing a smooth upgrade path
- PSR-18 adapters for existing clients like old versions of Guzzle
- Middlewares for PSR-18 HTTP clients
- Convenience clients on top of PSR-18
- Autodiscovery
Httplug PluginClient
public function handleRequest(
RequestInterface $request,
callable $next,
callable $first
): Promise;
- AddHost, AddPath, QueryDefaults
- Header manipulation
- Authentication, Cookie, Redirect, Retry
- ContentLenght, ContentType, Decoder
Cache Plugin
- Cache responses with a PSR-6 cache
- Respect Cache-Control response header if desired
- Are we a proxy or a single client?
CachePlugin::clientCache - max-age, no-cache
CachePlugin::serverCache - additionally: private
Httplug Authentication
- AuthPlugin
- Authentication interface
- Basic (username:password), Bearer, Wsse
- Chain, Matching, RequestConditional
- Alter request to add auth credentials
- If you need a token, inject another client instance to the auth to request the token
Other PSR-18 decorators
- HttpClientPool: Round robin or least recently used over PSR-18 instances
- HttpClientRouter: Use request matcher to chose which client to use for each request
BatchClient
/**
* @param RequestInterface[] The requests to send
*
* @throws BatchException If any request threw
*/
public function sendRequests(
array $requests
): BatchResult
HttpMethodsClient
public function get($uri, array $headers = []):
public function post($uri, array $headers = [],
public function put($uri, array $headers = [],
public function send(string $method, $uri, arr
Httplug discovery
- I personally prefer dependency injection
- Static methods to instantiate
- PSR-17 factories
- PSR-18 client
- or Httplug clients / factories
composer require php-http/discovery
public function __construct(
?ClientInterface $httpClient = null,
?RequestFactoryInterface $requestF = null
) {
$this->httpClient = $httpClient ?:
Psr18ClientDiscovery::find();
$this->requestF = $requestF ?:
Psr17FactoryDiscovery::findRequestFactory();
...
Still allow user to provide client instance!
Symfony HttplugBundle
- Configure PSR-18 Http clients
- Configure Httplug clients
- Configure plugins
- Symfony toolbar integration
- composer require php-http/httplug-bundle
HttplugBundle configuration
httplug:
clients:
app:
http_methods_client: true
plugins:
- header_set:
headers:
"User-Agent": "demo-app"
HTTP request in Symfony
private HttpMethodsClient $httpClient;
public function status(): Response {
try {
$r = $this->httpClient->get('http://php.net/');
} catch (Exception $e) {
return new Response('Failed', 502);
}
return new Response(200 === $r->getStatusCode()
? 'Success'
: 'Error');
Outlook
Who can promise a Promise?
sendAsync(RequestInterface $request): Promise;
Promise::wait(): ResponseInterface;
- PSR for asynchronous clients would be nice, but first needs a standard for Promises
- The PSR for HTTP clients is not the right place to define Promises
PHP 8.1 introduces the Fibers concept
PHP 8.1: Fibers
- Interruptible functions that can suspend themselves from anywhere
- Need to use non-blocking functions - fibers are not threads
- reactphp implements asynchronous HTTP requests with fibers
- php-http/react-adapter is currently being adapted for this
Meanwhile: Use Httplug with Promises
interface Promise
{
public const PENDING = 'pending';
public const FULFILLED = 'fulfilled';
public const REJECTED = 'rejected';
public function then(?callable $onFulfilled,
public function getState();
public function wait($unwrap = true);
}
Fire off requests
try {
$promises = [];
foreach ($uris as $u) {
$promises[$u] = $httpClient->sendAsyncRequest(
$requestFactory->createRequest('GET', $u)
);
}
} catch (\Exception $e) {
return new Response('Configuration error', 500);
}
Wait for it
foreach ($promises as $uri => $promise) {
$s .= $uri.':';
try {
$r = $promise->wait();
if (200 === $r->getStatusCode()) {
$s .= 'Up and running';
} else {
$s .= 'Error: '.$r->getStatusCode();
}
} catch (\Exception $e) {
$s .= 'Network Error: '.$e->getMessage();
}
}
then()
$results = [];
$promises = [];
foreach ($uris as $u) {
$promise = $httpClient->sendAsyncRequest(
$requestFactory->createRequest('GET', $u));
$onFulfilled = function (ResponseInterface $r)
use ($u, $results) {
if (200 === $r->getStatusCode()) {
$results[$u] = 'Up and running';
} else {
$results[$u] = 'Error: '.$r->getStatusCode();
}
};
}
then() callbacks
...
$onRejected = function (Exception $r)
use ($u, $results) {
$results[$u] = 'Error: '.$e->getMessage();
};
$promise->then($onFulfilled, $onRejected);
$promises[] = $promise;
}
} catch (Exception $e) {
return new Response('Configuration error', 500);
}
Wait and build result page
foreach ($promises as $promise) {
$promise->wait(false);
}
foreach ($results as $u => $text) {
$s .= $uri.':'.text;
}