Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
165 changes: 52 additions & 113 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/gatepost-dev/.github/main/brand/gatepost-lockup-dark.svg">
<img src="https://raw.githubusercontent.com/gatepost-dev/.github/main/brand/gatepost-lockup.svg" alt="gatepost" height="48">
</picture>

# gatepost/postcode

Parse, check and format Nigeria's digital postcodes in PHP, and query NIPOST's gateway for them.
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/gatepost-dev/.github/main/brand/gatepost-lockup-dark.svg">
<img src="https://raw.githubusercontent.com/gatepost-dev/.github/main/brand/gatepost-lockup.svg" alt="Gatepost" width="220">
</picture>
</p>

<p align="center"><strong>gatepost/postcode</strong></p>

<p align="center">Parse, check and format Nigeria's digital postcodes in PHP, and query NIPOST's gateway for them.</p>

<p align="center">
<a href="https://github.com/gatepost-dev/php/actions/workflows/ci.yml"><img src="https://github.com/gatepost-dev/php/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI status"></a>
<a href="https://github.com/gatepost-dev/php/blob/main/LICENSE"><img src="https://img.shields.io/badge/licence-Apache--2.0-blue" alt="Licence: Apache-2.0"></a>
<a href="https://scorecard.dev/viewer/?uri=github.com/gatepost-dev/php"><img src="https://api.scorecard.dev/projects/github.com/gatepost-dev/php/badge" alt="OpenSSF Scorecard"></a>
</p>

<p align="center">
<a href="https://gatepost-dev.github.io/docs/">Docs</a> &middot;
<a href="https://gatepost-dev.github.io/docs/playground/">Playground</a> &middot;
<a href="https://gatepost-dev.github.io/docs/guides/php/">PHP guide</a> &middot;
<a href="https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md">Contributing</a> &middot;
<a href="https://github.com/gatepost-dev/php/discussions">Discussions</a>
</p>

> Unofficial. Not made or endorsed by NIPOST.

[![CI](https://github.com/gatepost-dev/php/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gatepost-dev/php/actions/workflows/ci.yml)
[![Licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](https://github.com/gatepost-dev/php/blob/main/LICENSE)

## Install

```sh
composer require gatepost/postcode:^0.1@alpha
```

Composer installs only stable releases unless the constraint allows an alpha.
The first alpha, `0.1.0-alpha.1`, is a GitHub release only. Packagist does not serve the package yet, so Composer cannot find it there today.

## Quickstart

Expand All @@ -37,40 +50,9 @@ if ($result->isOk()) {

`parse` accepts any spacing, dashes and letter case. For any string, it returns a result and never throws.

## What it does

- Reads codes that users type or paste, including full-width letters and digits.
- Names the problem in a bad code, and suggests a fix for common typos, such as O in place of 0.
- Recognises old 6-digit postcodes.
- Writes a code in its compact, canonical and display forms.
- 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

`parse` reads at most 64 code points of input. This is the input limit. Longer input fails with `ParseErrorCode::BadLength`, even when it holds a valid code. The check runs before `parse` normalises the input, so long text cannot stall a server. `isLegacy` applies the same limit.

```php
<?php

use Gatepost\Postcode\Postcode;

$code = 'EK-01-A03-FK-01';
echo Postcode::parse(str_pad($code, 64))->isOk() ? 'ok' : 'failed', "\n"; // ok
echo Postcode::parse(str_pad($code, 65))->error?->code->value, "\n"; // bad_length
```

`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
`PostcodeClient` needs your own NIPOST API key, a PSR-18 HTTP client and a PSR-17 request factory. This example uses Guzzle 7 (`composer require guzzlehttp/guzzle`). Keep the key on a server, because it is a secret.

```php
<?php
Expand All @@ -95,96 +77,53 @@ try {
}
```

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
The example uses `FC-01-Z99-ZZ-01`, a synthetic postcode, and shows the answer of Gatepost's mock server, which CI runs it against. The real gateway does not know this postcode. PSR-18 has no timeout, so set it on your HTTP client and pass the same value as `timeoutMs`.

`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.
## Main functions

`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.
| Function | What it does |
|---|---|
| `Postcode::parse($input, $allowPartial)` | Returns a result that holds a `Postcode` or a `ParseError`. |
| `Postcode::normalize($input)` | Removes separators and makes letters upper case. It does not check the result. |
| `Postcode::isLegacy($input)` | Tells whether text is an old 6-digit postcode. |
| `$postcode->canonical`, `compact`, `display` | The three written forms of a code. |
| `$postcode->truncate()`, `parent()`, `contains()` | Shorten a code, or check whether one code holds another. |
| `$postcode->redact()` | Hides the unit of a code for logs. |
| `$client->lookup($code, $level)` | Asks the gateway what it knows about a postcode. |
| `$client->reverse($lat, $lng)` | Finds the postcode at a GPS fix. |
| `$client->autocomplete($text)` | Suggests values while a user types. |

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.
## What it does

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.
- Reads codes that users type or paste, including full-width letters and digits.
- Names the problem in a bad code, and suggests a fix for common typos, such as O in place of 0.
- Recognises old 6-digit postcodes.
- Shortens a code to its area, district, LGA or state.
- Retries after a 502, 503 or 504, a failed connection or a timeout, at most twice by default.
- Keeps results in a PSR-16 cache when you pass one. It keeps no result by default.
- Needs neither the intl nor the mbstring extension.

## Requirements

| Requirement | Version |
|---|---|
| PHP | 8.1 or later |
| HTTP, for the client | a PSR-18 client and a PSR-17 request factory, such as Guzzle 7 |
| HTTP, for the client | a PSR-18 client and a PSR-17 request factory |
| 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` and `PostcodeClient` has a doc comment with an example.
The [guide](https://gatepost-dev.github.io/docs/guides/php/) shows each function with an example. The [reference](https://gatepost-dev.github.io/docs/reference/php/) lists every public class and method.

## Support

Ask questions and report bugs in [GitHub Issues](https://github.com/gatepost-dev/php/issues). Report security problems privately, as [`SECURITY.md`](https://github.com/gatepost-dev/.github/blob/main/SECURITY.md) describes.
Ask questions in [GitHub Discussions](https://github.com/gatepost-dev/php/discussions). Report bugs in [GitHub Issues](https://github.com/gatepost-dev/php/issues). Report security problems through the [private reporting form](https://github.com/gatepost-dev/php/security/advisories/new).

## Contributing

Read [`CONTRIBUTING.md`](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request. The title and the body of the pull request become the squash commit on `main`. The title and the suffix ` (#N)` that GitHub adds have at most 72 characters together.

To run every check, you need PHP 8.4 with Xdebug, Composer, Python 3.11 or later, uv, gitleaks and changie. Clone with `git clone --recurse-submodules`, then run:

```sh
composer install
composer --working-dir=tools/infection install
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
```
Read [`CONTRIBUTING.md`](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request.

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.
Develop: [`docs/developing.md`](docs/developing.md) shows how to set up the tools and run the checks.

## Licence

Expand Down
37 changes: 37 additions & 0 deletions docs/developing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Develop gatepost/postcode

Read the [contributing guide](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request. The title and the body of the pull request become the squash commit on `main`. The title and the suffix ` (#N)` that GitHub adds have at most 72 characters together.

## Set up

To run every check, you need PHP 8.4 with Xdebug, Composer, Python 3.11 or later, uv, gitleaks and changie. Clone with `git clone --recurse-submodules`, then run:

```sh
composer install
composer --working-dir=tools/infection install
composer --working-dir=tools/bc-check install --ignore-platform-req=ext-intl
composer check
```

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.

`composer run` lists the other scripts. `composer test` runs the tests without coverage. `composer mutation` runs the mutation tests, and it needs Xdebug.

## Contract scenarios

`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
```

## Change files

Add a change file with `changie new` for each user-visible change. `composer changes:check` compares `CHANGELOG.md` with the change files.

## README examples

`tests/Unit/ReadmeTest.php` runs the offline example of the README. `tests/Contract/ReadmeExampleTest.php` runs the client example against the mock server. A test finds each example by the heading of its section, so change a heading and its test together.
6 changes: 1 addition & 5 deletions tests/Contract/ReadmeExampleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@ final class ReadmeExampleTest extends MockServerTestCase
public static function examples(): array
{
return [
'the check of a postcode' => ['Check a postcode with the gateway', "valid\n"],
'the search for a place' => [
'Find a place and complete a postcode',
"FC-01-Z99-ZZ-01\ndistrict\n",
],
'the check of a postcode' => ['Call the gateway', "valid\n"],
];
}

Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/DocExamplesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
use SplFileInfo;

/**
* The README makes the doc comments of src the API reference until the docs site is live, and
* The docs site holds the API reference.
* DOC-3 asks that CI runs each code example. ReadmeTest runs the examples of the README. This test
* runs the php examples of the doc comments, and it compares each result that an example shows.
*
Expand Down
14 changes: 1 addition & 13 deletions tests/Unit/ReadmeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
namespace Gatepost\Postcode\Tests\Unit;

use Composer\Semver\Semver;
use Gatepost\Postcode\Internal\SpecData;
use Gatepost\Postcode\Tests\Contract\ReadmeExampleTest;
use Gatepost\Postcode\Tests\Readme;
use PHPUnit\Framework\Attributes\DataProvider;
Expand All @@ -30,7 +29,6 @@ public static function examples(): array
{
return [
'the quickstart' => ['Quickstart', 'EK-01-A03-FK-01'],
'the input limit' => ['Limit the input', "ok\nbad_length\n"],
];
}

Expand Down Expand Up @@ -88,8 +86,7 @@ public function listsEachPhpExampleOfTheReadmeInATest(): void
public static function clientExampleSections(): array
{
return [
'the check of a postcode' => ['Check a postcode with the gateway'],
'the search for a place' => ['Find a place and complete a postcode'],
'the check of a postcode' => ['Call the gateway'],
];
}

Expand All @@ -106,15 +103,6 @@ public function givesTheTransportTimeoutAndTimeoutMsTheSameValue(string $heading
self::assertSame((int) $transport[1] * 1000, (int) $client[1]);
}

#[Test]
public function statesTheInputLimitOfTheSpec(): void
{
$section = Readme::section('Limit the input');

self::assertSame(1, \preg_match('/at most (\d+) code points/', $section, $limit));
self::assertSame(SpecData::MAX_INPUT_CODE_POINTS, (int) $limit[1]);
}

#[Test]
public function installsWithAConstraintThatAllowsTheNewestRelease(): void
{
Expand Down