Guzzle Middleware to Handle External Validation Failures

James Bannister • July 10, 2021 • 3 minute read

laravel

In this post I want to show how we use Guzzle middleware with Guzzle and Laravel Http clients to catch 422 validation failures from external services and turn them into local validation failures. This approach can be used for catching other errors, and you'd just need to adjust it to cover your use case. I'll start with a little backstory which explains why we wrote this, but if you're just after the code then just scroll down.

At my day job we run a number of different projects/services to support the platform we provide. These projects/services are what we call "macroservices" or "microliths"; they are essentially focussed monoliths that handle a specific domain concern. For example, our Companies service. This service has its own database, frontend, backend, and API and handles all things to do with companies and what a company can offer on our platform. We have around 20 different services in our constellation, and these primarily communicate with each other via REST APIs.

Some of our projects are wrappers that tie together a number of services and provide a portal for a particular type of user on our platform. These may store their own data in a database, but are fairly pure in the sense that they defer to a lot of other services in the background to persist and fetch data. We tend to end up writing a form request in the portal that validates a user's submission, but the service that the portal then talks to via an API will also have validation rules on its endpoint.

The validation rules are usually in sync, but occasionally there are rules that you cannot support in a different service. For example, a unique constraint on an email or username, without access to the datasource, you couldn't write a validation rule for it. You could write a custom rule into the portal to check against the service locally, but this is just one such example that is somewhat simplified. So, from time to time, we have requests that will pass validation in the portal, but fail against the service due to one of these rules.

If you're running Guzzle or the Laravel Http client with http_errors enabled, a 422 failure from an HTTP call will cause an exception, and if not handled cause a 500 for your users. If you have http_errors disabled (which is the default with the Laravel Http client) then the data returned may not be in the format you expected (given you have a validation failure payload).

In the apps that are client/customer facing, we want to try and make any errors as user-friendly as possible, and a 422 failure from an upstream service could be turned into local validation errors and then displayed nicely to your users.

We can achieve this using Guzzle middleware, which you can use with both the Guzzle client and the Laravel Http client.

First, we'll create the middleware class called ProxyValidationErrors. This can live wherever you like, but I opted to put it in App\Http\Guzzle:

1<?php
2 
3namespace App\Http\Guzzle;
4 
5use Illuminate\Support\Facades\Validator;
6use Illuminate\Validation\ValidationException;
7use Psr\Http\Message\RequestInterface;
8 
9class ProxyValidationErrors
10{
11 public function __invoke($handler)
12 {
13 return function (RequestInterface $request, array $options) use (&$handler) {
14 return $handler($request, $options)->then(function ($response) {
15 if ($response->getStatusCode() === 422) {
16 throw new ValidationException(
17 tap(
18 Validator::make(request()->all(), []),
19 fn ($validator) => collect(
20 json_decode($response->getBody()->getContents(), true)['errors'] ?? []
21 )->each(fn ($message, $field) => $validator->errors()->add($field, $message))
22 )
23 );
24 }
25 
26 return $response;
27 });
28 };
29 }
30}

When Guzzle gets a request/response, it runs through the middleware stack (exactly the same way Laravel does). This class is checking if the response status code is 422 and if so it throws a ValidationException with a Validator that has been hydrated with the upstream errors.

We can then configure a Guzzle or Laravel Http client to use this middleware. Again, put this where you like, but your AppServiceProvider is a great place if you aren't sure where to place it.

1use App\Http\Guzzle\ProxyValidationErrors;
2use GuzzleHttp\Client;
3use GuzzleHttp\HandlerStack;
4use Illuminate\Http\Client\PendingRequest;
5use Illuminate\Support\Facades\Http;
6 
7// Guzzle Client
8$this->app->bind(Client::class, function () {
9 $stack = HandlerStack::create();
10 $stack->push(new ProxyValidationErrors);
11 
12 return new Client([
13 'handler' => $stack,
14 //... any other guzzle options you like
15 ]);
16});
17 
18// Laravel Http Client
19$this->app->bind(PendingRequest::class, function () {
20 return Http::withMiddleware(new ProxyValidationErrors);
21});

To use these, you would then use dependency injection in your class to pull in either the Guzzle or Laravel Http client:

1use App\Http\Requests\YourFormRequest;
2use GuzzleHttp\Client;
3use Illuminate\Http\Client\PendingRequest;
4 
5class ExampleController
6{
7 private Client $guzzleClient;
8 
9 private PendingRequest $laravelClient;
10 
11 public function __construct(Client $guzzleClient, PendingRequest $laravelClient)
12 {
13 $this->guzzleClient = $guzzleClient;
14 $this->laravelClient = $laravelClient;
15 }
16 
17 public function store(YourFormRequest $request)
18 {
19 // With Guzzle
20 $this->guzzleClient->post('http://example.com', [
21 'json' => $request->validated(),
22 ]);
23 
24 // With Laravel Http client
25 $this->laravelClient->post('http://example.com', $request->validated());
26 
27 //...
28 }
29}

Whilst we do our best to make sure we never have a mismatch of validation rules, and we have plans for further improvements to how we handle cross service communication, this has been a great strategy for more gracefully handling unexpected 422 validation failures.


I hope you found this useful. If you have any feedback, thoughts, or different approaches to this then I'd love to hear from you; I'm @jryd_13 on Twitter.

Thanks!