Here's yet another article about decorating an async GuzzleHttp adapter. So far in the series I've got this lot:
- PHP: decorating async GuzzleHttp calls
- PHP: decorating async GuzzleHttp calls - handling exceptions
- PHP: decorating async GuzzleHttp calls - handling exceptions a different way
- PHP: decorating async GuzzleHttp calls - caching
- PHP: decorating async GuzzleHttp calls - multiple decorators
Firstly I need some end points to test against. I decided I'd better shuffle-on from those CFML based ones I had, and use PHP instead. So I've rejigged them all as follows:
data.php
class Person{
public $id;
public $firstName;
public $lastName;
function __construct($id, $firstName, $lastName){
$this->id = $id;
$this->firstName = $firstName;
$this->lastName = $lastName;
}
}
$people = [
new Person(1, "Donald", "Duck"),
new Person(2, "Donald", "Cameron"),
new Person(3, "Donald", "Trump"),
new Person(4, "Donald", "Wearsyatroosas")
];
getById.php
$id = $_GET["id"];
require __DIR__ . "/data.php";
$filteredPeople = array_filter($people, function($person) use ($id) {
return $person->id == $id;
});
$person = array_shift($filteredPeople);
$result = (array) $person;
sleep(5);
$result["retrieved"] = (new DateTime())->format("Y-m-d H:i:s");
header("type:application/json");
echo json_encode($result);
create.php
require __DIR__ . "/data.php";
$newPerson = new Person(
uniqid(),
$_POST['firstName'],
$_POST['lastName']
);
$result = (array) $newPerson;
$result["retrieved"] = (new DateTime())->format("Y-m-d H:i:s");
header("type:application/json");
echo json_encode($result);
update.php
$id = $_GET["id"];
parse_str(file_get_contents("php://input"),$put_vars);
require __DIR__ . "/data.php";
$filteredPeople = array_filter($people, function($person) use ($id) {
return $person->id == $id;
});
if (empty($filteredPeople)){
http_response_code(400);
throw new InvalidArgumentException("No Person with ID $id found");
}
$person = array_shift($filteredPeople);
$newPerson = new Person(
$person->id,
$put_vars['firstName'],
$put_vars['lastName']
);
$result = [
'before' => (array) $person,
'after' => (array) $newPerson,
'updated' => (new DateTime())->format("Y-m-d H:i:s")
];
header("type:application/json");
echo json_encode($result);
That lot is all pretty perfunctory. There was a coupla PHP things I didn't know about before hand, namely:
- how to extract the body for a put request. I was initially expecting them to be in the $_POST array for some reason, but that makes no sense.
- how to set the HTTP status. I'd never had to do that before with PHP (we use Silex / Symfony, and we just use API methods to do same).
(readers need to remember I'm fairly new to PHP ;-)
The next update is to the Adapter interface:
namespace me\adamcameron\testApp\adapter;
use GuzzleHttp\Promise\Promise;
interface Adapter {
public function get($url, $parameters) : Promise;
public function post($url, $body) : Promise;
public function put($url, $body, $parameters) : Promise;
}
I've added the two new methods there. This now breaks all my Adapter implementations.
First I need to add the actual method implementations to the base adapter: the ones that actually makes the calls:
namespace me\adamcameron\testApp\adapter;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\Promise;
class GuzzleAdapter implements Adapter {
private $client;
public function __construct() {
$this->client = new Client();
}
public function get($url, $parameters) : Promise {
$fullUrl = sprintf("%s?%s", $url, http_build_query($parameters));
$response = $this->client->requestAsync("get", $fullUrl);
return $response;
}
public function post($url, $body) : Promise {
$options = ['form_params' => $body];
$response = $this->client->requestAsync("post", $url, $options);
return $response;
}
public function put($url, $body, $parameters) : Promise {
$fullUrl = sprintf("%s?%s", $url, http_build_query($parameters));
$options = ['form_params' => $body];
$response = $this->client->requestAsync("put", $fullUrl, $options);
return $response;
}
}
Nothing surprising there. Note that each of the new methods have different argument requirements. This becomes relevant later.
Now I need to add equivalent methods to the decorators. I'll start with the caching one as that's easy: we have no caching requirements for POST or PUT requests, so the methods just pass through to the underlying adapter:
public function post($url, $body) : Promise {
return $this->adapter->post($url, $body);
}
public function put($url, $body, $parameters) : Promise {
return $this->adapter->put($url, $body, $parameters);
}
See: easy.
The logging adapter was more of a challenge. The first pass of the implementation looked like this sort of thing:
public function get($url, $parameters) : Promise {
$encodedParameters = json_encode($parameters);
$this->logger->logMessage(sprintf("%s: Requesting for %s", $this->thisFile, $encodedParameters));
$response = $this->adapter->get($url, $parameters);
$response->then(function($response) use ($encodedParameters) {
$body = $response->getBody();
$this->logger->logMessage(sprintf("%s: Response for %s: %s", $this->thisFile, $encodedParameters, $body));
$body->rewind();
});
return $response;
}
public function post($url, $body) : Promise {
$logDetails = json_encode($body);
$this->logger->logMessage(sprintf("%s: Requesting for %s", $this->thisFile, $logDetails));
$response = $this->adapter->post($url, $body);
$response->then(function($response) use ($logDetails) {
$body = $response->getBody();
$this->logger->logMessage(sprintf("%s: Response for %s: %s", $this->thisFile, $logDetails, $body));
$body->rewind();
});
return $response;
}
public function post($url, $body, $parameters) : Promise {
$logDetails = json_encode([
'parameters' => $parameters,
'body' => $body
]);
$this->logger->logMessage(sprintf("%s: Requesting for %s", $this->thisFile, $logDetails));
$response = $this->adapter->put($url, $body, $parameters);
$response->then(function($response) use ($logDetails) {
$body = $response->getBody();
$this->logger->logMessage(sprintf("%s: Response for %s: %s", $this->thisFile, $logDetails, $body));
$body->rewind();
});
return $response;
}
That's fine, but look at how much similarity there is in there:
- We log some stuff
- We peform a request to a method
- We have a resolution handler
This way I have three congruent parts:
- capture some stuff to log. The composition of what to log is method-specific, but that's fine
- call a method
- with some arguments
I was able to factor-out this commonality into a helper method:
private function performLoggedRequest($method, $logDetails, ...$requestArgs) : Promise {
$this->logger->logMessage(sprintf("%s: Requesting for %s", $this->thisFile, $logDetails));
$response = call_user_func_array([$this->adapter, $method], $requestArgs);
$response->then(function($response) use ($logDetails) {
$body = $response->getBody();
$this->logger->logMessage(sprintf("%s: Response for %s: %s", $this->thisFile, $logDetails, $body));
$body->rewind();
});
return $response;
}
From there, it's just a matter of refactoring all three public methods to call this helper method:
public function get($url, $parameters) : Promise {
$logDetails = json_encode($parameters);
return $this->performLoggedRequest(__FUNCTION__, $logDetails, $url, $parameters);
}
public function post($url, $body) : Promise {
$logDetails = json_encode($body);
return $this->performLoggedRequest(__FUNCTION__, $logDetails, $url, $body);
}
public function put($url, $body, $parameters) : Promise {
$logDetails = json_encode([
'parameters' => $parameters,
'body' => $body
]);
return $this->performLoggedRequest(__FUNCTION__, $logDetails, $url, $body, $parameters);
}
For each call to performLoggedRequest, all the passed-in arguments after $logDetails end up in a single array, as $requestArgs All because of the ... in the method signature. Cool.
This enables use to call call_user_func_array. with both a dynamic method name, and a dynamic number of arguments in that array.
I then needed to do much the same with the StatusToExceptionAdapter:
public function get($url, $parameters) : Promise {
return $this->request(__FUNCTION__, $url, $parameters);
}
public function post($url, $body) : Promise {
return $this->request(__FUNCTION__, $url, $body);
}
public function put($url, $parameters, $body) : Promise {
return $this->request(__FUNCTION__, $url, $parameters, $body);
}
private function request($method, ...$args) : Promise {
return call_user_func_array([$this->adapter, $method], $args)
->then(function (Response $response) {
return $this->handleThen($response);
})
->otherwise(function ($exception) {
$this->handleOtherwise($exception);
});
}
Easy!
Finally a test. I used the same basic test rig as last time, so I'll just focus on the individual tests here. I also ran the equivalent test of getById as before: no change there so I'll not bother reproducing it.
The test for post was as follows:
function testCreate(PersonRepository $personRepository, LoggingService $loggingService) {
$personDetails = [
'firstName' => 'Donald',
'lastName' => 'McLean'
];
$loggingService->logMessage(sprintf("Test: calling create(%s)", json_encode($personDetails)));
$response = $personRepository->create($personDetails);
$body = (string) $response->wait()->getBody();
$loggingService->logMessage(sprintf("Test: called create(): [%s]", $body));
}
That's all obvious. The log output for this was:
[2016-11-24 20:04:11] testPost.INFO: Test: calling create({"firstName":"Donald","lastName":"McLean"}) [] []
[2016-11-24 20:04:11] testPost.INFO: LoggingAdapter: Requesting for {"firstName":"Donald","lastName":"McLean"} [] []
[2016-11-24 20:04:11] testPost.INFO: LoggingAdapter: Response for {"firstName":"Donald","lastName":"McLean"}: {"id":"583747bb1a6d9","firstName":"Donald","lastName":"McLean","retrieved":"2016-11-24 20:04:11"} [] []
[2016-11-24 20:04:11] testPost.INFO: StatusToExceptionAdapter: non-exception status encountered: 200 [] []
[2016-11-24 20:04:11] testPost.INFO: Test: called create(): [{"id":"583747bb1a6d9","firstName":"Donald","lastName":"McLean","retrieved":"2016-11-24 20:04:11"}] [] []
We can see no caching was called (good), but the approrpiate logging and StatusToException checks were made. And we got a new record ID back after the insert. Excellent.
The test of the update had two things to do: doing an update of an existing record (happy path) then trying to update a non-existent record (which should yield an exception):
function testUpdate(PersonRepository $personRepository, LoggingService $loggingService) {
testUpdateByPhase("Valid ID", 3, $personRepository, $loggingService);
testUpdateByPhase("Invalid ID", -1, $personRepository, $loggingService);
}
function testUpdateByPhase($phase, $id, PersonRepository $personRepository, LoggingService $loggingService) {
$personDetails = [
'firstName' => 'Donald',
'lastName' => 'Corleone'
];
$logDetails = [
'id' => $id,
'details' => $personDetails
];
$loggingService->logMessage(sprintf("Test (phase: %s): calling update(%s)", $phase, json_encode($logDetails)));
$response = $personRepository->update($id, $personDetails);
try {
$body = (string) $response->wait()->getBody();
$loggingService->logMessage(sprintf("Test (phase %s): called update(): [%s]", $phase, $body));
} catch (Exception $e) {
$previous = $e->getPrevious();
$loggingService->logMessage(
sprintf(
"Test (phase %s): call to update failed: "
. "Class: %s; "
. "Code: %s; "
. "Message: %s"
. "Previous class: %s"
. "Previous message: %s"
, $phase, get_class($e), $e->getCode(), $e->getMessage()
, get_class($previous), $previous->getMessage()
)
);
} finally {
$loggingService->logMessage(sprintf("Test (phase %s): complete", $phase));
}
}
That's more code, but most of it is error handling, and there's nothing noteworthy. The log shows the outcome. First the valid update:
[2016-11-24 20:04:11] testPost.INFO: Test (phase: Valid ID): calling update({"id":3,"details":{"firstName":"Donald","lastName":"Corleone"}}) [] []
[2016-11-24 20:04:11] testPost.INFO: LoggingAdapter: Requesting for {"parameters":{"id":3},"body":{"firstName":"Donald","lastName":"Corleone"}} [] []
[2016-11-24 20:04:11] testPost.INFO: LoggingAdapter: Response for {"parameters":{"id":3},"body":{"firstName":"Donald","lastName":"Corleone"}}: {"before":{"id":3,"firstName":"Donald","lastName":"Trump"},"after":{"id":3,"firstName":"Donald","lastName":"Corleone"},"updated":"2016-11-24 20:04:11"} [] []
[2016-11-24 20:04:11] testPost.INFO: StatusToExceptionAdapter: non-exception status encountered: 200 [] []
[2016-11-24 20:04:11] testPost.INFO: Test (phase Valid ID): called update(): [{"before":{"id":3,"firstName":"Donald","lastName":"Trump"},"after":{"id":3,"firstName":"Donald","lastName":"Corleone"},"updated":"2016-11-24 20:04:11"}] [] []
[2016-11-24 20:04:11] testPost.INFO: Test (phase Valid ID): complete [] []
Success!
And the invalid ID:
[2016-11-24 20:04:11] testPost.INFO: LoggingAdapter: Requesting for {"parameters":{"id":-1},"body":{"firstName":"Donald","lastName":"Corleone"}} [] []
[2016-11-24 20:04:11] testPost.INFO: StatusToExceptionAdapter: remapped status encountered: 400 [] []
[2016-11-24 20:04:11] testPost.INFO: Test (phase Invalid ID): call to update failed: Class: me\adamcameron\testApp\exception\BadRequestException; Code: 400; Message: A response of 400 was received from the service Previous class: GuzzleHttp\Exception\ClientExceptionPrevious message: Client error: `PUT http://php.local:8070/experiment/guzzle/update.php?id=-1` resulted in a `400 Bad Request` response: <br /> <font size='1'><table class='xdebug-error xe-uncaught-exception' dir='ltr' border='1' cellspacing='0' cellpadding (truncated...) [] []
[2016-11-24 20:04:11] testPost.INFO: Test (phase Invalid ID): complete [] []
That worked as expected too. Although I now note I should have returned a 404 there, not a 400. Oops. But it's doing what I told it to do, anyhow. And pleasingly, the StatusToExceptionAdapter did its job.
And that's about it. I learned a few new things whilst investigating this one, which is good.
That's the end of my foray into decorating GuzzleHttp adapters. I'll get onto something different next.
Righto.
--
Adam