diff --git a/README.md b/README.md index 14e2123..f0a6d52 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,37 @@ - - - gatepost - - -# gatepost/postcode - -Parse, check and format Nigeria's digital postcodes in PHP, and query NIPOST's gateway for them. +

+ + + Gatepost + +

+ +

gatepost/postcode

+ +

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

+ +

+ CI status + Licence: Apache-2.0 + OpenSSF Scorecard +

+ +

+ Docs · + Playground · + PHP guide · + Contributing · + Discussions +

> 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 @@ -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 -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 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 diff --git a/docs/developing.md b/docs/developing.md new file mode 100644 index 0000000..62ababe --- /dev/null +++ b/docs/developing.md @@ -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. diff --git a/tests/Contract/ReadmeExampleTest.php b/tests/Contract/ReadmeExampleTest.php index 5b7e0ff..8468577 100644 --- a/tests/Contract/ReadmeExampleTest.php +++ b/tests/Contract/ReadmeExampleTest.php @@ -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"], ]; } diff --git a/tests/Unit/DocExamplesTest.php b/tests/Unit/DocExamplesTest.php index 0949053..5d408e2 100644 --- a/tests/Unit/DocExamplesTest.php +++ b/tests/Unit/DocExamplesTest.php @@ -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. * diff --git a/tests/Unit/ReadmeTest.php b/tests/Unit/ReadmeTest.php index a6f0860..99330c0 100644 --- a/tests/Unit/ReadmeTest.php +++ b/tests/Unit/ReadmeTest.php @@ -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; @@ -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"], ]; } @@ -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'], ]; } @@ -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 {