Testing an API client without the network
How the Puzzler PHP SDK signs its requests and turns HTTP errors into exceptions, and how one protected method lets the tests check both with no credentials and no connection.
The Puzzler PHP SDK is a small, unofficial client for the REST API that Puzzler Media uses to supply crosswords, sudoku and other puzzles to publishers. Every call needs a client ID, an API key and a secret key, issued by Puzzler, and that makes it awkward to test. Until version 1.1.0, the unit tests checked that the classes could be built and that the exceptions kept what they were given. Anything that made a request needed real credentials, and one test, which checks that bad credentials are refused, went to the live API on every run, credentials or not.
Version 1.1.0, on 18 October 2026, moved the cURL calls into a protected method that tests can replace. Here’s what sits on either side of it.
Signing a request
Every request carries four headers of its own: X-Client-Id, X-Api-Key, X-Timestamp and X-Signature. The signature is a SHA-256 hash of the API key, client ID, a Unix timestamp and the secret key, joined with underscores and Base64-encoded. This is from src/Http/HttpClient.php:
private function generateSignature($timestamp)
{
$message = $this->apiKey . '_' . $this->clientId . '_' . $timestamp . '_' . $this->secretKey;
$hash = hash('sha256', $message, true);
return base64_encode($hash);
}
The secret key itself is never sent, only the hash it went into. request() calls time() for every request, so even a client that lives for hours signs each call afresh. A signature is valid for five minutes, so a server whose clock has drifted gets 401 responses with correct credentials, and the README’s troubleshooting notes cover that.
One seam
request() does everything that belongs to the SDK: it builds the URL, signs the headers, encodes the body and reads the response. The transfer itself is in send():
list($response, $httpCode, $contentType, $error) = $this->send($method, $url, $headers, $jsonBody);
if ($error) {
throw new PuzzlerException('cURL error: ' . $error);
}
return $this->handleResponse($response, $httpCode, $contentType);
send() makes the cURL calls and returns four values: the body, the status code, the content type and cURL’s error message, if there was one. Apart from attaching a body when there is one, it makes no decisions, so replacing it in tests loses very little. It’s protected, so callers never see it, and the public API didn’t change. The SDK still needs nothing but the cURL and JSON extensions.
A fake transport
The tests in tests/Unit/Http/HttpClientMockedTest.php replace send() with an anonymous subclass. It records every call and returns a canned reply:
return new class('cid', 'key', 'secret', 'https://api.example.com/', $reply) extends HttpClient {
public $calls = [];
private $reply;
public function __construct($c, $k, $s, $u, array $reply)
{
parent::__construct($c, $k, $s, $u);
$this->reply = $reply;
}
protected function send($method, $url, array $headers, $jsonBody)
{
$this->calls[] = compact('method', 'url', 'headers', 'jsonBody');
return $this->reply;
}
};
The fake’s base URL ends in a slash, and the first test checks that posting to /api/Puzzle/collect still gives https://api.example.com/api/Puzzle/collect, with one slash between them, and that the body is {"a":1}.
The signature check is the most useful part. The test can’t control time(), so it notes the time before and after the call and checks that the timestamp falls between them. Then it builds the signature itself, from the literal values, rather than calling the SDK’s private method:
$expected = base64_encode(hash('sha256', 'key_cid_' . $headers['X-Timestamp'] . '_secret', true));
$this->assertSame($expected, $headers['X-Signature']);
If someone swapped the order of the fields, or dropped the true that makes hash() return raw bytes, the SDK would still sign every request consistently, and the API would refuse every one. This test fails instead.
Errors that say what happened
handleResponse() treats 200 as the only success. A JSON content type is decoded, and JSON that doesn’t parse throws rather than coming back as null. Anything else comes back as the raw string. Every other status becomes an exception:
switch ($httpCode) {
case 400:
throw new BadRequestException('Bad request', $httpCode, $response);
case 401:
throw new AuthenticationException('Authentication failed', $httpCode, $response);
// ...404, 405, then 500 and 502 as ServerException...
default:
throw new PuzzlerException('Unexpected response', $httpCode, $response);
}
They all extend PuzzlerException, so one catch covers the lot. The status becomes the exception’s code, and the raw body is kept for getResponseBody(), so the API’s own explanation isn’t lost. Any status not in the list, another 2xx included, gets the base class.
A data provider feeds the fake 400, 401, 404, 405, 500, 502 and 418, and checks the class, the code and the body for each. 418 stands in for anything unexpected. Two more tests cover a body that claims to be JSON and isn’t, and a cURL failure, which surfaces as a PuzzlerException whose message starts with cURL error:.
What the fake shows about collect()
The puzzle and health clients take an HttpClient in their constructors, so the same subclass works for them, though PuzzlerClient builds its own and can’t be handed one. The offline tests don’t use it for PuzzleClient, and its request body has a history. The first version, in October 2025, wrapped the filters in a model property. On 30 October 2025 the wrapper came out, and on 12 November 2025 it went back in. Under an hour later, in a commit called “Puzzler API changes”, it became the current rule: filters go straight into the body, and a call with no filters sends a model property on its own.
Running PuzzleClient over the fake shows what goes out:
collect() {"model":[]}
collect(['puzzleDate' => '2026-10-21',
'puzzleTypes' => ['WS']]) {"puzzleDate":"2026-10-21","puzzleTypes":["WS"]}
collect(['puzzleTypes' => 'WS']) {"model":[]}
Two things stand out. The README shows the body for a call with no filters as {"model": {}}, but PHP’s json_encode() turns an empty array into [], so that’s what goes out. The live test for that call, testCollectWithNoFiltersUsesModelWrapper, is the one that can say whether the API minds. And a puzzleTypes given as a string rather than an array is dropped without a word, so the call asks for everything. Both would be one-line tests with the fake.
The live tests
The integration tests still run against the real API, and they skip themselves when PUZZLER_CLIENT_ID, PUZZLER_API_KEY or PUZZLER_SECRET_KEY isn’t set. The test for bad credentials now skips too, so a run without credentials makes no network calls at all. Without them, the suite has 39 tests: the 25 offline ones pass and the 14 live ones skip. PHPUnit is set to fail on risky tests and on warnings.
GitHub Actions runs the suite on PHP 7.4, 8.0, 8.1, 8.2, 8.3 and 8.4, after composer validate --strict. No credentials are set there, so CI checks signing and error handling on every version, and the live tests only run where credentials are.
Using it
composer require tomgould/puzzlerphpsdk
use TomGould\PuzzlerPHPSDK\PuzzlerClient;
use TomGould\PuzzlerPHPSDK\Exception\AuthenticationException;
use TomGould\PuzzlerPHPSDK\Exception\PuzzlerException;
$client = new PuzzlerClient($clientId, $apiKey, $secretKey);
try {
$crosswords = $client->puzzle()->collect([
'puzzleDate' => date('Y-m-d'),
'puzzleTypes' => ['XW'],
]);
} catch (AuthenticationException $e) {
// Wrong credentials, or a clock more than five minutes out.
} catch (PuzzlerException $e) {
echo $e->getCode(), ' ', $e->getResponseBody(), PHP_EOL;
}
To run the offline tests from a clone:
git clone https://github.com/tomgould/PuzzlerPHPSDK.git
cd PuzzlerPHPSDK
composer install
vendor/bin/phpunit --testsuite Unit
Anything beyond the tests needs your own credentials from Puzzler Media. The code is on GitHub.