{
    "type": "article",
    "title": "Testing an API client without the network",
    "canonical": "https://www.dealmobile.co.uk/writing/testing-an-api-client-without-the-network",
    "path": "/writing/testing-an-api-client-without-the-network",
    "url": "https://www.dealmobile.co.uk/writing/testing-an-api-client-without-the-network",
    "markdown": "https://www.dealmobile.co.uk/writing/testing-an-api-client-without-the-network.md",
    "json": "https://www.dealmobile.co.uk/writing/testing-an-api-client-without-the-network.json",
    "summary": "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.",
    "published": "2026-10-21T00:00:00+01:00",
    "modified": "2026-10-25T18:32:07+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/testing-an-api-client-without-the-network/hero.png",
        "alt": "A terminal showing the Puzzler PHP SDK’s offline test run ending in OK, 25 tests, 55 assertions."
    },
    "fields": {
        "image_light": null
    },
    "tags": [
        {
            "name": "PHP",
            "url": "https://www.dealmobile.co.uk/writing/tag/php",
            "json": "https://www.dealmobile.co.uk/writing/tag/php.json"
        },
        {
            "name": "APIs",
            "url": "https://www.dealmobile.co.uk/writing/tag/apis",
            "json": "https://www.dealmobile.co.uk/writing/tag/apis.json"
        },
        {
            "name": "Testing",
            "url": "https://www.dealmobile.co.uk/writing/tag/testing",
            "json": "https://www.dealmobile.co.uk/writing/tag/testing.json"
        },
        {
            "name": "Puzzler PHP SDK",
            "url": "https://www.dealmobile.co.uk/writing/tag/puzzler-php-sdk",
            "json": "https://www.dealmobile.co.uk/writing/tag/puzzler-php-sdk.json"
        }
    ],
    "projects": [
        {
            "name": "Puzzler PHP SDK",
            "url": "https://www.dealmobile.co.uk/projects/puzzler-php-sdk",
            "json": "https://www.dealmobile.co.uk/projects/puzzler-php-sdk.json"
        }
    ],
    "body": {
        "html": "<p>The <a href=\"/projects/puzzler-php-sdk\">Puzzler PHP SDK</a> 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.</p>\n<p>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.</p>\n<h2 id=\"signing-a-request\">Signing a request</h2>\n<p>Every request carries four headers of its own: <code>X-Client-Id</code>, <code>X-Api-Key</code>, <code>X-Timestamp</code> and <code>X-Signature</code>. 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 <code>src/Http/HttpClient.php</code>:</p>\n<pre><code class=\"language-php\">private function generateSignature($timestamp)\n{\n    $message = $this-&gt;apiKey . '_' . $this-&gt;clientId . '_' . $timestamp . '_' . $this-&gt;secretKey;\n    $hash = hash('sha256', $message, true);\n    return base64_encode($hash);\n}\n</code></pre>\n<p>The secret key itself is never sent, only the hash it went into. <code>request()</code> calls <code>time()</code> 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.</p>\n<h2 id=\"one-seam\">One seam</h2>\n<p><code>request()</code> 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 <code>send()</code>:</p>\n<pre><code class=\"language-php\">list($response, $httpCode, $contentType, $error) = $this-&gt;send($method, $url, $headers, $jsonBody);\n\nif ($error) {\n    throw new PuzzlerException('cURL error: ' . $error);\n}\n\nreturn $this-&gt;handleResponse($response, $httpCode, $contentType);\n</code></pre>\n<p><code>send()</code> 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.</p>\n<h2 id=\"a-fake-transport\">A fake transport</h2>\n<p>The tests in <code>tests/Unit/Http/HttpClientMockedTest.php</code> replace <code>send()</code> with an anonymous subclass. It records every call and returns a canned reply:</p>\n<pre><code class=\"language-php\">return new class('cid', 'key', 'secret', 'https://api.example.com/', $reply) extends HttpClient {\n    public $calls = [];\n    private $reply;\n\n    public function __construct($c, $k, $s, $u, array $reply)\n    {\n        parent::__construct($c, $k, $s, $u);\n        $this-&gt;reply = $reply;\n    }\n\n    protected function send($method, $url, array $headers, $jsonBody)\n    {\n        $this-&gt;calls[] = compact('method', 'url', 'headers', 'jsonBody');\n        return $this-&gt;reply;\n    }\n};\n</code></pre>\n<p>The fake’s base URL ends in a slash, and the first test checks that posting to <code>/api/Puzzle/collect</code> still gives <code>https://api.example.com/api/Puzzle/collect</code>, with one slash between them, and that the body is <code>{\"a\":1}</code>.</p>\n<p>The signature check is the most useful part. The test can’t control <code>time()</code>, 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:</p>\n<pre><code class=\"language-php\">$expected = base64_encode(hash('sha256', 'key_cid_' . $headers['X-Timestamp'] . '_secret', true));\n$this-&gt;assertSame($expected, $headers['X-Signature']);\n</code></pre>\n<p>If someone swapped the order of the fields, or dropped the <code>true</code> that makes <code>hash()</code> return raw bytes, the SDK would still sign every request consistently, and the API would refuse every one. This test fails instead.</p>\n<h2 id=\"errors-that-say-what-happened\">Errors that say what happened</h2>\n<p><code>handleResponse()</code> treats 200 as the only success. A JSON content type is decoded, and JSON that doesn’t parse throws rather than coming back as <code>null</code>. Anything else comes back as the raw string. Every other status becomes an exception:</p>\n<pre><code class=\"language-php\">switch ($httpCode) {\n    case 400:\n        throw new BadRequestException('Bad request', $httpCode, $response);\n    case 401:\n        throw new AuthenticationException('Authentication failed', $httpCode, $response);\n    // ...404, 405, then 500 and 502 as ServerException...\n    default:\n        throw new PuzzlerException('Unexpected response', $httpCode, $response);\n}\n</code></pre>\n<p>They all extend <code>PuzzlerException</code>, so one <code>catch</code> covers the lot. The status becomes the exception’s code, and the raw body is kept for <code>getResponseBody()</code>, so the API’s own explanation isn’t lost. Any status not in the list, another 2xx included, gets the base class.</p>\n<p>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 <code>PuzzlerException</code> whose message starts with <code>cURL error:</code>.</p>\n<h2 id=\"what-the-fake-shows-about-collect\">What the fake shows about collect()</h2>\n<p>The puzzle and health clients take an <code>HttpClient</code> in their constructors, so the same subclass works for them, though <code>PuzzlerClient</code> builds its own and can’t be handed one. The offline tests don’t use it for <code>PuzzleClient</code>, and its request body has a history. The first version, in October 2025, wrapped the filters in a <code>model</code> 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 <code>model</code> property on its own.</p>\n<p>Running <code>PuzzleClient</code> over the fake shows what goes out:</p>\n<pre><code>collect()                                   {\"model\":[]}\ncollect(['puzzleDate' =&gt; '2026-10-21',\n         'puzzleTypes' =&gt; ['WS']])          {\"puzzleDate\":\"2026-10-21\",\"puzzleTypes\":[\"WS\"]}\ncollect(['puzzleTypes' =&gt; 'WS'])            {\"model\":[]}\n</code></pre>\n<p>Two things stand out. The README shows the body for a call with no filters as <code>{\"model\": {}}</code>, but PHP’s <code>json_encode()</code> turns an empty array into <code>[]</code>, so that’s what goes out. The live test for that call, <code>testCollectWithNoFiltersUsesModelWrapper</code>, is the one that can say whether the API minds. And a <code>puzzleTypes</code> 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.</p>\n<h2 id=\"the-live-tests\">The live tests</h2>\n<p>The integration tests still run against the real API, and they skip themselves when <code>PUZZLER_CLIENT_ID</code>, <code>PUZZLER_API_KEY</code> or <code>PUZZLER_SECRET_KEY</code> 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.</p>\n<p>GitHub Actions runs the suite on PHP 7.4, 8.0, 8.1, 8.2, 8.3 and 8.4, after <code>composer validate --strict</code>. No credentials are set there, so CI checks signing and error handling on every version, and the live tests only run where credentials are.</p>\n<h2 id=\"using-it\">Using it</h2>\n<pre><code class=\"language-bash\">composer require tomgould/puzzlerphpsdk\n</code></pre>\n<pre><code class=\"language-php\">use TomGould\\PuzzlerPHPSDK\\PuzzlerClient;\nuse TomGould\\PuzzlerPHPSDK\\Exception\\AuthenticationException;\nuse TomGould\\PuzzlerPHPSDK\\Exception\\PuzzlerException;\n\n$client = new PuzzlerClient($clientId, $apiKey, $secretKey);\n\ntry {\n    $crosswords = $client-&gt;puzzle()-&gt;collect([\n        'puzzleDate' =&gt; date('Y-m-d'),\n        'puzzleTypes' =&gt; ['XW'],\n    ]);\n} catch (AuthenticationException $e) {\n    // Wrong credentials, or a clock more than five minutes out.\n} catch (PuzzlerException $e) {\n    echo $e-&gt;getCode(), ' ', $e-&gt;getResponseBody(), PHP_EOL;\n}\n</code></pre>\n<p>To run the offline tests from a clone:</p>\n<pre><code class=\"language-bash\">git clone https://github.com/tomgould/PuzzlerPHPSDK.git\ncd PuzzlerPHPSDK\ncomposer install\nvendor/bin/phpunit --testsuite Unit\n</code></pre>\n<p>Anything beyond the tests needs your own credentials from Puzzler Media. The code is on <a href=\"https://github.com/tomgould/PuzzlerPHPSDK\">GitHub</a>.</p>"
    },
    "markdown_text": "---\ntitle: 'Testing an API client without the network'\ncanonical: 'https://www.dealmobile.co.uk/writing/testing-an-api-client-without-the-network'\nurl: 'https://www.dealmobile.co.uk/writing/testing-an-api-client-without-the-network'\ntype: Post\npublished: '2026-10-21'\nsummary: '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.'\n---\n\n# Testing an API client without the network\n\nPublished 2026-10-21\n\n![A terminal showing the Puzzler PHP SDK’s offline test run ending in OK, 25 tests, 55 assertions.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/testing-an-api-client-without-the-network/hero.png.webp?itok=0PW2YCJo)\n\nThe [Puzzler PHP SDK](https://www.dealmobile.co.uk/projects/puzzler-php-sdk.md) 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.\n\nVersion 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.\n\n## Signing a request\n\nEvery 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`:\n\n```php\nprivate function generateSignature($timestamp)\n{\n    $message = $this->apiKey . '_' . $this->clientId . '_' . $timestamp . '_' . $this->secretKey;\n    $hash = hash('sha256', $message, true);\n    return base64_encode($hash);\n}\n\n```\n\nThe 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.\n\n## One seam\n\n`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()`:\n\n```php\nlist($response, $httpCode, $contentType, $error) = $this->send($method, $url, $headers, $jsonBody);\n\nif ($error) {\n    throw new PuzzlerException('cURL error: ' . $error);\n}\n\nreturn $this->handleResponse($response, $httpCode, $contentType);\n\n```\n\n`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.\n\n## A fake transport\n\nThe tests in `tests/Unit/Http/HttpClientMockedTest.php` replace `send()` with an anonymous subclass. It records every call and returns a canned reply:\n\n```php\nreturn new class('cid', 'key', 'secret', 'https://api.example.com/', $reply) extends HttpClient {\n    public $calls = [];\n    private $reply;\n\n    public function __construct($c, $k, $s, $u, array $reply)\n    {\n        parent::__construct($c, $k, $s, $u);\n        $this->reply = $reply;\n    }\n\n    protected function send($method, $url, array $headers, $jsonBody)\n    {\n        $this->calls[] = compact('method', 'url', 'headers', 'jsonBody');\n        return $this->reply;\n    }\n};\n\n```\n\nThe 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}`.\n\nThe 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:\n\n```php\n$expected = base64_encode(hash('sha256', 'key_cid_' . $headers['X-Timestamp'] . '_secret', true));\n$this->assertSame($expected, $headers['X-Signature']);\n\n```\n\nIf 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.\n\n## Errors that say what happened\n\n`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:\n\n```php\nswitch ($httpCode) {\n    case 400:\n        throw new BadRequestException('Bad request', $httpCode, $response);\n    case 401:\n        throw new AuthenticationException('Authentication failed', $httpCode, $response);\n    // ...404, 405, then 500 and 502 as ServerException...\n    default:\n        throw new PuzzlerException('Unexpected response', $httpCode, $response);\n}\n\n```\n\nThey 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.\n\nA 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:`.\n\n## What the fake shows about collect()\n\nThe 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.\n\nRunning `PuzzleClient` over the fake shows what goes out:\n\n```\ncollect()                                   {\"model\":[]}\ncollect(['puzzleDate' => '2026-10-21',\n         'puzzleTypes' => ['WS']])          {\"puzzleDate\":\"2026-10-21\",\"puzzleTypes\":[\"WS\"]}\ncollect(['puzzleTypes' => 'WS'])            {\"model\":[]}\n\n```\n\nTwo 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.\n\n## The live tests\n\nThe 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.\n\nGitHub 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.\n\n## Using it\n\n```bash\ncomposer require tomgould/puzzlerphpsdk\n\n```\n\n```php\nuse TomGould\\PuzzlerPHPSDK\\PuzzlerClient;\nuse TomGould\\PuzzlerPHPSDK\\Exception\\AuthenticationException;\nuse TomGould\\PuzzlerPHPSDK\\Exception\\PuzzlerException;\n\n$client = new PuzzlerClient($clientId, $apiKey, $secretKey);\n\ntry {\n    $crosswords = $client->puzzle()->collect([\n        'puzzleDate' => date('Y-m-d'),\n        'puzzleTypes' => ['XW'],\n    ]);\n} catch (AuthenticationException $e) {\n    // Wrong credentials, or a clock more than five minutes out.\n} catch (PuzzlerException $e) {\n    echo $e->getCode(), ' ', $e->getResponseBody(), PHP_EOL;\n}\n\n```\n\nTo run the offline tests from a clone:\n\n```bash\ngit clone https://github.com/tomgould/PuzzlerPHPSDK.git\ncd PuzzlerPHPSDK\ncomposer install\nvendor/bin/phpunit --testsuite Unit\n\n```\n\nAnything beyond the tests needs your own credentials from Puzzler Media. The code is on [GitHub](https://github.com/tomgould/PuzzlerPHPSDK).\n\n- **Projects:** [Puzzler PHP SDK](https://www.dealmobile.co.uk/projects/puzzler-php-sdk.md)\n- **Tags:** [PHP](https://www.dealmobile.co.uk/writing/tag/php.md), [APIs](https://www.dealmobile.co.uk/writing/tag/apis.md), [Testing](https://www.dealmobile.co.uk/writing/tag/testing.md), [Puzzler PHP SDK](https://www.dealmobile.co.uk/writing/tag/puzzler-php-sdk.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}