Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
51b7d6c
chore(core): implement spec 0.2.0
MobileMage Oct 3, 2026
f6b27a4
feat(client): map gateway errors and retry failed attempts
MobileMage Oct 3, 2026
70a953a
feat(client): look up a postcode through the gateway
MobileMage Oct 3, 2026
87aba8b
fix(client): treat a past Retry-After date as invalid on every status
MobileMage Oct 3, 2026
73b6434
fix(client): give null for lookup parts outside the closed list
MobileMage Oct 3, 2026
518d10d
feat(client): add reverse search and autocomplete
MobileMage Oct 3, 2026
ef14f72
feat(client): keep results in a PSR-16 cache when asked
MobileMage Oct 3, 2026
a0ab839
test(client): run the contract scenarios against the mock server
MobileMage Oct 3, 2026
6d96c76
fix(client): read autocomplete timeouts per call and refuse bad text
MobileMage Oct 3, 2026
9bb4dab
fix(client): never let the cache fail a call that the gateway answered
MobileMage Oct 3, 2026
96d07c3
docs(client): show the client in the README and run its examples
MobileMage Oct 3, 2026
9be7c63
docs(client): tell callers to pass the transport timeout as timeoutMs
MobileMage Oct 3, 2026
7458894
fix(client): send a plain ampersand between the query parameters
MobileMage Oct 3, 2026
c281f93
fix(repo): list the client options in the API dump again
MobileMage Oct 3, 2026
03640cc
fix(repo): stop the mutation run from crashing on an escaped mutant
MobileMage Oct 3, 2026
8806436
fix(client): read all three HTTP date forms of Retry-After
MobileMage Oct 3, 2026
307740c
refactor(client): drop a dead fallback in the HTTP date reader
MobileMage Oct 3, 2026
c43cdba
test(client): pin retry, cache and message rules that no test covered
MobileMage Oct 3, 2026
cbe479d
docs(client): correct the README on Retry-After, the cache and the key
MobileMage Oct 3, 2026
8f5489f
fix(client): ignore the day name of a Retry-After date
MobileMage Oct 3, 2026
a1a982b
chore(repo): use spec v0.2.0 and the mock server on js main
MobileMage Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20261003-040232.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'Add the client of NIPOST''s gateway: lookup, reverse and autocomplete, from spec 0.2.0.'
time: 2026-10-03T04:02:32.482746+01:00
3 changes: 3 additions & 0 deletions .changes/unreleased/Changed-20261003-024952.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Changed
body: Implement spec 0.2.0. It adds the client contract, and core results stay the same.
time: 2026-10-03T02:49:52.051073+01:00
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,52 @@ jobs:
require "vendor/autoload.php";
echo Gatepost\Postcode\Postcode::parse("ek 01 a03 fk 01")->value->canonical;
' | grep -x 'EK-01-A03-FK-01'

# The client must pass each contract scenario of the spec against the mock server. The mock
# server lives in the js repo and runs on Node with no install. It reads the fixtures and the
# scenarios of this repo's own spec submodule, so only its code comes from js.
contract:
runs-on: ubuntu-24.04
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.5']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: true
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: gatepost-dev/js
# A js commit that holds packages/mock-server. Move it when the mock server changes.
ref: c94adef64c7d3640ddc20f53a27ec2f6fba96601
path: build/js
sparse-checkout: packages/mock-server
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ matrix.php }}
coverage: none
extensions: :intl
- run: composer install --no-interaction --no-progress
- name: Use the newest PHPUnit on the newest PHP
if: matrix.php != '8.1'
run: |
composer config --unset platform.php
composer update --no-interaction --no-progress --with-all-dependencies phpunit/phpunit
- name: Start the mock server
run: |
GATEPOST_SPEC_DIR="$PWD/spec" PORT=4010 \
node build/js/packages/mock-server/src/main.ts > "$RUNNER_TEMP/mock.log" 2>&1 &
curl --silent --show-error --retry 10 --retry-connrefused --retry-delay 1 \
http://127.0.0.1:4010/healthz
- run: composer contract
env:
GATEPOST_MOCK_URL: http://127.0.0.1:4010
- if: failure()
run: cat "$RUNNER_TEMP/mock.log"
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS.md

This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the PHP SDK, `gatepost/postcode`. Today it has the core.
This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the PHP SDK, `gatepost/postcode`. Today it has the core and the client.

## Read first

Expand Down
14 changes: 13 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,21 @@ _Avoid_: using this word for a Gatepost component
### Gatepost

**Spec**:
The `spec` repo. It holds the grammar, the data, the vectors and the standards. The API fixtures and the completed OpenAPI file come later.
The `spec` repo. It holds the grammar, the client contract, the data, the vectors, the fixtures, the completed OpenAPI file and the standards.
_Avoid_: schema, contract (for the repo)

**Contract scenario**:
One shared test case in `spec/contract/`. It names the calls, the mock server's responses and the outcome that every client must reach.
_Avoid_: contract test (for the file), vector, fixture

**Attempt**:
One request that a client sends for one call. A retry is the second or third attempt.
_Avoid_: try, request (when you count them)

**Evidence**:
How Gatepost knows the shape of a gateway response: `observed` in its own calls, `documented` by NIPOST only, or `assumed` by the mock server.
_Avoid_: source, confidence, proof

**Vector**:
One shared test case in `spec/vectors/` that every SDK must pass.
_Avoid_: fixture, example, golden file
Expand Down
99 changes: 96 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# gatepost/postcode

Parse, check and format Nigeria's digital postcodes in PHP, with no network access.
Parse, check and format Nigeria's digital postcodes in PHP, and query NIPOST's gateway for them.

> Unofficial. Not made or endorsed by NIPOST.

Expand Down Expand Up @@ -46,6 +46,7 @@ if ($result->isOk()) {
- Shortens a code to its area, district, LGA or state, and checks whether one code contains another.
- Hides the unit of a code for logs.
- Runs without the intl and mbstring extensions.
- Queries NIPOST's gateway with your own API key: what it knows about a postcode, the postcode at a GPS fix, and suggestions while a user types.

### Limit the input

Expand All @@ -63,16 +64,99 @@ echo Postcode::parse(str_pad($code, 65))->error?->code->value, "\n"; // bad_leng

`normalize` has no input limit, so call `parse` for text from an untrusted source.

## Call the gateway

`PostcodeClient` calls NIPOST's gateway with your own API key. The client needs a PSR-18 HTTP client and a PSR-17 request factory, such as Guzzle 7 (`composer require guzzlehttp/guzzle`). Keep the key on a server, because it is a secret.

The client is synchronous. Each call blocks until it has a result or throws a `PostcodeException`. It has no parallel calls and no cancel. The contract scenarios that start calls in parallel do not apply to it, and the contract suite leaves them out.

### Check a postcode with the gateway

```php
<?php

use Gatepost\Postcode\Client\PostcodeClient;
use Gatepost\Postcode\Client\PostcodeException;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;

$client = new PostcodeClient(
new Client(['timeout' => 15]),
new HttpFactory(),
apiKey: getenv('NIPOST_API_KEY') ?: null,
timeoutMs: 15000,
);

try {
$result = $client->lookup('FC-01-Z99-ZZ-01');
echo $result->valid ? 'valid' : 'not found', "\n";
} catch (PostcodeException $error) {
echo $error->errorCode()->value, "\n";
}
```

The examples use `FC-01-Z99-ZZ-01`, a synthetic postcode, and show the answers of Gatepost's mock server, which CI runs them against. The real gateway does not know this postcode.

`lookup` checks the code with `Postcode::parse()` first, and sends no request for a code that fails. A postcode that the gateway does not know is a result with `valid` set to false, not an error.

A key holds a lookup level, and `lookup` sends level 1 when you give none. Pass `level: 2` to `lookup` for a higher level. A level above the grant of the key fails with `ErrorCode::Forbidden`. Levels 2 to 5 use credits. NIPOST's docs say that level 2 adds the names of the places that hold the postcode and a recent house address, and level 3 adds what the building is used for. Gatepost has seen only level 1 responses from the gateway. The fields of levels 2 to 5 are documented, not observed: they follow NIPOST's docs, and no Gatepost test has seen them from the gateway yet. `levelReceived` comes from the fields of the response, because the gateway does not say which level it sent.

### Find a place and complete a postcode

`reverse($lat, $lng)` finds the postcode at a point. An optional third argument is the radius in metres, from 0 to 250. `found` can be true while `unit` is null: the gateway then found an area, but no unit within the radius. `area`, `district` and `state` are the text that the gateway sent.

`autocomplete($text)` takes the text that a user typed. It names the segment that the user is typing, and gives the gateway's values for it. Each value comes with the partial postcode that it completes, when the text and the value make one.

```php
<?php

use Gatepost\Postcode\Client\PostcodeClient;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;

$client = new PostcodeClient(
new Client(['timeout' => 15]),
new HttpFactory(),
apiKey: getenv('NIPOST_API_KEY') ?: null,
timeoutMs: 15000,
);

$place = $client->reverse(9.0, 7.0);
echo $place->unit?->postcode->canonical, "\n";

$typing = $client->autocomplete('fc 01 z');
echo $typing->segment->value, "\n";
```

Each call refuses bad input before it sends a request, and throws `ErrorCode::InvalidInput`. This covers a level outside 1 to 5, a latitude, a longitude or a radius outside its range, and `autocomplete` text that is empty, has more than 11 letters and digits, or holds another character.

### Handle errors and timeouts

Each failed call throws `PostcodeException`. `errorCode()` gives one of the cases of `ErrorCode`, such as `ErrorCode::RateLimited`. `status()` gives the HTTP status, `apiCode()` gives the gateway's own error code, and `retryAfterMs()` gives the wait that the `Retry-After` header of a 429, 502, 503 or 504 asked for. `ErrorCode::UnexpectedResponse` means that the gateway answered 200 with a body that the client cannot read. None of the messages of the client holds the API key. Show the user a message for the error code, not the message of the exception, which is for developers. The exception of a failed connection or timeout holds the failure of your transport as `getPrevious()`. That failure can hold the request with its `X-API-Key` header, so do not log it with its objects.

The client tries a call again after a 502, 503 or 504, a failed connection or a timeout, at most twice by default (`maxRetries`). It waits 500 ms before the first retry and twice as long before the next, plus a random part of up to 250 ms. After a 429, it waits only when `Retry-After` asks for 10 seconds or less. After a 502, 503 or 504, it waits for a valid `Retry-After` of 10 seconds or less in place of its own wait. `Retry-After` can hold seconds or an HTTP date in any of the three forms of RFC 9110. A timeout of `autocomplete` gets no retry, because the next keystroke replaces the call.

PSR-18 has no timeout, so the client cannot stop a slow attempt. Set the timeout on your HTTP client, and pass the same value as `timeoutMs`, as the examples do with 15 seconds. The client applies `timeoutMs` to every call, because one transport holds one timeout. 15 seconds is long enough for `autocomplete`, whose default wait is 15 seconds. The other calls wait 8 seconds by default, and `timeoutMs` replaces that value. The client reports a failed attempt that took `timeoutMs` or more as `ErrorCode::Timeout`, and a failure that came sooner as `ErrorCode::NetworkError`. If the two values differ, the client reads a slow failure with the wrong code, and tries it again when it should not.

### Keep results

The client keeps no result by default. To keep results, pass `cacheTtlMs` and a PSR-16 `cache`, such as the cache of your framework. An identical call within that time gets the kept result with no request. The client never keeps an error.

The client sets no size bound of its own, so use a store with eviction, such as Redis or APCu with a size limit. A store with no bound grows with each different postcode that you look up. The cache keys hold a keyed hash of the call, never the API key or the postcode in the clear. Two clients with the same API key, the same base URL and the same cache share their results.

A cache that fails never fails a call. The client reads a failed read as a miss, and skips a failed write. `clearCache()` is the exception. It removes every result that the clients of the same cache kept, also for another API key, and leaves the other entries of the cache alone. When the store fails, `clearCache()` throws the failure, so that you do not read old results after you asked to remove them.

## Requirements

| Requirement | Version |
|---|---|
| PHP | 8.1 or later |
| Gatepost spec | 0.1.0 |
| HTTP, for the client | a PSR-18 client and a PSR-17 request factory, such as Guzzle 7 |
| Gatepost spec | 0.2.0 |

## Docs

The Gatepost docs site will hold the guide and the API reference. Until it is live, each public method of `Postcode` has a doc comment with an example.
The Gatepost docs site will hold the guide and the API reference. Until it is live, each public method of `Postcode` and `PostcodeClient` has a doc comment with an example.

## Support

Expand All @@ -91,6 +175,15 @@ composer --working-dir=tools/bc-check install --ignore-platform-req=ext-intl
composer check
```

`composer check` runs no contract scenario and no example of the client, because they need the mock server, which runs on Node 22.22.2 or later in the 22 line, or on Node 24.15.0 or later. CI runs them in its `contract` job. To run them yourself, clone the js repo next to this one, start its mock server with this repo's spec, and run `composer contract`:

```sh
git clone --depth 1 https://github.com/gatepost-dev/js ../js
GATEPOST_SPEC_DIR="$PWD/spec" PORT=4010 node ../js/packages/mock-server/src/main.ts &
GATEPOST_MOCK_URL=http://127.0.0.1:4010 composer contract
kill %1
```

The BC check tool lists ext-intl as a requirement. Its check runs without that extension, so the install line ignores the requirement. The line also works on a machine that has ext-intl.

## Licence
Expand Down
13 changes: 10 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
{
"name": "gatepost/postcode",
"description": "Unofficial. Not made or endorsed by NIPOST. Parse Nigerian postcodes offline.",
"description": "Unofficial. Not made or endorsed by NIPOST. Read and query Nigerian postcodes.",
"license": "Apache-2.0",
"type": "library",
"require": {
"php": "^8.1"
"php": "^8.1",
"psr/http-client": "^1.0.3",
"psr/http-factory": "^1.1.0",
"psr/http-message": "^1.1 || ^2.0",
"psr/simple-cache": "^2.0 || ^3.0"
},
"require-dev": {
"composer/semver": "^3.5.0",
"ergebnis/composer-normalize": "^2.54.0",
"friendsofphp/php-cs-fixer": "^3.95.27",
"guzzlehttp/guzzle": "^7.15.5",
"phpmd/phpmd": "^2.15.0",
"phpstan/phpstan": "^2.2.16",
"phpstan/phpstan-deprecation-rules": "^2.0.5",
Expand Down Expand Up @@ -61,6 +66,7 @@
"@tells"
],
"context:check": "cmp CONTEXT.md spec/CONTEXT.md",
"contract": "phpunit --testsuite contract",
"cs": "php-cs-fixer fix",
"cs:check": "php-cs-fixer fix --dry-run --diff",
"deps:licences": "@php scripts/check-licences.php",
Expand All @@ -69,7 +75,7 @@
"licences": "uvx --with charset-normalizer==3.5.2 reuse@6.2.0 lint",
"mutation": [
"@putenv XDEBUG_MODE=coverage",
"tools/infection/vendor/bin/infection --threads=max"
"tools/infection/vendor/bin/infection --threads=max --show-mutations=0"
],
"phpmd": "@php -d error_reporting=24575 vendor/bin/phpmd scripts,src,tests text phpmd.xml",
"phpstan": "phpstan analyse --no-progress",
Expand All @@ -90,6 +96,7 @@
"changes:check": "Check that CHANGELOG.md matches the change files.",
"check": "Run every check that CI runs.",
"context:check": "Check that CONTEXT.md matches the copy in the spec.",
"contract": "Run the contract scenarios against the mock server at GATEPOST_MOCK_URL.",
"cs": "Fix the code style.",
"cs:check": "Check the code style.",
"deps:licences": "Check that each locked package has a permissive licence (DEP-4).",
Expand Down
Loading