diff --git a/.changeset/first-client-release.md b/.changeset/first-client-release.md new file mode 100644 index 0000000..3af5774 --- /dev/null +++ b/.changeset/first-client-release.md @@ -0,0 +1,5 @@ +--- +'@gatepost/client': minor +--- + +Add the client package. It looks up postcodes, finds the postcode of a place and completes a typed postcode through NIPOST's gateway, with the retries, waits and limits of spec 0.2.0. diff --git a/.github/workflows/live.yml b/.github/workflows/live.yml new file mode 100644 index 0000000..b591279 --- /dev/null +++ b/.github/workflows/live.yml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +name: live + +# Each night, the client makes three calls to NIPOST's staging gateway with the test key: a +# lookup of a test code from NIPOST's docs, an autocomplete and a reverse geocode at sea. The +# contract scenarios cannot run there, because they need the scripted answers of the mock server. +# A failure means that the gateway changed. The job prints no response. +on: + schedule: + - cron: '30 2 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + staging: + # A fork has no secrets and makes no call, so it need not run the job. + if: github.repository == 'gatepost-dev/js' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: corepack enable + - run: pnpm install --frozen-lockfile + - name: Call the staging gateway + env: + GATEPOST_LIVE_URL: ${{ secrets.NIPOST_STAGING_URL }} + GATEPOST_LIVE_KEY: ${{ secrets.NIPOST_TEST_SECRET_KEY }} + # A test code from NIPOST's docs. The gateway knew it on 2 Oct 2026. + GATEPOST_LIVE_CODE: FC-03-B06-AG-12 + run: | + if [ -z "$GATEPOST_LIVE_URL" ]; then + echo "::notice::NIPOST has not given a staging URL yet, so the job makes no call." + exit 0 + fi + pnpm --filter @gatepost/client exec vitest run test/live.test.ts diff --git a/AGENTS.md b/AGENTS.md index aacaca3..0191180 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 TypeScript packages. Today it has `@gatepost/core` and the private `@gatepost/mock-server`. +This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core`, `@gatepost/client` and the private `@gatepost/mock-server`. ## Read first diff --git a/README.md b/README.md index 5ea3582..668e7ed 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ TypeScript packages for Nigeria's National Digital Postcode. | Package | What it does | | ----------------------------------------------- | --------------------------------------------------- | | [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. | +| [`@gatepost/client`](packages/client) | Call NIPOST's gateway, with retries and limits. | | [`@gatepost/mock-server`](packages/mock-server) | Mock NIPOST's gateway for client tests. Not on npm. | ## Develop @@ -22,12 +23,12 @@ You need Node 24, `corepack enable`, Python 3.11 or later and `uv`. pnpm install pnpm check -`pnpm check` also builds the package. Then it runs publint, attw, size-limit and API Extractor on -the result, and it starts the built package once. It checks the licence of each dependency and of +`pnpm check` also builds the packages. Then it runs publint, attw, size-limit and API Extractor on +the result, and it starts each built package once. It checks the licence of each dependency and of each file, and it checks the workflows with zizmor. It starts REUSE and zizmor with `uvx` at pinned versions. REUSE needs an encoding detector, and `uvx` installs REUSE without one, so the script pins -`charset-normalizer` as well. Renovate updates the three pins. After a change to the public API, run -`pnpm --filter @gatepost/core run api:update` and commit `packages/core/etc/core.api.md`. For each +`charset-normalizer` as well. Renovate updates the three pins. After a change to the public API of a +package, run `pnpm --filter run api:update` and commit its `etc/.api.md`. For each change to a package, add a change file with `pnpm changeset`, or with `pnpm changeset --empty` when the change needs no release. The pull request job fails without a change file. diff --git a/docs/adr/0001-client-depends-on-core.md b/docs/adr/0001-client-depends-on-core.md new file mode 100644 index 0000000..a7fa70f --- /dev/null +++ b/docs/adr/0001-client-depends-on-core.md @@ -0,0 +1,24 @@ +# 1. The client depends on the core + +Date: 3 Oct 2026 + +## Context + +`@gatepost/client` must parse each postcode before it sends a lookup, so that a bad code sends no request. It also returns parsed postcodes in its results, and it builds the partial postcode of each autocomplete offer. `@gatepost/core` holds the only parser, with the state table and the segment rules from the spec data. + +The client was first planned with no runtime dependencies. DEP-3 asks for a record of each new runtime dependency. + +## Decision + +The client lists `@gatepost/core` as its one dependency, with a caret range on the same release line. It has no other dependency. Its size limit of 5 KB counts the core too, because size-limit bundles the dependencies. + +## Reasons + +- A copy of the parser in the client would be a second place for the spec data, which CS-4 forbids, and an app that uses both packages would ship the parser twice. +- The core is a Gatepost package with no dependencies of its own, so the client still pulls in no third-party code. +- The client's types name the core's `Postcode` and `Precision`, so a caller can pass a parsed postcode to `lookup`. + +## Consequences + +- A breaking release of the core needs a release of the client. +- The client's tests and type check read the core's source, so they need no build of the core first. The published package reads the core's built files. diff --git a/package.json b/package.json index 8492343..a0fdd48 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "tells": "python3 spec/scripts/check-tells", "context:check": "cmp CONTEXT.md spec/CONTEXT.md", "licences:check": "node scripts/check-licences.mjs", - "licence-files:check": "cmp LICENSE packages/core/LICENSE && cmp NOTICE packages/core/NOTICE", + "licence-files:check": "pnpm -r run licence-files:check", "reuse:check": "uvx --with charset-normalizer==3.5.2 reuse@6.2.0 lint", "workflows:check": "uvx zizmor@1.30.1 --offline .github/workflows", "generate": "node scripts/generate-spec-data.mjs", diff --git a/packages/client/LICENSE b/packages/client/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/client/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/client/NOTICE b/packages/client/NOTICE new file mode 100644 index 0000000..bc94510 --- /dev/null +++ b/packages/client/NOTICE @@ -0,0 +1,5 @@ +Gatepost +Copyright 2026 The Gatepost authors + +This product is unofficial. The Nigerian Postal Service (NIPOST) did not make it and does not endorse it. +Gatepost uses the name NIPOST only to describe compatibility with NIPOST's National Digital Postcode. diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..6751239 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,198 @@ + + + gatepost + + +# @gatepost/client + +Call NIPOST's postcode gateway from TypeScript: look up a postcode, find the postcode of a place, and complete a postcode that a user is typing. + +> Unofficial. Not made or endorsed by NIPOST. + +[![CI](https://github.com/gatepost-dev/js/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gatepost-dev/js/actions/workflows/ci.yml) +[![Licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](https://github.com/gatepost-dev/js/blob/main/LICENSE) + +## Install + +```sh +pnpm add @gatepost/client +``` + +The package installs `@gatepost/core`, which parses each postcode before a request goes out. + +## Quickstart + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const result = await client.lookup('fc 01 z99 zz 01'); +result.postcode.canonical; // 'FC-01-Z99-ZZ-01' +result.valid; // true +``` + +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. + +Get a key from NIPOST's dashboard. The gateway refuses every call without a key. A key that starts with `nipost_test_` or `nipost_live_` is a secret key, so keep it on a server. The client refuses a secret key in a page that has a `document`, such as a web page. + +## What it does + +- Looks up a postcode at a lookup level from 1 to 5. +- Finds the unit at a coordinate. +- Offers the values of the segment that a user is typing. +- Checks each postcode offline first, so a bad code sends no request. +- Retries a failed request when a retry can help, and waits between attempts. +- Shares one request between identical calls, and sends at most 4 requests at a time. +- Keeps results for a time that you choose. It keeps none by default. + +The client sends only the requests that you make. It has no logging. It sends your key only in the `X-API-Key` header, and none of its own error messages holds the key. + +### Look up a postcode + +`lookup` takes a postcode in any form, or a postcode that `parse` from `@gatepost/core` returned. A postcode that the gateway does not know gives `valid` false and a `status` of `not_found`, not an error. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const unknown = await client.lookup('FC-01-Z99-ZZ-02'); +unknown.valid; // false +unknown.status; // 'not_found' +``` + +A key holds a lookup level, and `lookup` sends level 1 when you give none. NIPOST's docs say that level 1 tells only whether a postcode exists, level 2 adds the names of the places that hold it and a recent house address, and level 3 adds what the building is used for. A call above the key's level fails with `forbidden`. The gateway does not say which level it sent, so `levelReceived` comes from the fields of the response. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const result = await client.lookup('FC-01-Z99-ZZ-01', { level: 2 }); +result.levelRequested; // 2 +result.levelReceived; // 2 +result.administrativeAddress?.lgaName; // 'SYNTHETIC LGA' +``` + +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. The example above shows the answer of the mock server. + +### Handle errors + +Each call raises a `PostcodeError` when it cannot give a result. Its `code` is one of `invalid_input`, `unauthorized`, `insufficient_credits`, `origin_not_allowed`, `forbidden`, `rate_limited`, `server_error`, `unexpected_response`, `network_error` and `timeout`. `unexpected_response` means that the gateway answered 200 with a body that the client cannot read. `status` holds the HTTP status, and `apiCode` holds the gateway's own error code. + +```ts +import { PostcodeClient, PostcodeError } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +try { + await client.lookup('900108'); +} catch (error) { + if (error instanceof PostcodeError) { + error.code; // 'invalid_input' + error.status; // null + } +} +``` + +`900108` is a legacy postcode, so the client sends no request for it. Show the user the message for the error code, not the error's own message, which is for developers. + +When the gateway limits a key, the error is `rate_limited`. The client waits and tries again when the gateway asks for a wait of 10 seconds or less. For a longer wait, `retryAfterMs` holds it, so you can tell the user when to try again. + +### Find the postcode of a place + +`reverse` takes a latitude and a longitude in degrees, and an optional radius in metres. The gateway uses 25 m when you give no radius, and 250 m at most. `found` can be true while `unit` is null: the gateway then found an area, but no unit within the radius. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const place = await client.reverse(9, 7); +place.found; // true +place.unit?.postcode.canonical; // 'FC-01-Z99-ZZ-01' +place.area; // 'FC-01-Z99-ZZ' +``` + +A GPS fix is only as good as its accuracy. `precisionForAccuracy` from `@gatepost/core` tells you which segment a fix supports. + +### Complete a typed postcode + +`autocomplete` takes the text that a user typed. It names the segment that the user is typing, and the gateway's values for it. Each value comes with the partial postcode that it completes. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const typing = await client.autocomplete('fc 01 z'); +typing.segment; // 'district' +typing.suggestions[0]?.code; // 'Z99' +typing.suggestions[0]?.postcode?.canonical; // 'FC-01-Z99' +``` + +Empty text sends no request and fails with `invalid_input`, because the gateway does not answer it. A timeout of `autocomplete` gets no retry, because the next keystroke replaces the call. `autocomplete` waits 15 seconds for one attempt, not 8, unless you set `timeoutMs`. + +### Cancel a call + +Each call takes an `AbortSignal`. A cancelled call ends with the signal's reason, not with a `PostcodeError`. When every caller of a shared request cancels, the request stops and frees its place. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const keystroke = new AbortController(); +const call = client.autocomplete('fc 0', { signal: keystroke.signal }); +keystroke.abort(); +const outcome = await call.catch((reason: unknown) => reason); +outcome === keystroke.signal.reason; // true +``` + +### Choose the settings + +| Option | Meaning | Default | +| ------------ | --------------------------------------------------- | ---------------------------------- | +| `apiKey` | the key for the `X-API-Key` header | none, so no key is sent | +| `baseUrl` | the gateway's address | `https://api.postcode.gov.ng` | +| `transport` | the function that sends each request, like `fetch` | the platform's `fetch` | +| `timeoutMs` | the longest wait for one attempt, in milliseconds | 8000, and 15000 for `autocomplete` | +| `maxRetries` | the most retries after the first attempt | 2 | +| `cacheTtlMs` | how long the client keeps a result, in milliseconds | 0, which keeps none | + +A client retries a 502, 503 or 504, a lost connection and a timeout. It waits 500 ms before the first retry and 1000 ms before the second, plus up to 250 ms at random. With `cacheTtlMs` above 0, an identical call within that time gets the kept result with no request. Errors stay out of the cache, and `clearCache` removes every kept result. The cache of each call type keeps at most 1000 results and drops the oldest first. Identical calls share one result object, and so does the cache. Do not change a result that a call returned. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const apiKey = process.env['NIPOST_API_KEY']; +const client = new PostcodeClient({ apiKey, cacheTtlMs: 60_000 }); +await client.lookup('FC-01-Z99-ZZ-01'); +client.clearCache(); +``` + +## Requirements + +| Requirement | Version | +| ------------- | ---------------------------------------------- | +| Node | 22 or later, or a current browser with `fetch` | +| Module format | ESM only | +| Gatepost spec | 0.2.0 | + +The package exports `SPEC_VERSION`, the version of the Gatepost spec that it implements. + +```ts +import { SPEC_VERSION } from '@gatepost/client'; + +SPEC_VERSION; // '0.2.0' +``` + +## Docs + +The API report is in [`etc/client.api.md`](https://github.com/gatepost-dev/js/blob/main/packages/client/etc/client.api.md). [`client.md`](https://github.com/gatepost-dev/spec/blob/main/client.md) in the spec repo defines the results, the errors and the retries, and every Gatepost client passes the same contract scenarios. + +## Support + +Ask questions and report bugs in [GitHub Issues](https://github.com/gatepost-dev/js/issues). Report security problems privately, as [`SECURITY.md`](https://github.com/gatepost-dev/.github/blob/main/SECURITY.md) describes. + +## Contributing + +Read [`CONTRIBUTING.md`](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request. + +## Licence + +Apache-2.0. See [`LICENSE`](https://github.com/gatepost-dev/js/blob/main/LICENSE) and [`NOTICE`](https://github.com/gatepost-dev/js/blob/main/NOTICE). diff --git a/packages/client/api-extractor.json b/packages/client/api-extractor.json new file mode 100644 index 0000000..839481e --- /dev/null +++ b/packages/client/api-extractor.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "/dist/index.d.ts", + "compiler": { + "overrideTsconfig": { + "extends": "../../tsconfig.base.json", + "compilerOptions": { "types": ["node"] }, + "files": ["dist/index.d.ts"] + } + }, + "apiReport": { + "enabled": true, + "reportFolder": "/etc/", + "reportTempFolder": "/temp/" + }, + "docModel": { "enabled": false }, + "dtsRollup": { "enabled": false }, + "tsdocMetadata": { "enabled": false }, + "messages": { + "extractorMessageReporting": { + "ae-undocumented": { "logLevel": "error", "addToApiReportFile": false }, + "ae-missing-release-tag": { "logLevel": "none" } + } + } +} diff --git a/packages/client/etc/client.api.md b/packages/client/etc/client.api.md new file mode 100644 index 0000000..352b142 --- /dev/null +++ b/packages/client/etc/client.api.md @@ -0,0 +1,118 @@ +## API Report File for "@gatepost/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Postcode } from '@gatepost/core'; +import { Precision } from '@gatepost/core'; + +// @public +export interface AdministrativeAddress { + readonly lgaName: string | null; + readonly localityName: string | null; + readonly stateName: string | null; + readonly zone: string | null; +} + +// @public +export interface AutocompleteResult { + readonly segment: Precision; + readonly suggestions: readonly { + readonly code: string; + readonly label: string | null; + readonly postcode: Postcode | null; + }[]; +} + +// @public +export interface ClientOptions { + readonly apiKey?: string | undefined; + readonly baseUrl?: string; + readonly cacheTtlMs?: number; + readonly maxRetries?: number; + readonly timeoutMs?: number; + readonly transport?: typeof globalThis.fetch; +} + +// @public +export type Confidence = "high" | "medium" | "low"; + +// @public +export type LookupLevel = 1 | 2 | 3 | 4 | 5; + +// @public +export interface LookupResult { + readonly administrativeAddress: AdministrativeAddress | null; + readonly buildingUseStatus: string | null; + readonly levelReceived: LookupLevel; + readonly levelRequested: LookupLevel; + readonly postcode: Postcode; + readonly recentHouseAddress: string | null; + readonly status: string | null; + readonly valid: boolean; +} + +// @public +export type LookupStatus = "valid" | "invalid" | "not_found" | "restricted"; + +// @public +export class PostcodeClient { + constructor(options?: ClientOptions); + autocomplete(q: string, options?: Readonly<{ + signal?: AbortSignal; + }>): Promise; + clearCache(): void; + lookup(code: string | Postcode, options?: Readonly<{ + level?: LookupLevel; + signal?: AbortSignal; + }>): Promise; + reverse(lat: number, lng: number, options?: Readonly<{ + maxDistanceM?: number; + signal?: AbortSignal; + }>): Promise; +} + +// @public +export class PostcodeError extends Error { + constructor(code: PostcodeErrorCode, message: string, details?: Readonly<{ + status?: number | null; + apiCode?: string | null; + retryAfterMs?: number | null; + cause?: unknown; + }>); + readonly apiCode: string | null; + readonly code: PostcodeErrorCode; + override readonly name: string; + readonly retryAfterMs: number | null; + readonly status: number | null; +} + +// @public +export type PostcodeErrorCode = "invalid_input" | "unauthorized" | "insufficient_credits" | "origin_not_allowed" | "forbidden" | "rate_limited" | "server_error" | "unexpected_response" | "network_error" | "timeout"; + +// @public +export interface ReverseResult { + readonly area: string | null; + readonly district: string | null; + readonly found: boolean; + readonly radiusM: number | null; + readonly state: string | null; + readonly unit: ReverseUnit | null; +} + +// @public +export interface ReverseUnit { + readonly address: string | null; + readonly confidence: Confidence; + readonly distanceM: number; + readonly lgaName: string | null; + readonly localityName: string | null; + readonly postcode: Postcode; + readonly stateName: string | null; +} + +// @public +export const SPEC_VERSION = "0.2.0"; + +``` diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..c5e7496 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,57 @@ +{ + "name": "@gatepost/client", + "version": "0.0.0", + "description": "Unofficial. Not made or endorsed by NIPOST. Call NIPOST's postcode gateway.", + "license": "Apache-2.0", + "homepage": "https://github.com/gatepost-dev/js/tree/main/packages/client#readme", + "bugs": "https://github.com/gatepost-dev/js/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/gatepost-dev/js.git", + "directory": "packages/client" + }, + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "NOTICE" + ], + "engines": { + "node": ">=22" + }, + "keywords": [ + "nigeria", + "postcode", + "address", + "nipost" + ], + "size-limit": [ + { + "path": "dist/index.js", + "limit": "6 KB" + } + ], + "dependencies": { + "@gatepost/core": "workspace:^" + }, + "devDependencies": { + "@gatepost/mock-server": "workspace:*" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run --coverage", + "build": "tsdown", + "package:check": "run-s package:lint package:smoke", + "package:lint": "publint && attw --pack --profile esm-only && size-limit && api-extractor run", + "package:smoke": "node scripts/smoke.mjs", + "api:update": "api-extractor run --local", + "licence-files:check": "cmp ../../LICENSE LICENSE && cmp ../../NOTICE NOTICE" + } +} diff --git a/packages/client/scripts/smoke.mjs b/packages/client/scripts/smoke.mjs new file mode 100644 index 0000000..6853191 --- /dev/null +++ b/packages/client/scripts/smoke.mjs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Starts the built package as a user does: by its name, through the exports map of package.json. +// The unit tests import src/, and publint, attw, size-limit and API Extractor only read dist/. +// So this is the one check that runs the built code, with the built core. It sends no request: +// the transport fails the check if the client calls it. +import { PostcodeClient, PostcodeError } from '@gatepost/client'; + +const transport = () => { + throw new Error('The built client sent a request for a code that does not parse.'); +}; +const client = new PostcodeClient({ transport }); +const outcome = await client.lookup('ek 01 a03').catch((error) => error); +if (!(outcome instanceof PostcodeError) || outcome.code !== 'invalid_input') { + throw new Error(`The built package gave ${String(outcome)} for a partial postcode.`); +} diff --git a/packages/client/src/calls.ts b/packages/client/src/calls.ts new file mode 100644 index 0000000..d38658e --- /dev/null +++ b/packages/client/src/calls.ts @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The calls of one method of a client: the requests in flight, which identical calls share, + * and the kept results. + * + * @internal + */ +export interface Calls { + /** + * Gives the kept result for the key, joins the request in flight for it, or starts one. + * + * @param key - The method's arguments, as text. Equal arguments give an equal key. + * @param run - Starts the request. Its signal aborts when every caller has left. + * @param signal - The caller's signal, if any. + * @returns The result. + */ + call(key: string, run: (signal: AbortSignal) => Promise, signal?: AbortSignal): Promise; + /** Removes every kept result. Requests in flight go on. */ + clear(): void; +} + +// A server can call with many different arguments, so a client keeps at most this many results. +const MAX_KEPT = 1000; + +interface InFlight { + readonly promise: Promise; + readonly controller: AbortController; + callers: number; +} + +/** + * Makes the store of calls for one method. + * + * @param cacheTtlMs - How long a result stays, in milliseconds. 0 keeps nothing. + * @returns The store. + * @internal + */ +export function createCalls(cacheTtlMs: number): Calls { + let epoch = 0; + const inFlight = new Map>(); + const kept = new Map(); + + // The key is not in the map, because call() removes a result that has expired. + // Every result lives for the same time, so the oldest entries are at the front of the map. + const keep = (key: string, result: T): void => { + const now = Date.now(); + for (const [oldKey, entry] of kept) { + if (entry.until > now && kept.size < MAX_KEPT) { + break; + } + kept.delete(oldKey); + } + kept.set(key, { result, until: now + cacheTtlMs }); + }; + + const start = (key: string, run: (signal: AbortSignal) => Promise): InFlight => { + const controller = new AbortController(); + const startedIn = epoch; + const promise = run(controller.signal).then((result) => { + // A result of a request that started before clearCache must not come back. + if (cacheTtlMs > 0 && startedIn === epoch) { + keep(key, result); + } + return result; + }); + const entry = { promise, controller, callers: 0 }; + const forget = (): void => { + if (inFlight.get(key) === entry) { + inFlight.delete(key); + } + }; + // When every caller has left, nobody reads the outcome. The second callback marks the + // rejection as handled, so that it is not reported as unhandled. + promise.then(forget, forget); + inFlight.set(key, entry); + return entry; + }; + + // Each caller can leave with its own signal. The request stops when the last caller leaves, + // so a replaced keystroke frees its place in the queue. + const join = (key: string, entry: InFlight, signal: AbortSignal | undefined): Promise => { + entry.callers += 1; + if (signal === undefined) { + return entry.promise; + } + return new Promise((resolve, reject) => { + const leave = (): void => { + entry.callers -= 1; + if (entry.callers === 0) { + inFlight.delete(key); + entry.controller.abort(signal.reason); + } + // API-11: a cancelled call ends with the reason of the caller's signal, of any type. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- API-11 + reject(signal.reason); + }; + signal.addEventListener('abort', leave, { once: true }); + entry.promise.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', leave); + }); + }); + }; + + return { + async call(key, run, signal) { + signal?.throwIfAborted(); + const hit = kept.get(key); + if (hit !== undefined && hit.until > Date.now()) { + return hit.result; + } + kept.delete(key); + return await join(key, inFlight.get(key) ?? start(key, run), signal); + }, + clear() { + epoch += 1; + kept.clear(); + }, + }; +} diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 0000000..8831c0a --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { Postcode } from '@gatepost/core'; +import { createCalls, type Calls } from './calls.js'; +import { createQueue } from './queue.js'; +import { autocompleteRequest, lookupRequest, reverseRequest } from './requests.js'; +import { readAutocomplete, readLookup, readReverse } from './responses.js'; +import { send, type SendSettings } from './send.js'; +import type { + AutocompleteResult, + ClientOptions, + LookupLevel, + LookupResult, + ReverseResult, +} from './types.js'; + +// API-8: a client sends at most 4 requests at a time. +const PARALLEL_REQUESTS = 4; +const AUTOCOMPLETE_TIMEOUT_MS = 15_000; +const SECRET_KEY = /^nipost_(test|live)_/; + +// The numeric options and the rule for each. A value that breaks its rule is a programmer error. +const NUMBER_RULES = [ + ['timeoutMs', (ms: number) => ms > 0 && Number.isFinite(ms), 'a number above 0'], + [ + 'maxRetries', + (retries: number) => Number.isSafeInteger(retries) && retries >= 0, + 'a whole number of 0 or more', + ], + // NIPOST's rules give every cache a time limit, so Infinity is not allowed. + ['cacheTtlMs', (ms: number) => ms >= 0 && Number.isFinite(ms), 'a number of 0 or more'], +] as const; + +function checkOptions(options: ClientOptions): void { + for (const [name, isAllowed, rule] of NUMBER_RULES) { + const option = options[name]; + if (option !== undefined && !isAllowed(option)) { + throw new RangeError(`${name} must be ${rule}. It is ${String(option)}.`); + } + } + // SEC-1: anyone can read a key that a web page holds. The message names the key's kind only. + const { apiKey } = options; + if (apiKey === '') { + throw new RangeError('apiKey must not be empty.'); + } + if (apiKey !== undefined && SECRET_KEY.test(apiKey) && 'document' in globalThis) { + throw new TypeError('A web page must not hold a secret key. Use a publishable key here.'); + } +} + +/** + * Calls NIPOST's postcode gateway. A client retries, waits, shares identical calls and limits + * its parallel requests as `spec/client.md` says. It sends only the requests that its caller + * makes, and it never logs. + * + * @example + * ```ts + * import { PostcodeClient } from '@gatepost/client'; + * + * const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); + * const result = await client.lookup('fc 01 z99 zz 01'); + * result.postcode.canonical; // 'FC-01-Z99-ZZ-01' + * ``` + */ +export class PostcodeClient { + readonly #settings: SendSettings; + readonly #autocompleteSettings: SendSettings; + readonly #lookups: Calls; + readonly #reverses: Calls; + readonly #autocompletes: Calls; + + /** + * Makes a client. Two clients share nothing, so each can have its own key and settings. + * + * @param options - The key, the gateway's address, the transport, the timeout, the retries + * and the cache time. Each one is optional. + * @throws RangeError for a `timeoutMs` of 0 or less, a `maxRetries` that is not a whole + * number of 0 or more, a `cacheTtlMs` that is negative or not finite, or an empty `apiKey`. + * @throws TypeError for a secret key in a web page. + */ + constructor(options: ClientOptions = {}) { + checkOptions(options); + const cacheTtlMs = options.cacheTtlMs ?? 0; + this.#settings = { + baseUrl: options.baseUrl ?? 'https://api.postcode.gov.ng', + apiKey: options.apiKey, + transport: options.transport ?? ((input, init) => globalThis.fetch(input, init)), + timeoutMs: options.timeoutMs ?? 8000, + maxRetries: options.maxRetries ?? 2, + queue: createQueue(PARALLEL_REQUESTS), + }; + // A user waits for each completion, so autocomplete gets a longer timeout than a lookup. + this.#autocompleteSettings = { + ...this.#settings, + timeoutMs: options.timeoutMs ?? AUTOCOMPLETE_TIMEOUT_MS, + }; + this.#lookups = createCalls(cacheTtlMs); + this.#reverses = createCalls(cacheTtlMs); + this.#autocompletes = createCalls(cacheTtlMs); + } + + /** + * Asks the gateway about one postcode. The core parses the code first, and a code that does + * not parse as a whole postcode sends no request. A postcode that the gateway does not know + * gives a result with `valid` false, not an error. + * + * @param code - The postcode as text in any form, or as a postcode that `parse` returned. + * @param options - The lookup `level`, which is 1 by default, and a `signal` that cancels the + * call. + * @returns The facts about the postcode, up to the level that the key holds. + * @throws PostcodeError `invalid_input` for a code that does not parse, and the code that the + * status map gives for a failed response. + * @example + * ```ts + * import { PostcodeClient } from '@gatepost/client'; + * + * const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); + * const result = await client.lookup('FC-01-Z99-ZZ-01', { level: 2 }); + * result.levelRequested; // 2 + * if (result.levelReceived >= 2) { + * result.administrativeAddress?.stateName; + * } + * ``` + */ + async lookup( + code: string | Postcode, + options: Readonly<{ level?: LookupLevel; signal?: AbortSignal }> = {}, + ): Promise { + const level = options.level ?? 1; + const { postcode, request } = lookupRequest(code, level); + const run = async (signal: AbortSignal): Promise => { + const answer = await send(this.#settings, request, { signal, timeoutRetried: true }); + return readLookup(answer, { postcode, level }); + }; + return await this.#lookups.call(`${postcode.canonical} ${String(level)}`, run, options.signal); + } + + /** + * Asks the gateway for the unit nearest to a coordinate. A point with no postcode in range + * gives a result with `found` false, not an error. + * + * @param lat - The latitude in degrees. + * @param lng - The longitude in degrees. + * @param options - The radius `maxDistanceM` in metres, which the gateway sets to 25 when it + * is missing, and a `signal` that cancels the call. + * @returns What the gateway found. + * @throws PostcodeError with the code that the status map gives for a failed response. + * @example + * ```ts + * import { PostcodeClient } from '@gatepost/client'; + * + * const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); + * const result = await client.reverse(9, 7, { maxDistanceM: 250 }); + * if (result.unit !== null) { + * result.unit.postcode.canonical; + * } + * ``` + */ + async reverse( + lat: number, + lng: number, + options: Readonly<{ maxDistanceM?: number; signal?: AbortSignal }> = {}, + ): Promise { + const request = reverseRequest(lat, lng, options.maxDistanceM); + const run = async (signal: AbortSignal): Promise => + readReverse(await send(this.#settings, request, { signal, timeoutRetried: true })); + const key = `${String(lat)} ${String(lng)} ${String(options.maxDistanceM)}`; + return await this.#reverses.call(key, run, options.signal); + } + + /** + * Asks the gateway for the values of the segment that a user is typing. Empty text sends no + * request, because the gateway does not answer it. A timeout gets no retry, because the next + * keystroke replaces the call. + * + * @param q - The text that the user typed, in any spacing and letter case. + * @param options - A `signal` that cancels the call. + * @returns The active segment and the gateway's values for it. + * @throws PostcodeError `invalid_input` for text that is empty, too long or not letters and + * digits, and the code that the status map gives for a failed response. + * @example + * ```ts + * import { PostcodeClient } from '@gatepost/client'; + * + * const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); + * const result = await client.autocomplete('fc 01 z'); + * result.segment; // 'district' + * ``` + */ + async autocomplete( + q: string, + options: Readonly<{ signal?: AbortSignal }> = {}, + ): Promise { + const { typed, request } = autocompleteRequest(q); + const run = async (signal: AbortSignal): Promise => + readAutocomplete( + await send(this.#autocompleteSettings, request, { signal, timeoutRetried: false }), + typed, + ); + return await this.#autocompletes.call(typed, run, options.signal); + } + + /** + * Removes every kept result, so that the next call of each kind sends a request. + * + * @example + * ```ts + * import { PostcodeClient } from '@gatepost/client'; + * + * const apiKey = process.env['NIPOST_API_KEY']; + * const client = new PostcodeClient({ apiKey, cacheTtlMs: 60_000 }); + * await client.lookup('FC-01-Z99-ZZ-01'); + * client.clearCache(); + * ``` + */ + clearCache(): void { + this.#lookups.clear(); + this.#reverses.clear(); + this.#autocompletes.clear(); + } +} diff --git a/packages/client/src/error.ts b/packages/client/src/error.ts new file mode 100644 index 0000000..279fac3 --- /dev/null +++ b/packages/client/src/error.ts @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { PostcodeErrorCode } from './types.js'; + +/** + * The error that each call of `PostcodeClient` raises when it cannot give a result. A + * cancelled call raises the platform's own abort error instead. + * + * @example + * ```ts + * import { PostcodeClient, PostcodeError } from '@gatepost/client'; + * + * const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); + * try { + * await client.lookup('FC-01-Z99'); + * } catch (error) { + * if (error instanceof PostcodeError) { + * error.code; // 'invalid_input' + * error.status; // null + * } + * } + * ``` + */ +export class PostcodeError extends Error { + /** Always `PostcodeError`, so that a log names the error type. */ + override readonly name: string = 'PostcodeError'; + /** Why the call failed, as a stable code. */ + readonly code: PostcodeErrorCode; + /** The HTTP status of the response, or null when no response arrived. */ + readonly status: number | null; + /** The `error.code` of the response body, or null. */ + readonly apiCode: string | null; + /** The wait that `Retry-After` asked for, in milliseconds, or null. */ + readonly retryAfterMs: number | null; + + /** + * Makes an error. The client makes each one. A test of code that uses the client can make + * one too. + * + * @param code - Why the call failed. + * @param message - What happened and what to do, with no API key in it. + * @param details - The `status`, the `apiCode`, the `retryAfterMs` and the `cause`. Each one + * that is missing is null. + */ + constructor( + code: PostcodeErrorCode, + message: string, + details: Readonly<{ + status?: number | null; + apiCode?: string | null; + retryAfterMs?: number | null; + cause?: unknown; + }> = {}, + ) { + super(message, details.cause === undefined ? undefined : { cause: details.cause }); + this.code = code; + this.status = details.status ?? null; + this.apiCode = details.apiCode ?? null; + this.retryAfterMs = details.retryAfterMs ?? null; + } +} + +/** + * The part of a response that decides the error: its status, its body and its `Retry-After`. + * + * @internal + */ +export interface FailedResponse { + readonly status: number; + readonly apiCode: string | null; + readonly retryAfterMs: number | null; +} + +function codeFor({ status, apiCode }: FailedResponse): PostcodeErrorCode { + if (status === 401) { + return 'unauthorized'; + } + if (status === 402) { + return 'insufficient_credits'; + } + if (status === 403) { + return apiCode === 'origin_not_allowed' ? 'origin_not_allowed' : 'forbidden'; + } + if (status === 429) { + return 'rate_limited'; + } + return status >= 400 && status < 500 ? 'invalid_input' : 'server_error'; +} + +const ADVICE: Readonly> = { + invalid_input: 'The gateway refused the request. Check the arguments.', + unauthorized: 'The gateway refused the API key. Check the apiKey option.', + insufficient_credits: 'The account has no credits. Add credits, or use level 1.', + origin_not_allowed: "The gateway refused this page's origin. Add it to the publishable key.", + forbidden: "The gateway refused the call. Check the key's scope and lookup level.", + rate_limited: 'The gateway limits the requests of this key. Wait, then try again.', + server_error: 'The gateway failed. Try again later.', + unexpected_response: + 'The gateway sent a reply that the client cannot read. Try again, or update it.', + network_error: 'The request did not reach the gateway. Check the network and the baseUrl.', + timeout: 'The gateway sent no response in time. Try again, or raise timeoutMs.', +}; + +/** + * Maps a response that is not a success to its error, as the table in `spec/client.md` says. + * + * @param response - The status, the API's error code and the wait of the response. + * @returns The error. + * @internal + */ +export function errorForResponse(response: FailedResponse): PostcodeError { + const code = codeFor(response); + return new PostcodeError(code, `${ADVICE[code]} (status ${String(response.status)})`, { + status: response.status, + apiCode: response.apiCode, + retryAfterMs: response.retryAfterMs, + }); +} + +/** + * Makes an error for a failure with no response, or for a check of the client. + * + * @param code - `invalid_input`, `network_error`, `timeout`, `unexpected_response` or + * `server_error`. + * @param detail - A sentence that adds to the advice, or an empty string. For `invalid_input` + * the advice is left out, so the detail must be a full message and not empty. + * @param details - The `cause` that the platform raised, or the `status` of a response that + * the client could not read. + * @returns The error. + * @internal + */ +export function clientError( + code: PostcodeErrorCode, + detail: string, + details: Readonly<{ cause?: unknown; status?: number }> = {}, +): PostcodeError { + const message = code === 'invalid_input' ? detail : `${ADVICE[code]} ${detail}`.trim(); + return new PostcodeError(code, message, details); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..e425986 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Call NIPOST's postcode gateway: look up a postcode, find the unit nearest to a coordinate, + * and complete a postcode that a user is typing. + * + * Unofficial. Not made or endorsed by NIPOST. + * + * @packageDocumentation + */ +export { PostcodeClient } from './client.js'; +export { PostcodeError } from './error.js'; +export type { + AdministrativeAddress, + AutocompleteResult, + ClientOptions, + Confidence, + LookupLevel, + LookupResult, + LookupStatus, + PostcodeErrorCode, + ReverseResult, + ReverseUnit, +} from './types.js'; +export { SPEC_VERSION } from './version.js'; diff --git a/packages/client/src/queue.ts b/packages/client/src/queue.ts new file mode 100644 index 0000000..8814fa1 --- /dev/null +++ b/packages/client/src/queue.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Runs tasks with at most a fixed number at a time. The others wait in the order of their calls. + * + * @internal + */ +export type Queue = (task: () => Promise, signal: AbortSignal) => Promise; + +/** + * Makes a queue. A task that waits for its turn leaves the queue when its signal aborts, so a + * cancelled call never holds a place (API-8, API-11). + * + * @param limit - The most tasks that run at one time. + * @returns The queue. + * @internal + */ +export function createQueue(limit: number): Queue { + let running = 0; + const waiting: (() => void)[] = []; + + const release = (): void => { + running -= 1; + waiting.shift()?.(); + }; + + const turn = (signal: AbortSignal): Promise => { + if (running < limit) { + running += 1; + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const start = (): void => { + signal.removeEventListener('abort', leave); + running += 1; + resolve(); + }; + const leave = (): void => { + waiting.splice(waiting.indexOf(start), 1); + // API-11: a cancelled call ends with the reason of the caller's signal, of any type. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- API-11 + reject(signal.reason); + }; + waiting.push(start); + signal.addEventListener('abort', leave, { once: true }); + }); + }; + + return async (task, signal) => { + signal.throwIfAborted(); + await turn(signal); + try { + return await task(); + } finally { + release(); + } + }; +} diff --git a/packages/client/src/requests.ts b/packages/client/src/requests.ts new file mode 100644 index 0000000..91929da --- /dev/null +++ b/packages/client/src/requests.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { normalize, parse, type Postcode } from '@gatepost/core'; +import { clientError } from './error.js'; +import type { LookupLevel } from './types.js'; + +// A typed prefix of a postcode: letters and digits, at most as many as a whole compact code. +const QUERY = /^[A-Z0-9]{1,11}$/; +// The input limit of the grammar, maxInputCodePoints in spec/data/format.json. A test reads that +// file, so a change there fails CI. normalize has no limit of its own, and long text can stall it. +const INPUT_LIMIT = 64; +// The most that the gateway accepts as a radius, in metres. +const MAX_DISTANCE_M = 250; +// What String prints for a number below 1e-6 or above 1e21. +const EXPONENT_FORM = /^(-?)(\d)(?:\.(\d+))?e([+-]\d+)$/; + +// A code point takes one or two UTF-16 units, so the first 2 x (limit + 1) units hold one code +// point more than the limit whenever the whole text does. +function isOverInputLimit(text: string): boolean { + const head = text.slice(0, 2 * (INPUT_LIMIT + 1)); + return text.length > INPUT_LIMIT && Array.from(head).length > INPUT_LIMIT; +} + +// The shortest decimal form that reads back as the same number, with no exponent. Every +// client prints the same query for the same input, whatever its language prints for a number. +// Every number that reaches this function is at most 250 in size, so only a number below 1e-6 +// prints with an exponent, and it has a negative one. +function decimal(value: number): string { + const printed = String(value); + const parts = EXPONENT_FORM.exec(printed); + if (parts === null) { + return printed; + } + const [, sign = '', first = '', rest = '', exponent = '0'] = parts; + const digits = first + rest; + return `${sign}0.${'0'.repeat(-1 - Number(exponent))}${digits}`; +} + +function requireRange(name: string, value: unknown, min: number, max: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) { + throw clientError( + 'invalid_input', + `${name} must be a number from ${String(min)} to ${String(max)}.`, + ); + } + return value; +} + +/** + * One request to the gateway: its path and its query. + * + * @internal + */ +export interface GatewayRequest { + readonly path: string; + readonly query: Readonly>; +} + +/** + * Builds a lookup request. The code must parse as a whole postcode, so that no request goes + * out for text that the gateway would only call invalid. + * + * @param code - The postcode as text, or as a postcode that `parse` returned. + * @param level - The lookup level, from 1 to 5. The default is 1. + * @returns The parsed postcode, the level and the request. + * @throws PostcodeError `invalid_input` when the code is not a whole postcode, or when the + * level is not a whole number from 1 to 5. + * @internal + */ +export function lookupRequest( + code: string | Postcode, + level: number | undefined, +): { readonly postcode: Postcode; readonly level: LookupLevel; readonly request: GatewayRequest } { + const wanted = level ?? 1; + if (!Number.isInteger(wanted) || wanted < 1 || wanted > 5) { + throw clientError('invalid_input', 'The level must be a whole number from 1 to 5.'); + } + const parsed = parse(typeof code === 'string' ? code : code.canonical); + if (!parsed.ok) { + const reason = `The code is not a whole postcode (${parsed.error.code}).`; + throw clientError('invalid_input', `${reason} Check it with parse from @gatepost/core.`); + } + const query = { code: parsed.value.canonical, level: String(wanted) }; + return { + postcode: parsed.value, + level: wanted as LookupLevel, + request: { path: '/v1/lookup', query }, + }; +} + +/** + * Builds a reverse geocode request. + * + * @param lat - The latitude in degrees, from -90 to 90. + * @param lng - The longitude in degrees, from -180 to 180. + * @param maxDistanceM - The radius in metres, from 0 to 250, or undefined for the gateway's + * default. The gateway cuts a larger value without a sign, so the client refuses it. + * @returns The request. + * @throws PostcodeError `invalid_input` when a number is outside its range or is not finite. + * @internal + */ +export function reverseRequest( + lat: number, + lng: number, + maxDistanceM: number | undefined, +): GatewayRequest { + const query: Record = { + lat: decimal(requireRange('The latitude', lat, -90, 90)), + lng: decimal(requireRange('The longitude', lng, -180, 180)), + }; + if (maxDistanceM !== undefined) { + const radius = requireRange('The radius', maxDistanceM, 0, MAX_DISTANCE_M); + query['max_distance_m'] = decimal(radius); + } + return { path: '/v1/search/reverse', query }; +} + +/** + * Builds an autocomplete request from the text that a user typed. + * + * @param q - The typed text. + * @returns The normalised text and the request. + * @throws PostcodeError `invalid_input` when the text is over the input limit, or when the + * normalised text is empty, too long, or holds a character other than A to Z and 0 to 9. The + * gateway does not answer an empty query. + * @internal + */ +export function autocompleteRequest(q: string): { + readonly typed: string; + readonly request: GatewayRequest; +} { + const typed = isOverInputLimit(q) ? '' : normalize(q); + if (!QUERY.test(typed)) { + const rule = 'The text must hold 1 to 11 letters and digits after normalize.'; + throw clientError('invalid_input', `${rule} Send no call for empty text.`); + } + return { typed, request: { path: '/v1/search/autocomplete', query: { q: typed } } }; +} diff --git a/packages/client/src/responses.ts b/packages/client/src/responses.ts new file mode 100644 index 0000000..0b8056a --- /dev/null +++ b/packages/client/src/responses.ts @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Each reader narrows the JSON of a response and reads only the fields that it knows (TS-20). +// Unknown fields stay unread. A known field of the wrong type raises unexpected_response, and +// an unknown value of a field that allows one gets its fallback (API-14). +import { parse, type Postcode, type Precision } from '@gatepost/core'; +import { clientError } from './error.js'; +import type { + AdministrativeAddress, + AutocompleteResult, + Confidence, + LookupLevel, + LookupResult, + ReverseResult, + ReverseUnit, +} from './types.js'; + +type Fields = Readonly>; + +const CONFIDENCES: readonly Confidence[] = ['high', 'medium', 'low']; +const SEGMENTS: readonly Precision[] = ['state', 'lga', 'district', 'area', 'unit']; + +function isFields(json: unknown): json is Fields { + return typeof json === 'object' && json !== null && !Array.isArray(json); +} + +function isNumber(field: unknown): field is number { + return typeof field === 'number' && Number.isFinite(field); +} + +function textOf(fields: Fields, name: string): string | null { + const field = fields[name]; + return typeof field === 'string' ? field : null; +} + +function present(fields: Fields, name: string): boolean { + return fields[name] !== undefined && fields[name] !== null; +} + +function oneOf(allowed: readonly T[], field: unknown): T | null { + return allowed.find((choice) => choice === field) ?? null; +} + +// The readers see only responses with status 200, so the error carries that status. +function unexpected(what: string): never { + throw clientError('unexpected_response', `${what}.`, { status: 200 }); +} + +/** + * Reads the API's own error code from the body of a failed response. + * + * @param body - The parsed JSON body, or undefined when the body is not JSON. + * @returns The text in `error.code`, or null. + * @internal + */ +export function apiCodeOf(body: unknown): string | null { + const error = isFields(body) ? body['error'] : undefined; + return isFields(error) ? textOf(error, 'code') : null; +} + +/** + * Reads the `data` field of a successful response. + * + * @param body - The parsed JSON body, or undefined when the body is not JSON. + * @returns The value of `data`. + * @throws PostcodeError `unexpected_response` when the body is not a JSON object with `data`. + * @internal + */ +export function dataOf(body: unknown): unknown { + if (!isFields(body) || body['data'] === undefined) { + return unexpected('the body has no data'); + } + return body['data']; +} + +function levelOf(data: Fields): LookupLevel { + if (present(data, 'point_geometry')) { + return 5; + } + if (present(data, 'other_building_info')) { + return 4; + } + if (present(data, 'building_use_status')) { + return 3; + } + return present(data, 'administrative_address') || present(data, 'recent_house_address') ? 2 : 1; +} + +function addressOf(data: Fields): AdministrativeAddress | null { + const address = data['administrative_address']; + if (!isFields(address)) { + return null; + } + return { + stateName: textOf(address, 'state_name'), + lgaName: textOf(address, 'lga_name'), + localityName: textOf(address, 'locality_name'), + zone: textOf(address, 'zone'), + }; +} + +/** + * Reads the data of a lookup response. + * + * @param data - The `data` field of the response. + * @param request - The parsed postcode and the level that the caller asked for. + * @returns The lookup result. A `status` of any text stays as it is. + * @throws PostcodeError `unexpected_response` when `valid` is not a boolean, or when `status` + * is present, not null and not text. + * @internal + */ +export function readLookup( + data: unknown, + request: Readonly<{ postcode: Postcode; level: LookupLevel }>, +): LookupResult { + if (!isFields(data) || typeof data['valid'] !== 'boolean') { + return unexpected('valid is not a boolean'); + } + if (present(data, 'status') && typeof data['status'] !== 'string') { + return unexpected('status is not text'); + } + const house = data['recent_house_address']; + return { + postcode: request.postcode, + valid: data['valid'], + status: textOf(data, 'status'), + levelRequested: request.level, + levelReceived: levelOf(data), + administrativeAddress: addressOf(data), + recentHouseAddress: isFields(house) ? textOf(house, 'recent') : null, + buildingUseStatus: textOf(data, 'building_use_status'), + }; +} + +function unitOf(unit: unknown): ReverseUnit | null { + if (unit === undefined || unit === null) { + return null; + } + if (!isFields(unit) || !isNumber(unit['distance_m'])) { + return unexpected('distance_m is not a number'); + } + const parsed = parse(textOf(unit, 'postcode') ?? ''); + if (!parsed.ok) { + return unexpected('the postcode does not parse'); + } + return { + postcode: parsed.value, + distanceM: unit['distance_m'], + confidence: oneOf(CONFIDENCES, unit['confidence']) ?? 'low', + stateName: textOf(unit, 'state_name'), + lgaName: textOf(unit, 'lga_name'), + localityName: textOf(unit, 'locality_name'), + address: textOf(unit, 'address'), + }; +} + +/** + * Reads the data of a reverse geocode response. + * + * @param data - The `data` field of the response. + * @returns The reverse result. + * @throws PostcodeError `unexpected_response` when `found` is not a boolean, or when a unit is + * present and its postcode does not parse or it has no distance. + * @internal + */ +export function readReverse(data: unknown): ReverseResult { + if (!isFields(data) || typeof data['found'] !== 'boolean') { + return unexpected('found is not a boolean'); + } + const radius = data['radius_m']; + return { + found: data['found'], + radiusM: isNumber(radius) ? radius : null, + unit: unitOf(data['unit']), + area: textOf(data, 'area'), + district: textOf(data, 'district'), + state: textOf(data, 'state'), + }; +} + +// The gateway sends the value of the active segment only. The typed text up to the start of that +// segment, plus the value, makes a partial postcode. It counts when it has the precision of the +// segment, so a value of the wrong length gives null. +const SEGMENT_STARTS = [0, 2, 4, 7, 9]; + +function suggestedPostcode(typed: string, code: string, segment: Precision): Postcode | null { + const start = SEGMENT_STARTS[SEGMENTS.indexOf(segment)]; + const parsed = parse(typed.slice(0, start) + code, { allowPartial: true }); + return parsed.ok && parsed.value.precision === segment ? parsed.value : null; +} + +/** + * Reads the data of an autocomplete response. + * + * @param data - The `data` field of the response. + * @param typed - The normalised text that the request sent. + * @returns The autocomplete result. + * @throws PostcodeError `unexpected_response` when `segment` is not a segment name, + * `suggestions` is not a list, or an item of the list has no text in `code`. + * @internal + */ +export function readAutocomplete(data: unknown, typed: string): AutocompleteResult { + const segment = isFields(data) ? oneOf(SEGMENTS, data['segment']) : null; + const items = isFields(data) ? data['suggestions'] : undefined; + if (segment === null || !Array.isArray(items)) { + return unexpected('segment or suggestions is wrong'); + } + const suggestions = items.map((item: unknown) => { + const code = isFields(item) ? textOf(item, 'code') : null; + if (!isFields(item) || code === null) { + return unexpected('a code is not text'); + } + const postcode = suggestedPostcode(typed, code, segment); + return { code, label: textOf(item, 'label'), postcode }; + }); + return { segment, suggestions }; +} diff --git a/packages/client/src/retry.ts b/packages/client/src/retry.ts new file mode 100644 index 0000000..069e940 --- /dev/null +++ b/packages/client/src/retry.ts @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { PostcodeError } from './error.js'; + +/** @internal */ +export const FIRST_WAIT_MS = 500; +/** @internal */ +export const JITTER_MS = 250; +// A longer Retry-After ends the call at once, so a caller can tell a user when to try again. +const LONGEST_RETRY_AFTER_MS = 10_000; +// The statuses that can carry a wait. A 429 is `rate_limited`, and the others are retried. +/** @internal */ +export const WAIT_STATUSES: readonly number[] = [429, 502, 503, 504]; +const SECONDS = /^\d+$/; +// The three HTTP-date forms of RFC 9110: IMF-fixdate, RFC 850 and asctime. Date.parse also reads +// text that is no HTTP date, such as 2999-01-01, so each form needs its own pattern. The asctime +// form has no zone and means UTC, so Date.parse gets the zone GMT for all three. +const HTTP_DATE = + /^(?:\w{3}, \d\d [A-Z][a-z]{2} \d{4}|\w+day, \d\d-[A-Z][a-z]{2}-\d\d) [\d:]{8} GMT$|^\w{3} [A-Z][a-z]{2} [ \d]\d [\d:]{8} \d{4}$/; // check-tells: allow TELL-1 because one pattern cannot wrap + +/** + * Reads a `Retry-After` header, which holds seconds or an HTTP date. + * + * @param header - The value of the header, or null when the response has none. + * @param now - The current time in milliseconds since the epoch. + * @returns The wait in milliseconds, or null for a missing or invalid value. A date in the past + * is invalid. + * @internal + */ +export function retryAfterMs(header: string | null, now: number): number | null { + if (header === null) { + return null; + } + if (SECONDS.test(header)) { + return Number(header) * 1000; + } + if (!HTTP_DATE.test(header)) { + return null; + } + const date = Date.parse(`${header.replace(' GMT', '')} GMT`); + return date >= now ? date - now : null; +} + +// The wait that Retry-After asked for, when the client accepts it. +function honouredMs(error: PostcodeError): number | null { + const asked = error.retryAfterMs; + return asked !== null && asked <= LONGEST_RETRY_AFTER_MS ? asked : null; +} + +/** + * Decides whether a failed attempt gets a retry, and how long the client waits first. + * + * @param error - The error of the attempt. + * @param retry - The number of the next retry. The first retry is 1. + * @param timeoutRetried - False for `autocomplete`, whose timeout gets no retry. + * @returns The wait in milliseconds, or null when the call ends with the error. + * @internal + */ +export function retryWaitMs( + error: PostcodeError, + retry: number, + timeoutRetried: boolean, +): number | null { + const ownWaitMs = FIRST_WAIT_MS * 2 ** (retry - 1) + Math.random() * JITTER_MS; + if (error.code === 'rate_limited') { + return honouredMs(error); + } + if (error.code === 'server_error') { + return WAIT_STATUSES.includes(error.status ?? 0) ? (honouredMs(error) ?? ownWaitMs) : null; + } + const retried = error.code === 'network_error' || (error.code === 'timeout' && timeoutRetried); + return retried ? ownWaitMs : null; +} diff --git a/packages/client/src/send.ts b/packages/client/src/send.ts new file mode 100644 index 0000000..43fe2a9 --- /dev/null +++ b/packages/client/src/send.ts @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { clientError, errorForResponse, PostcodeError } from './error.js'; +import type { Queue } from './queue.js'; +import type { GatewayRequest } from './requests.js'; +import { apiCodeOf, dataOf } from './responses.js'; +import { FIRST_WAIT_MS, JITTER_MS, retryAfterMs, retryWaitMs, WAIT_STATUSES } from './retry.js'; + +/** + * What every request of one client shares. + * + * @internal + */ +export interface SendSettings { + readonly baseUrl: string; + readonly apiKey: string | undefined; + readonly transport: typeof globalThis.fetch; + readonly timeoutMs: number; + readonly maxRetries: number; + readonly queue: Queue; +} + +// The base URL can hold a path, such as the address of a staging gateway, so the client adds +// the request's path to it. +function urlOf(baseUrl: string, request: GatewayRequest): string { + const url = new URL(baseUrl.replace(/\/+$/, '') + request.path); + for (const [name, text] of Object.entries(request.query)) { + url.searchParams.set(name, text); + } + return url.href; +} + +function readJson(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + // A body that is not JSON, such as the HTML page of a captive portal, has no fields. + return undefined; + } +} + +// The timer and the caller's signal share one controller, so either one stops the request. +// Safari 16.4 lacks AbortSignal.any, so the code joins the two signals itself. +async function attempt(settings: SendSettings, url: string, signal: AbortSignal): Promise { + const controller = new AbortController(); + const stop = (): void => { + controller.abort(signal.reason); + }; + signal.addEventListener('abort', stop, { once: true }); + const timer = setTimeout(() => { + controller.abort(clientError('timeout', `It waited ${String(settings.timeoutMs)} ms.`)); + }, settings.timeoutMs); + try { + const headers: Record = { Accept: 'application/json' }; + if (settings.apiKey !== undefined) { + headers['X-API-Key'] = settings.apiKey; + } + const response = await settings.transport(url, { headers, signal: controller.signal }); + const body = readJson(await response.text()); + if (response.status === 200) { + return dataOf(body); + } + const header = response.headers.get('Retry-After'); + throw errorForResponse({ + status: response.status, + apiCode: apiCodeOf(body), + retryAfterMs: WAIT_STATUSES.includes(response.status) + ? retryAfterMs(header, Date.now()) + : null, + }); + } catch (error: unknown) { + throw failureOf(error, controller.signal); + } finally { + clearTimeout(timer); + signal.removeEventListener('abort', stop); + } +} + +// The caller's abort and the timeout end the request with the reason that the controller holds. +// Any other failure of the transport means that no response arrived. +function failureOf(error: unknown, signal: AbortSignal): unknown { + if (error instanceof PostcodeError) { + return error; + } + if (signal.aborted) { + return signal.reason; + } + return clientError('network_error', '', { cause: error }); +} + +// A timer can fire up to a millisecond early on a finer clock. The client never waits less than +// a Retry-After asks, so it sets a new timer for any time that is left. +function pause(waitMs: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const until = performance.now() + waitMs; + return new Promise((resolve, reject) => { + let timer: ReturnType; + const stop = (): void => { + clearTimeout(timer); + // API-11: a cancelled call ends with the reason of the caller's signal, of any type. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- API-11 + reject(signal.reason); + }; + const tick = (): void => { + const leftMs = until - performance.now(); + if (leftMs > 0) { + timer = setTimeout(tick, leftMs); + return; + } + signal.removeEventListener('abort', stop); + resolve(); + }; + signal.addEventListener('abort', stop, { once: true }); + tick(); + }); +} + +// The longest a call lasts from its first attempt: every attempt may use its full timeout, and +// every retry may wait its longest own wait. +function deadlineMs(settings: SendSettings): number { + const { maxRetries, timeoutMs } = settings; + // The waits 500 x 2^(n-1) + 250 for n = 1 to maxRetries add up to this, with no loop. + const waitsMs = FIRST_WAIT_MS * (2 ** maxRetries - 1) + JITTER_MS * maxRetries; + return (1 + maxRetries) * timeoutMs + waitsMs; +} + +/** + * Sends a request, with the retries and the waits of `spec/client.md`. Each attempt takes a + * place in the queue, and gives it back before the wait. + * + * @param settings - The settings of the client. + * @param request - The path and the query. + * @param options - The caller's signal, and whether a timeout gets a retry. + * @returns The `data` field of the response. + * @throws PostcodeError for a failure that the retries did not cure, or the reason of the + * caller's signal when it aborts. + * @internal + */ +export async function send( + settings: SendSettings, + request: GatewayRequest, + options: Readonly<{ signal: AbortSignal; timeoutRetried: boolean }>, +): Promise { + const url = urlOf(settings.baseUrl, request); + let deadline = Number.POSITIVE_INFINITY; + const run = (): Promise => { + // The deadline starts when the first attempt leaves the queue. + if (deadline === Number.POSITIVE_INFINITY) { + deadline = performance.now() + deadlineMs(settings); + } + return attempt(settings, url, options.signal); + }; + for (let retry = 1; ; retry += 1) { + try { + return await settings.queue(run, options.signal); + } catch (error: unknown) { + const waitMs = + error instanceof PostcodeError ? retryWaitMs(error, retry, options.timeoutRetried) : null; + if (waitMs === null || retry > settings.maxRetries || performance.now() + waitMs > deadline) { + throw error; + } + await pause(waitMs, options.signal); + } + } +} diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts new file mode 100644 index 0000000..1268408 --- /dev/null +++ b/packages/client/src/types.ts @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { Postcode, Precision } from '@gatepost/core'; + +/** + * Settings for a `PostcodeClient`. Each one is optional. + */ +export interface ClientOptions { + /** The key for the `X-API-Key` header. With no key, the client sends no header. */ + readonly apiKey?: string | undefined; + /** The gateway's address. The default is `https://api.postcode.gov.ng`. */ + readonly baseUrl?: string; + /** The function that sends each request. The default is the platform's `fetch`. */ + readonly transport?: typeof globalThis.fetch; + /** + * The longest wait for one attempt, in milliseconds. The default is 8000, and 15000 for + * `autocomplete`. + */ + readonly timeoutMs?: number; + /** The most retries after the first attempt. The default is 2. */ + readonly maxRetries?: number; + /** How long the client keeps a result, in milliseconds. The default 0 turns the cache off. */ + readonly cacheTtlMs?: number; +} + +/** + * How much data a lookup asks for, from 1 for validity only to 5 for point geometry. + */ +export type LookupLevel = 1 | 2 | 3 | 4 | 5; + +/** + * The verdicts that the gateway has sent so far. `invalid` means a wrong form, and `not_found` + * means a right form that the gateway does not know. A new gateway can send another one, so + * `LookupResult.status` is text. + */ +export type LookupStatus = 'valid' | 'invalid' | 'not_found' | 'restricted'; + +/** + * The names of the places that hold a postcode. The gateway sends them from lookup level 2. + * A name that the response leaves out is null. + */ +export interface AdministrativeAddress { + /** The state's name. */ + readonly stateName: string | null; + /** The LGA's name. */ + readonly lgaName: string | null; + /** The locality's name. */ + readonly localityName: string | null; + /** The geopolitical zone. */ + readonly zone: string | null; +} + +/** + * The facts that a lookup returned for one postcode. + */ +export interface LookupResult { + /** The postcode that the caller asked for, as the core parsed it. */ + readonly postcode: Postcode; + /** True when the gateway knows the postcode. */ + readonly valid: boolean; + /** The gateway's verdict as text, such as a `LookupStatus`, or null when it sent none. */ + readonly status: string | null; + /** The lookup level that the caller asked for. */ + readonly levelRequested: LookupLevel; + /** The lookup level of the data in the response, which the client reads from its fields. */ + readonly levelReceived: LookupLevel; + /** The names of the places that hold the postcode, from level 2, or null. */ + readonly administrativeAddress: AdministrativeAddress | null; + /** The most recent house address at the postcode, from level 2, or null. */ + readonly recentHouseAddress: string | null; + /** What the building is used for, from level 3, or null. */ + readonly buildingUseStatus: string | null; +} + +/** + * How sure the gateway is that a unit holds a coordinate. It falls with the distance. + */ +export type Confidence = 'high' | 'medium' | 'low'; + +/** + * The unit nearest to a coordinate. + */ +export interface ReverseUnit { + /** The unit's postcode. */ + readonly postcode: Postcode; + /** The distance from the coordinate, in metres. */ + readonly distanceM: number; + /** How sure the gateway is. An unknown value becomes `low`. */ + readonly confidence: Confidence; + /** The state's name, or null. */ + readonly stateName: string | null; + /** The LGA's name, or null. */ + readonly lgaName: string | null; + /** The locality's name, or null. */ + readonly localityName: string | null; + /** The address of the unit, or null. */ + readonly address: string | null; +} + +/** + * What the gateway found near a coordinate. `found` can be true while `unit` is null: the + * gateway then found an area, but no unit within the radius. + */ +export interface ReverseResult { + /** True when the gateway found a unit or an area within the radius. */ + readonly found: boolean; + /** The radius that the gateway applied, in metres, or null when the response has none. */ + readonly radiusM: number | null; + /** The nearest unit, or null. */ + readonly unit: ReverseUnit | null; + /** The area as the gateway sent it, or null. The client does not parse it. */ + readonly area: string | null; + /** The district as the gateway sent it, or null. The client does not parse it. */ + readonly district: string | null; + /** The state code, or null. */ + readonly state: string | null; +} + +/** + * The values that the gateway offers for the segment that a user is typing. + */ +export interface AutocompleteResult { + /** The segment that holds the last character that the user typed. */ + readonly segment: Precision; + /** The values, in the gateway's order. */ + readonly suggestions: readonly { + /** The value of the active segment, as the gateway sent it, such as `Z99`. */ + readonly code: string; + /** A label for the value, or null. The gateway has sent none so far. */ + readonly label: string | null; + /** The typed segments before the active one, followed by `code`, or null. */ + readonly postcode: Postcode | null; + }[]; +} + +/** + * Why a call failed. `spec/client.md` maps each HTTP status to one code. + */ +export type PostcodeErrorCode = + | 'invalid_input' + | 'unauthorized' + | 'insufficient_credits' + | 'origin_not_allowed' + | 'forbidden' + | 'rate_limited' + | 'server_error' + | 'unexpected_response' + | 'network_error' + | 'timeout'; diff --git a/packages/client/src/version.ts b/packages/client/src/version.ts new file mode 100644 index 0000000..6051b64 --- /dev/null +++ b/packages/client/src/version.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The version of the Gatepost spec that this package implements. + * + * @example + * ```ts + * import { SPEC_VERSION } from '@gatepost/client'; + * + * SPEC_VERSION; // '0.2.0' + * ``` + */ +export const SPEC_VERSION = '0.2.0'; diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts new file mode 100644 index 0000000..cdac966 --- /dev/null +++ b/packages/client/test/client.test.ts @@ -0,0 +1,320 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The rules of the client that no contract scenario tests: the defaults, the option errors, +// shared calls that callers leave, and the cache clock (T-5). +import { describe, expect, it, vi } from 'vitest'; +import { PostcodeClient, PostcodeError, type ClientOptions } from '../src/index.js'; +import { reasonOf, settle, useFakeClock } from './fake-clock.js'; +import { fakeTransport, VALID_LOOKUP, type Answer } from './transport.js'; + +const KEY = 'nipost_test_mock_l3'; + +useFakeClock(); + +function clientWith(options: ClientOptions, ...answers: readonly Answer[]) { + const fake = fakeTransport(...answers); + const client = new PostcodeClient({ apiKey: KEY, ...options, transport: fake.transport }); + return { client, requests: fake.requests }; +} + +describe('the defaults of API-8', () => { + it('calls https://api.postcode.gov.ng, and sends no key when it has none', async () => { + const fake = fakeTransport(VALID_LOOKUP); + await new PostcodeClient({ transport: fake.transport }).lookup('FC-01-Z99-ZZ-01'); + expect(fake.requests[0]?.url.href).toBe( + 'https://api.postcode.gov.ng/v1/lookup?code=FC-01-Z99-ZZ-01&level=1', + ); + expect(fake.requests[0]?.headers.has('X-API-Key')).toBe(false); + }); + + it('ends an attempt after 8000 ms, and makes two retries', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const { client, requests } = clientWith({}, 'hang'); + const call = client.lookup('FC-01-Z99-ZZ-01'); + await vi.advanceTimersByTimeAsync(8000 + 500 + 8000 + 1000 + 7999); + expect(requests).toHaveLength(3); + const error = reasonOf(await settle(call, 1)) as PostcodeError; + expect([error.code, error.status]).toEqual(['timeout', null]); + }); + + it('sends at most 4 requests at a time', async () => { + const { client, requests } = clientWith({ maxRetries: 0 }, 'hang'); + const calls = ['01', '02', '03', '04', '05'].map((unit) => + client.lookup(`FC-01-Z99-ZZ-${unit}`), + ); + await vi.advanceTimersByTimeAsync(7999); + expect(requests).toHaveLength(4); + await Promise.allSettled([...calls, vi.advanceTimersByTimeAsync(8001)]); + expect(requests).toHaveLength(5); + }); + + it('answers a call at once when maxRetries is very large', async () => { + const { client } = clientWith({ maxRetries: Number.MAX_SAFE_INTEGER }, VALID_LOOKUP); + expect((await client.lookup('FC-01-Z99-ZZ-01')).valid).toBe(true); + }); + + it('keeps no result when cacheTtlMs is not set', async () => { + const { client, requests } = clientWith({}, VALID_LOOKUP); + await client.lookup('FC-01-Z99-ZZ-01'); + await client.lookup('FC-01-Z99-ZZ-01'); + expect(requests).toHaveLength(2); + }); +}); + +describe('the timeout of autocomplete', () => { + it('ends an attempt after 15000 ms by default, and a lookup after 8000 ms', async () => { + const { client, requests } = clientWith({ maxRetries: 0 }, 'hang'); + const lookup = client.lookup('FC-01-Z99-ZZ-01'); + const autocomplete = client.autocomplete('FC0'); + const settled = Promise.allSettled([lookup, autocomplete]); + await vi.advanceTimersByTimeAsync(7999); + expect(requests.map((request) => request.signal.aborted)).toEqual([false, false]); + await vi.advanceTimersByTimeAsync(1); + expect(requests.map((request) => request.signal.aborted)).toEqual([true, false]); + await vi.advanceTimersByTimeAsync(6999); + expect(requests[1]?.signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(requests[1]?.signal.aborted).toBe(true); + await settled; + }); + + it('uses timeoutMs for autocomplete when the option is set', async () => { + const { client, requests } = clientWith({ maxRetries: 0, timeoutMs: 1000 }, 'hang'); + const settled = Promise.allSettled([client.autocomplete('FC0')]); + await vi.advanceTimersByTimeAsync(1000); + expect(requests[0]?.signal.aborted).toBe(true); + await settled; + }); +}); + +describe('the options', () => { + it.each([ + [{ timeoutMs: 0 }, 'timeoutMs must be a number above 0. It is 0.'], + [{ timeoutMs: Number.NaN }, 'timeoutMs must be a number above 0. It is NaN.'], + [{ maxRetries: -1 }, 'maxRetries must be a whole number of 0 or more. It is -1.'], + [{ maxRetries: 1.5 }, 'maxRetries must be a whole number of 0 or more. It is 1.5.'], + [{ cacheTtlMs: -1 }, 'cacheTtlMs must be a number of 0 or more. It is -1.'], + [{ cacheTtlMs: Number.POSITIVE_INFINITY }, 'cacheTtlMs must be a number of 0 or more.'], + ] as const)('refuses %j as a programmer error', (options, message) => { + expect(() => new PostcodeClient(options)).toThrow(RangeError); + expect(() => new PostcodeClient(options)).toThrow(message); + }); + + it('refuses an empty key as a programmer error, because an empty header is no key', () => { + expect(() => new PostcodeClient({ apiKey: '' })).toThrow(RangeError); + expect(() => new PostcodeClient({ apiKey: '' })).toThrow('apiKey must not be empty.'); + }); + + it('refuses a secret key in a web page, and allows a publishable key there', () => { + vi.stubGlobal('document', {}); + expect(() => new PostcodeClient({ apiKey: 'nipost_live_abc' })).toThrow(TypeError); + expect(() => new PostcodeClient({ apiKey: 'nipost_test_abc' })).toThrow( + 'A web page must not hold a secret key. Use a publishable key here.', + ); + expect(() => new PostcodeClient({ apiKey: 'nipost_pk_live_abc' })).not.toThrow(); + }); + + it('allows a secret key on a server, where no document exists', () => { + expect(() => new PostcodeClient({ apiKey: 'nipost_live_abc' })).not.toThrow(); + }); + + it('gives two clients their own keys and their own caches (API-9)', async () => { + const first = clientWith({ apiKey: 'nipost_test_mock_l1', cacheTtlMs: 60_000 }, VALID_LOOKUP); + const second = clientWith({ apiKey: 'nipost_test_mock_l2', cacheTtlMs: 60_000 }, VALID_LOOKUP); + await first.client.lookup('FC-01-Z99-ZZ-01'); + await second.client.lookup('FC-01-Z99-ZZ-01'); + expect(first.requests[0]?.headers.get('X-API-Key')).toBe('nipost_test_mock_l1'); + expect(second.requests[0]?.headers.get('X-API-Key')).toBe('nipost_test_mock_l2'); + expect(second.requests).toHaveLength(1); + }); +}); + +describe('cancellation (API-11)', () => { + it('sends no request when the signal has aborted already', async () => { + const { client, requests } = clientWith({}, VALID_LOOKUP); + const reason = new DOMException('The user left.', 'AbortError'); + const call = client.lookup('FC-01-Z99-ZZ-01', { signal: AbortSignal.abort(reason) }); + await expect(call).rejects.toBe(reason); + expect(requests).toHaveLength(0); + }); +}); + +describe('shared calls', () => { + it('gives the result to a caller that stays when another caller leaves', async () => { + const { client, requests } = clientWith({}, VALID_LOOKUP); + const controller = new AbortController(); + const leaving = client.lookup('FC-01-Z99-ZZ-01', { signal: controller.signal }); + const staying = client.lookup('fc 01 z99 zz 01'); + controller.abort(); + await expect(leaving).rejects.toBe(controller.signal.reason); + expect((await staying).valid).toBe(true); + expect(requests).toHaveLength(1); + }); + + it('stops the request when every caller has left', async () => { + const { client, requests } = clientWith({}, 'hang'); + const controllers = [new AbortController(), new AbortController()]; + const calls = controllers.map((controller) => + client.autocomplete('FC0', { signal: controller.signal }), + ); + await vi.advanceTimersByTimeAsync(10); + for (const controller of controllers) { + controller.abort(); + } + await Promise.allSettled(calls); + expect(requests).toHaveLength(1); + expect(requests[0]?.signal.aborted).toBe(true); + }); + + it('starts a new request for a call that comes after every caller has left', async () => { + const { client, requests } = clientWith({}, 'hang', VALID_LOOKUP); + const controller = new AbortController(); + const first = client.lookup('FC-01-Z99-ZZ-01', { signal: controller.signal }); + await vi.advanceTimersByTimeAsync(10); + controller.abort(); + await Promise.allSettled([first]); + expect((await client.lookup('FC-01-Z99-ZZ-01')).valid).toBe(true); + expect(requests).toHaveLength(2); + }); +}); + +// A transport that answers after a delay, and obeys its signal, as fetch does. +function slowTransport(delayMs: number) { + const requests: URL[] = []; + const transport: typeof fetch = (input, init) => { + requests.push(new URL(input instanceof Request ? input.url : input)); + const signal = init?.signal; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve(new Response(JSON.stringify(VALID_LOOKUP_BODY), { status: 200 })); + }, delayMs); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(signal.reason as Error); + }); + }); + }; + return { transport, requests }; +} + +const VALID_LOOKUP_BODY = { data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid' } }; + +describe('shared calls that obey the signal', () => { + it('keeps the request for a caller that stays when the other caller leaves', async () => { + const { transport, requests } = slowTransport(50); + const client = new PostcodeClient({ transport }); + const controller = new AbortController(); + const leaving = client.lookup('FC-01-Z99-ZZ-01', { signal: controller.signal }); + const staying = client.lookup('FC-01-Z99-ZZ-01'); + await vi.advanceTimersByTimeAsync(10); + controller.abort(); + await expect(leaving).rejects.toBe(controller.signal.reason); + await vi.advanceTimersByTimeAsync(100); + expect((await staying).valid).toBe(true); + expect(requests).toHaveLength(1); + }); + + it('does not keep a result that a clearCache call overtook', async () => { + const { transport, requests } = slowTransport(50); + const client = new PostcodeClient({ transport, cacheTtlMs: 60_000 }); + const first = client.lookup('FC-01-Z99-ZZ-01'); + await vi.advanceTimersByTimeAsync(10); + client.clearCache(); + await vi.advanceTimersByTimeAsync(100); + await first; + const second = client.lookup('FC-01-Z99-ZZ-01'); + await vi.advanceTimersByTimeAsync(100); + await second; + expect(requests).toHaveLength(2); + }); +}); + +describe('the lookup level', () => { + it('shares and keeps a result for each level', async () => { + const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, VALID_LOOKUP); + const [one, two] = await Promise.all([ + client.lookup('FC-01-Z99-ZZ-01', { level: 1 }), + client.lookup('FC-01-Z99-ZZ-01', { level: 2 }), + ]); + expect([one.levelRequested, two.levelRequested]).toEqual([1, 2]); + expect(requests).toHaveLength(2); + expect((await client.lookup('FC-01-Z99-ZZ-01', { level: 2 })).levelRequested).toBe(2); + expect((await client.lookup('FC-01-Z99-ZZ-01', { level: 1 })).levelRequested).toBe(1); + expect(requests).toHaveLength(2); + }); +}); + +describe('the cache', () => { + it('holds a bounded number of results', async () => { + const reverse = { status: 200, body: { data: { found: false, radius_m: 25 } } }; + const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, reverse); + for (let lat = 0; lat <= 1100; lat += 1) { + await client.reverse(lat / 100, 0); + } + await client.reverse(0, 0); + expect(requests).toHaveLength(1102); + }); + + it('keeps a result for cacheTtlMs, and no longer', async () => { + const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, VALID_LOOKUP); + await client.lookup('FC-01-Z99-ZZ-01'); + await vi.advanceTimersByTimeAsync(59_999); + await client.lookup('FC-01-Z99-ZZ-01'); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await client.lookup('FC-01-Z99-ZZ-01'); + expect(requests).toHaveLength(2); + }); + + it('forgets every result on clearCache', async () => { + const reverse = { status: 200, body: { data: { found: false, radius_m: 25 } } }; + const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, reverse); + await client.reverse(0, 0); + client.clearCache(); + await client.reverse(0, 0); + expect(requests).toHaveLength(2); + }); + + it('keeps one result for each set of arguments', async () => { + const reverse = { status: 200, body: { data: { found: false, radius_m: 25 } } }; + const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, reverse); + await client.reverse(0, 0); + await client.reverse(0, 0, { maxDistanceM: 250 }); + await client.reverse(0, 0); + expect(requests).toHaveLength(2); + }); +}); + +describe('the share and cache key of each method', () => { + const reverse: Answer = { status: 200, body: { data: { found: false, radius_m: 25 } } }; + const autocomplete: Answer = { + status: 200, + body: { data: { segment: 'state', suggestions: [] } }, + }; + + it('keeps reverse calls that differ only in lng apart', async () => { + const shared = clientWith({}, reverse); + await Promise.all([shared.client.reverse(9, 7), shared.client.reverse(9, 8)]); + expect(shared.requests).toHaveLength(2); + const cached = clientWith({ cacheTtlMs: 60_000 }, reverse); + await cached.client.reverse(9, 7); + await cached.client.reverse(9, 8); + expect(cached.requests).toHaveLength(2); + }); + + it('keeps reverse calls that differ only in lat apart', async () => { + const { client, requests } = clientWith({}, reverse); + await Promise.all([client.reverse(9, 7), client.reverse(8, 7)]); + expect(requests).toHaveLength(2); + }); + + it('uses the normalised text as the key of autocomplete', async () => { + const shared = clientWith({}, autocomplete); + await Promise.all([shared.client.autocomplete('fc01'), shared.client.autocomplete('FC 01')]); + expect(shared.requests).toHaveLength(1); + const cached = clientWith({ cacheTtlMs: 60_000 }, autocomplete); + await cached.client.autocomplete('fc01'); + await cached.client.autocomplete('FC 01'); + expect(cached.requests).toHaveLength(1); + }); +}); diff --git a/packages/client/test/contract.test.ts b/packages/client/test/contract.test.ts new file mode 100644 index 0000000..132d9ed --- /dev/null +++ b/packages/client/test/contract.test.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// T-3: the client passes every contract scenario of the spec against the mock server. The +// scenarios measure real waits, so these tests use the real clock. They run at the same time, +// and each run of a scenario has its own place in the mock server. +import { startMockServer, type MockServer } from '@gatepost/mock-server'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { readScenarios, runScenario } from './scenarios.js'; + +// Timers can fire late, so a wait may pass its maximum by this much. It must not fall short. +const LATE_MS = 100; +// A scenario gets 5 s and the longest waits that it expects, so a client that hangs fails soon. +const BASE_LIMIT_MS = 5000; + +let mock: MockServer; + +beforeAll(async () => { + mock = await startMockServer({ port: 0 }); +}); + +afterAll(async () => { + await mock.close(); +}); + +const scenarios = readScenarios(); + +describe.concurrent('the contract scenarios', () => { + it('finds the scenarios of the spec', () => { + expect(scenarios.length).toBeGreaterThan(0); + }); + + for (const scenario of scenarios) { + const waitsMs = (scenario.expect.waitsMs ?? []).reduce((sum, bounds) => sum + bounds.max, 0); + it( + `${scenario.id}: ${scenario.description}`, + async () => { + const run = await runScenario(scenario, mock.url); + const { expect: wanted } = scenario; + expect(run.outcomes).toEqual(wanted.outcomes); + expect(run.attempts).toBe(wanted.attempts); + if (wanted.request !== undefined) { + expect(run.request).toEqual(wanted.request); + } + if (wanted.maxInFlight !== undefined) { + expect(run.maxInFlight).toBe(wanted.maxInFlight); + } + for (const [index, bounds] of (wanted.waitsMs ?? []).entries()) { + const waitMs = run.waitsMs[index] ?? Number.NaN; + expect(waitMs).toBeGreaterThanOrEqual(bounds.min); + expect(waitMs).toBeLessThanOrEqual(bounds.max + LATE_MS); + } + }, + BASE_LIMIT_MS + waitsMs, + ); + } +}); diff --git a/packages/client/test/error.test.ts b/packages/client/test/error.test.ts new file mode 100644 index 0000000..ff7b35f --- /dev/null +++ b/packages/client/test/error.test.ts @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { clientError, errorForResponse } from '../src/error.js'; +import { PostcodeError } from '../src/index.js'; + +describe('the status map of spec/client.md', () => { + it.each([ + [401, 'auth_required', 'unauthorized'], + [402, 'insufficient_credits', 'insufficient_credits'], + [403, 'origin_not_allowed', 'origin_not_allowed'], + [403, 'level_not_granted', 'forbidden'], + [403, null, 'forbidden'], + [429, 'rate_limited', 'rate_limited'], + [400, 'invalid_request', 'invalid_input'], + [404, 'not_found', 'invalid_input'], + [422, null, 'invalid_input'], + [499, null, 'invalid_input'], + [500, 'internal', 'server_error'], + [502, null, 'server_error'], + [503, null, 'server_error'], + [504, null, 'server_error'], + [204, null, 'server_error'], + [302, null, 'server_error'], + [100, null, 'server_error'], + [599, null, 'server_error'], + [401, 'unlisted_code', 'unauthorized'], + ] as const)('maps %i with the API code %s to %s', (status, apiCode, code) => { + const error = errorForResponse({ status, apiCode, retryAfterMs: null }); + expect([error.code, error.status, error.apiCode]).toEqual([code, status, apiCode]); + }); + + it('names the status in the message, after the advice', () => { + const error = errorForResponse({ + status: 403, + apiCode: 'level_not_granted', + retryAfterMs: null, + }); + expect(error.message).toBe( + "The gateway refused the call. Check the key's scope and lookup level." + ' (status 403)', + ); + }); + + it('keeps the API code in a field and out of the message', () => { + const error = errorForResponse({ status: 403, apiCode: 'odd_code', retryAfterMs: null }); + expect([error.apiCode, error.message.includes('odd_code')]).toEqual(['odd_code', false]); + }); + + it('keeps the wait that Retry-After asked for', () => { + const error = errorForResponse({ status: 429, apiCode: null, retryAfterMs: 120_000 }); + expect(error.retryAfterMs).toBe(120_000); + expect(error.message).toContain('(status 429)'); + }); + + it('keeps the wait of a 502', () => { + const error = errorForResponse({ status: 502, apiCode: null, retryAfterMs: 3000 }); + expect([error.code, error.retryAfterMs]).toEqual(['server_error', 3000]); + }); +}); + +describe('PostcodeError', () => { + it('is an Error with its own name, and keeps its cause', () => { + const cause = new TypeError('fetch failed'); + const error = new PostcodeError('network_error', 'No response.', { cause }); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('PostcodeError'); + expect(error.cause).toBe(cause); + expect([error.status, error.apiCode, error.retryAfterMs]).toEqual([null, null, null]); + }); +}); + +describe('clientError', () => { + it('adds the advice for a failure with no response, and keeps its cause', () => { + const cause = new TypeError('fetch failed'); + const error = clientError('network_error', '', { cause }); + expect(error.message).toBe( + 'The request did not reach the gateway. Check the network and the baseUrl.', + ); + expect([error.status, error.cause]).toEqual([null, cause]); + }); + + it('gives only the detail for input that fails the check of the client', () => { + expect(clientError('invalid_input', 'The text is empty.').message).toBe('The text is empty.'); + }); + + it('adds the detail after the advice for a reply that the client cannot read', () => { + const error = clientError('unexpected_response', 'The body has no data field.'); + expect(error.code).toBe('unexpected_response'); + expect(error.message).toBe( + 'The gateway sent a reply that the client cannot read. Try again, or update it. ' + + 'The body has no data field.', + ); + }); +}); diff --git a/packages/client/test/example-client.ts b/packages/client/test/example-client.ts new file mode 100644 index 0000000..785e3a2 --- /dev/null +++ b/packages/client/test/example-client.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The examples in the README and in the doc comments show calls to NIPOST's gateway. Their +// tests import this module in place of the package, so each call goes to a mock server. +import process from 'node:process'; +import { PostcodeClient as GatewayClient, type ClientOptions } from '../src/index.js'; + +export { PostcodeError, SPEC_VERSION } from '../src/index.js'; + +/** A client that calls the mock server that `GATEPOST_MOCK_URL` names, with a mock key. */ +export class PostcodeClient extends GatewayClient { + constructor(options: ClientOptions = {}) { + const baseUrl = process.env['GATEPOST_MOCK_URL']; + // With no mock server, a call would go to NIPOST. A test must never call it. + if (baseUrl === undefined) { + throw new Error('Start the mock server with useMockServer() before the examples run.'); + } + super({ ...options, baseUrl, apiKey: 'nipost_test_mock_l3' }); + } +} diff --git a/packages/client/test/examples.ts b/packages/client/test/examples.ts new file mode 100644 index 0000000..21e8bde --- /dev/null +++ b/packages/client/test/examples.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { startMockServer, type MockServer } from '@gatepost/mock-server'; +import { afterAll, beforeAll } from 'vitest'; +import type { Sources } from '../../core/test/examples.js'; + +/** The modules that the examples of this package import: a client of the mock, and the core. */ +export const CLIENT_SOURCES: Sources = { + '@gatepost/client': fileURLToPath(new URL('example-client.ts', import.meta.url)), + '@gatepost/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), +}; + +/** + * Starts a mock server for the examples of one test file, and stops it after them. + */ +export function useMockServer(): void { + let mock: MockServer; + beforeAll(async () => { + mock = await startMockServer({ port: 0 }); + process.env['GATEPOST_MOCK_URL'] = mock.url; + }); + afterAll(async () => { + await mock.close(); + }); +} diff --git a/packages/client/test/fake-clock.ts b/packages/client/test/fake-clock.ts new file mode 100644 index 0000000..5ba1980 --- /dev/null +++ b/packages/client/test/fake-clock.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The fake clock of the unit tests. The client measures its waits with performance.now, so the +// fake clock covers it as well as the timers and Date. +import { afterEach, beforeEach, vi } from 'vitest'; + +/** The time at the start of each test: 09:00:00 GMT on 14 Oct 2026. */ +export const START: number = Date.parse('2026-10-14T09:00:00Z'); + +/** + * Uses the fake clock in each test of the file that calls it. + */ +export function useFakeClock(): void { + beforeEach(() => { + vi.useFakeTimers({ now: START, toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }); + }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); +} + +/** + * Runs the fake clock while a call settles, so that a rejection is never left unhandled. + * + * @param call - The call. + * @param runMs - How far the clock runs, in milliseconds. + * @returns How the call settled. + */ +export async function settle(call: Promise, runMs: number): Promise> { + const outcome = Promise.allSettled([call]); + await vi.advanceTimersByTimeAsync(runMs); + const [settled] = await outcome; + return settled; +} + +/** + * Gives the reason of a call that failed. + * + * @param settled - How the call settled. + * @returns The reason. + */ +export function reasonOf(settled: PromiseSettledResult): unknown { + if (settled.status === 'fulfilled') { + throw new Error('The call did not fail.'); + } + return settled.reason; +} diff --git a/packages/client/test/key-secrecy.test.ts b/packages/client/test/key-secrecy.test.ts new file mode 100644 index 0000000..16dc278 --- /dev/null +++ b/packages/client/test/key-secrecy.test.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The README promises that no error message of the client holds the caller's API key. This +// file triggers every error code through the public client, with a key in the options, and +// reads each text that an error carries. A scripted transport and a fake clock stand in for +// the network (T-5). +import { describe, expect, it, vi } from 'vitest'; +import { PostcodeClient, type PostcodeErrorCode } from '../src/index.js'; +import { reasonOf, settle, useFakeClock } from './fake-clock.js'; +import { fakeTransport, type Answer } from './transport.js'; + +// The sentinel is built at run time, so that no line of this file looks like a real key. +const SECRET_PART = ['sentinel', '8f3a1c'].join('_'); +const KEY = ['nipost', 'live', SECRET_PART].join('_'); + +useFakeClock(); + +// Every text that an error holds: its name, message, code fields and stack, and the same for +// each error in its cause chain. A cause that is not an Error counts as its text. +function textsOf(error: unknown): string[] { + const texts: string[] = []; + let current: unknown = error; + while (current !== undefined) { + if (!(current instanceof Error)) { + texts.push(JSON.stringify(current)); + break; + } + const { name, message, stack } = current; + texts.push(name, message, stack ?? ''); + const { code, apiCode } = current as { code?: unknown; apiCode?: unknown }; + texts.push(String(code), String(apiCode)); + current = current.cause; + } + return texts; +} + +function expectNoKey(error: unknown): void { + for (const text of textsOf(error)) { + expect(text).not.toContain(SECRET_PART); + } +} + +async function failureOf(call: (client: PostcodeClient) => Promise, answer: Answer) { + const { transport } = fakeTransport(answer); + const client = new PostcodeClient({ + apiKey: KEY, + baseUrl: 'https://gateway.invalid', + transport, + timeoutMs: 1000, + maxRetries: 0, + }); + return reasonOf(await settle(call(client), 2000)); +} + +const lookup = (client: PostcodeClient): Promise => client.lookup('FC-01-Z99-ZZ-01'); +const failed = (status: number, code?: string): Answer => ({ + status, + body: code === undefined ? {} : { error: { code, message: KEY } }, +}); + +describe('the API key in an error', () => { + const paths: readonly (readonly [PostcodeErrorCode, Answer])[] = [ + ['unauthorized', failed(401, 'auth_required')], + ['insufficient_credits', failed(402, 'insufficient_credits')], + ['origin_not_allowed', failed(403, 'origin_not_allowed')], + ['forbidden', failed(403, 'level_not_granted')], + ['rate_limited', { status: 429, body: {}, headers: { 'Retry-After': '60' } }], + ['server_error', failed(500, 'internal')], + ['unexpected_response', { status: 200, body: { data: { valid: 'yes' } } }], + ['network_error', 'drop'], + ['timeout', 'hang'], + ]; + + it('is not in the error of any code that a response or a missing response gives', async () => { + for (const [code, answer] of paths) { + const error = await failureOf(lookup, answer); + expect((error as { code: unknown }).code).toBe(code); + expectNoKey(error); + } + }); + + it('is not in the message when the gateway echoes the key as its error code', async () => { + for (const status of [400, 401, 402, 403, 404, 500, 503]) { + const error = (await failureOf(lookup, failed(status, KEY))) as Error; + expect(error.message).not.toContain(SECRET_PART); + expect((error as { apiCode?: string }).apiCode).toBe(KEY); + } + }); + + it('is not in the error for input that the client refuses', async () => { + const calls = [ + (client: PostcodeClient) => client.lookup('not a postcode'), + (client: PostcodeClient) => client.lookup('FC-01-Z99-ZZ-01', { level: 9 as never }), + (client: PostcodeClient) => client.reverse(Number.NaN, 0), + (client: PostcodeClient) => client.autocomplete(''), + ]; + for (const call of calls) { + const error = await failureOf(call, 'drop'); + expect((error as { code: unknown }).code).toBe('invalid_input'); + expectNoKey(error); + } + }); + + it('is not in the error of every call kind that reaches the gateway', async () => { + const calls = [ + (client: PostcodeClient) => client.reverse(9, 7), + (client: PostcodeClient) => client.autocomplete('fc'), + ]; + for (const call of calls) { + expectNoKey(await failureOf(call, failed(401, 'auth_required'))); + expectNoKey(await failureOf(call, 'drop')); + } + }); + + it('is not in the error that refuses a secret key in a web page', () => { + vi.stubGlobal('document', {}); + let thrown: unknown; + try { + new PostcodeClient({ apiKey: KEY }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(TypeError); + expectNoKey(thrown); + }); + + it('is not in the error of an option that breaks its rule', () => { + let thrown: unknown; + try { + new PostcodeClient({ apiKey: KEY, timeoutMs: 0 }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(RangeError); + expectNoKey(thrown); + }); +}); diff --git a/packages/client/test/live.test.ts b/packages/client/test/live.test.ts new file mode 100644 index 0000000..e83f07b --- /dev/null +++ b/packages/client/test/live.test.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// A few calls that hold on any gateway. By default they go to a mock server. The live workflow +// sets GATEPOST_LIVE_URL, GATEPOST_LIVE_KEY and GATEPOST_LIVE_CODE, a test code from NIPOST's +// docs, and so checks the staging gateway each night. No real response enters the repo. +import process from 'node:process'; +import { startMockServer, type MockServer } from '@gatepost/mock-server'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PostcodeClient } from '../src/index.js'; + +const LIVE_URL = process.env['GATEPOST_LIVE_URL'] ?? ''; +let mock: MockServer | undefined; +let client: PostcodeClient; +let code = 'FC-01-Z99-ZZ-01'; + +beforeAll(async () => { + if (LIVE_URL === '') { + mock = await startMockServer({ port: 0 }); + client = new PostcodeClient({ baseUrl: mock.url, apiKey: 'nipost_test_mock_l1' }); + return; + } + code = process.env['GATEPOST_LIVE_CODE'] ?? ''; + client = new PostcodeClient({ baseUrl: LIVE_URL, apiKey: process.env['GATEPOST_LIVE_KEY'] }); +}); + +afterAll(async () => { + await mock?.close(); +}); + +describe('a gateway', () => { + it('knows the test code at lookup level 1', async () => { + const result = await client.lookup(code); + // Each check is a boolean, so that a failure prints no value that the gateway sent. + expect(result.valid && result.status === 'valid' && result.levelReceived === 1).toBe(true); + }); + + it('offers the state of the test code for its first letter', async () => { + const result = await client.autocomplete(code.slice(0, 1)); + const states = result.suggestions.map((item) => item.postcode?.canonical); + expect(result.segment === 'state').toBe(true); + expect(states.includes(code.slice(0, 2))).toBe(true); + }); + + it('finds no postcode at sea, at latitude 0 and longitude 0', async () => { + const result = await client.reverse(0, 0); + expect(!result.found && result.unit === null).toBe(true); + }); +}); diff --git a/packages/client/test/queue.test.ts b/packages/client/test/queue.test.ts new file mode 100644 index 0000000..dfaf89a --- /dev/null +++ b/packages/client/test/queue.test.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The queue: the order of its tasks, and the place that a cancelled task gives back (T-5). +import { describe, expect, it } from 'vitest'; +import { createQueue } from '../src/queue.js'; + +interface Gate { + readonly task: () => Promise; + readonly open: () => void; +} + +// A task that starts at once, and ends only when its gate opens. +function gated(started: string[], name: string): Gate { + let open = (): void => undefined; + const opened = new Promise((resolve) => { + open = resolve; + }); + return { + task: async () => { + started.push(name); + await opened; + }, + open, + }; +} + +const NEVER = new AbortController().signal; + +describe('createQueue', () => { + it('starts the tasks in the order of their calls, with at most the limit at a time', async () => { + const started: string[] = []; + const queue = createQueue(2); + const names = ['a', 'b', 'c', 'd', 'e', 'f']; + const gates = names.map((name) => gated(started, name)); + const calls = gates.map((gate) => queue(gate.task, NEVER)); + await Promise.resolve(); + expect(started).toEqual(['a', 'b']); + for (const gate of gates) { + gate.open(); + await Promise.resolve(); + } + await Promise.all(calls); + expect(started).toEqual(names); + }); + + it('gives the place of a task that left to the next task in line', async () => { + const started: string[] = []; + const queue = createQueue(1); + const first = gated(started, 'first'); + const leaving = gated(started, 'leaving'); + const staying = gated(started, 'staying'); + const controller = new AbortController(); + const calls = [ + queue(first.task, NEVER), + queue(leaving.task, controller.signal), + queue(staying.task, NEVER), + ]; + controller.abort(new Error('left')); + await expect(calls[1]).rejects.toThrow('left'); + first.open(); + await calls[0]; + await Promise.resolve(); + expect(started).toEqual(['first', 'staying']); + staying.open(); + await calls[2]; + const after = gated(started, 'after'); + after.open(); + await queue(after.task, NEVER); + expect(started).toEqual(['first', 'staying', 'after']); + }); +}); diff --git a/packages/client/test/readme.test.ts b/packages/client/test/readme.test.ts new file mode 100644 index 0000000..fd362d2 --- /dev/null +++ b/packages/client/test/readme.test.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { runExamples, tsBlocks } from '../../core/test/examples.js'; +import * as client from '../src/index.js'; +import type { PostcodeErrorCode } from '../src/index.js'; +import { CLIENT_SOURCES, useMockServer } from './examples.js'; + +const README = readFileSync(new URL('../README.md', import.meta.url), 'utf8'); +const EXAMPLES = tsBlocks(README).map((source, index) => ({ + name: `README example ${String(index)}`, + source, +})); + +describe('README', () => { + useMockServer(); + runExamples(EXAMPLES, CLIENT_SOURCES); + + it.each(Object.keys(client))('shows %s in an example', (name) => { + expect(EXAMPLES.map((example) => example.source).join('\n')).toMatch( + new RegExp(`\\b${name}\\b`), + ); + }); + + it('says that the package is unofficial', () => { + expect(README).toContain('> Unofficial. Not made or endorsed by NIPOST.'); + }); + + it('names the version of the spec that the package implements', () => { + const version = client.SPEC_VERSION.replaceAll('.', '\\.'); + expect(README).toMatch(new RegExp(`Gatepost spec\\s*\\|\\s*${version}\\s*\\|`)); + }); + + it('lists every error code, and no other, as the type does', () => { + // A new or removed code in the type makes this record fail to compile. + const codes: Record = { + invalid_input: true, + unauthorized: true, + insufficient_credits: true, + origin_not_allowed: true, + forbidden: true, + rate_limited: true, + server_error: true, + unexpected_response: true, + network_error: true, + timeout: true, + }; + const sentence = /Its `code` is one of ([^.]*)\./.exec(README)?.[1] ?? ''; + const listed = [...sentence.matchAll(/`([a-z_]+)`/g)].map((match) => match[1]); + expect(listed.sort()).toEqual(Object.keys(codes).sort()); + }); +}); diff --git a/packages/client/test/requests.test.ts b/packages/client/test/requests.test.ts new file mode 100644 index 0000000..7fa2d87 --- /dev/null +++ b/packages/client/test/requests.test.ts @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { parse } from '@gatepost/core'; +import { describe, expect, it } from 'vitest'; +import { PostcodeError } from '../src/index.js'; +import { autocompleteRequest, lookupRequest, reverseRequest } from '../src/requests.js'; + +const FORMAT = new URL('../../../spec/data/format.json', import.meta.url); +const { maxInputCodePoints } = JSON.parse(readFileSync(FORMAT, 'utf8')) as { + readonly maxInputCodePoints: number; +}; + +function failure(build: () => unknown): PostcodeError { + try { + build(); + } catch (error: unknown) { + if (error instanceof PostcodeError) { + return error; + } + } + throw new Error('The call raised no PostcodeError.'); +} + +describe('lookupRequest', () => { + it('sends the canonical form and the level', () => { + const { postcode, request } = lookupRequest('fc01 z99 zz01', 2); + expect(postcode.canonical).toBe('FC-01-Z99-ZZ-01'); + expect(request).toEqual({ path: '/v1/lookup', query: { code: 'FC-01-Z99-ZZ-01', level: '2' } }); + }); + + it('uses level 1 when the caller leaves the level out', () => { + const { level, request } = lookupRequest('FC-01-Z99-ZZ-01', undefined); + expect(level).toBe(1); + expect(request.query['level']).toBe('1'); + }); + + it('takes a postcode that parse returned', () => { + const parsed = parse('LA-01-Z99-ZZ-01'); + if (!parsed.ok) { + throw new Error('The test code does not parse.'); + } + expect(lookupRequest(parsed.value, 1).request.query['code']).toBe('LA-01-Z99-ZZ-01'); + }); + + it('refuses a partial postcode, also one that parse returned, and names the reason', () => { + const area = parse('FC-01-Z99-ZZ', { allowPartial: true }); + if (!area.ok) { + throw new Error('The test code does not parse.'); + } + for (const code of ['FC-01-Z99', area.value]) { + const error = failure(() => lookupRequest(code, 1)); + expect(error.code).toBe('invalid_input'); + expect(error.status).toBeNull(); + expect(error.message).toContain('(bad_length)'); + } + }); + + it('refuses a legacy postcode', () => { + expect(failure(() => lookupRequest('900108', 1)).message).toContain('(legacy_code)'); + }); + + it.each([0, 6, 2.5, -1, Number.NaN, Number.POSITIVE_INFINITY, '2'])( + 'refuses the level %j', + (level) => { + const error = failure(() => lookupRequest('FC-01-Z99-ZZ-01', level as number)); + expect(error.code).toBe('invalid_input'); + expect(error.status).toBeNull(); + }, + ); +}); + +describe('reverseRequest', () => { + it('writes each number as JavaScript prints it, and adds the radius only when given', () => { + expect(reverseRequest(9, 7, undefined)).toEqual({ + path: '/v1/search/reverse', + query: { lat: '9', lng: '7' }, + }); + expect(reverseRequest(9.001, -0.5, 250).query).toEqual({ + lat: '9.001', + lng: '-0.5', + max_distance_m: '250', + }); + }); + + it('writes a number in full, with no exponent, and a negative zero as 0', () => { + expect(reverseRequest(0.0000001, 1.5e-7, undefined).query).toEqual({ + lat: '0.0000001', + lng: '0.00000015', + }); + expect(reverseRequest(-0.0000001, -0, 0).query).toEqual({ + lat: '-0.0000001', + lng: '0', + max_distance_m: '0', + }); + }); + + it('accepts the limits of each range', () => { + expect(reverseRequest(-90, 180, 0).query['lat']).toBe('-90'); + expect(reverseRequest(90, -180, 250).query['lng']).toBe('-180'); + }); + + it.each([ + [90.5, 7, undefined], + [-91, 7, undefined], + [Number.NaN, 7, undefined], + [Number.POSITIVE_INFINITY, 7, undefined], + [9, 180.1, undefined], + [9, -181, undefined], + [9, Number.NEGATIVE_INFINITY, undefined], + [9, 7, 251], + [9, 7, -1], + [9, 7, Number.NaN], + [9, '7', undefined], + ])('refuses lat %j, lng %j and radius %j', (lat, lng, radius) => { + const error = failure(() => reverseRequest(lat, lng as number, radius)); + expect(error.code).toBe('invalid_input'); + expect(error.status).toBeNull(); + }); +}); + +describe('autocompleteRequest', () => { + it('sends the normalised text', () => { + expect(autocompleteRequest(' fc-01 z ')).toEqual({ + typed: 'FC01Z', + request: { path: '/v1/search/autocomplete', query: { q: 'FC01Z' } }, + }); + }); + + it('accepts the 11 characters of a whole code, and refuses a twelfth', () => { + expect(autocompleteRequest('FC01Z99ZZ01').typed).toBe('FC01Z99ZZ01'); + expect(failure(() => autocompleteRequest('FC01Z99ZZ011')).code).toBe('invalid_input'); + }); + + it.each(['', ' - ', 'FC!', `FC 01 ${String.fromCodePoint(0xe9)}`])( + 'refuses %j, which the gateway cannot use', + (q) => { + expect(failure(() => autocompleteRequest(q)).code).toBe('invalid_input'); + }, + ); + + it('applies the input limit of the spec data before it normalises', () => { + const atLimit = 'F'.padEnd(maxInputCodePoints); + const overLimit = 'F'.padEnd(maxInputCodePoints + 1); + expect(autocompleteRequest(atLimit).typed).toBe('F'); + expect(failure(() => autocompleteRequest(overLimit)).code).toBe('invalid_input'); + }); + + it('counts code points, not UTF-16 units, for the input limit', () => { + // Each mathematical bold capital is one code point in two UTF-16 units. NFKC makes it a + // plain letter, so text at the limit still works. + const bold = String.fromCodePoint(0x1d405, 0x1d402) + ' '.repeat(maxInputCodePoints - 2); + expect(bold.length).toBeGreaterThan(maxInputCodePoints); + expect(autocompleteRequest(bold).typed).toBe('FC'); + }); +}); diff --git a/packages/client/test/responses.test.ts b/packages/client/test/responses.test.ts new file mode 100644 index 0000000..53395d5 --- /dev/null +++ b/packages/client/test/responses.test.ts @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The contract scenarios cover the shapes that the gateway sends. These tests cover the shapes +// that it might send next, because NIPOST's API is at version 0.1 (API-14). +import { parse, type Postcode } from '@gatepost/core'; +import { describe, expect, it } from 'vitest'; +import { PostcodeError } from '../src/index.js'; +import { apiCodeOf, dataOf, readAutocomplete, readLookup, readReverse } from '../src/responses.js'; + +function postcode(text: string): Postcode { + const parsed = parse(text, { allowPartial: true }); + if (!parsed.ok) { + throw new Error(`${text} does not parse.`); + } + return parsed.value; +} + +const UNIT = postcode('FC-01-Z99-ZZ-01'); + +function unexpected(read: () => unknown): PostcodeError { + try { + read(); + } catch (error: unknown) { + if (error instanceof PostcodeError && error.code === 'unexpected_response') { + return error; + } + } + throw new Error('The reader raised no unexpected_response.'); +} + +describe('dataOf and apiCodeOf', () => { + it('reads the data field, which can be any JSON value', () => { + expect(dataOf({ data: [] })).toEqual([]); + }); + + it.each([undefined, null, [], 'text', { error: {} }, { result: {} }])( + 'gives unexpected_response with status 200 for the body %j', + (body) => { + expect(unexpected(() => dataOf(body)).status).toBe(200); + }, + ); + + it.each([ + [{ error: { code: 'level_not_granted', message: 'any' } }, 'level_not_granted'], + [{ error: { code: 7 } }, null], + [{ error: 'level_not_granted' }, null], + [undefined, null], + ])('reads the API code of %j as %j', (body, apiCode) => { + expect(apiCodeOf(body)).toBe(apiCode); + }); +}); + +describe('readLookup', () => { + const read = (data: unknown) => readLookup(data, { postcode: UNIT, level: 5 }); + + it.each([ + [{ valid: true }, 1], + [{ valid: true, recent_house_address: { recent: 'x' } }, 2], + [{ valid: true, administrative_address: {} }, 2], + [{ valid: true, building_use_status: 'residential' }, 3], + [{ valid: true, other_building_info: {} }, 4], + [{ valid: true, point_geometry: [7, 9] }, 5], + [{ valid: true, administrative_address: null, point_geometry: null }, 1], + ])('reads %j as level %i', (data, level) => { + expect(read(data).levelReceived).toBe(level); + }); + + it('keeps the level that the caller asked for, and the postcode that the core parsed', () => { + const result = read({ postcode: 'SOMETHING ELSE', valid: false }); + expect(result.levelRequested).toBe(5); + expect(result.postcode).toBe(UNIT); + }); + + it('gives null for each address name that is missing, and for an address that is text', () => { + const result = read({ + valid: true, + administrative_address: { state_name: 'FEDERAL CAPITAL TERRITORY', zone: 3 }, + recent_house_address: '1 SYNTHETIC STREET', + }); + expect(result.administrativeAddress).toEqual({ + stateName: 'FEDERAL CAPITAL TERRITORY', + lgaName: null, + localityName: null, + zone: null, + }); + expect(result.recentHouseAddress).toBeNull(); + }); + + it.each(['valid', 'restricted', 'invalid', 'not_found', 'maintenance'])( + 'keeps the status %s as text', + (status) => { + expect(read({ valid: false, status }).status).toBe(status); + }, + ); + + it('gives a null status when the response has none', () => { + expect(read({ valid: true }).status).toBeNull(); + expect(read({ valid: true, status: null }).status).toBeNull(); + }); + + it.each([5, true, {}, []])('gives unexpected_response for the status %j', (status) => { + expect(unexpected(() => read({ valid: true, status })).status).toBe(200); + }); + + it.each([{}, { valid: 'true' }, null, []])('gives unexpected_response for %j', (data) => { + expect(unexpected(() => read(data)).status).toBe(200); + }); +}); + +describe('readReverse', () => { + const base = { found: true, radius_m: 250 }; + + it('gives unexpected_response for a unit that it cannot read', () => { + const units = [ + { postcode: 'FC-01-Z99', distance_m: 4.2 }, + { postcode: 'HELLO', distance_m: 4.2 }, + { distance_m: 4.2 }, + { postcode: 'FC-01-Z99-ZZ-01' }, + { postcode: 'FC-01-Z99-ZZ-01', distance_m: '4.2' }, + { postcode: 'FC-01-Z99-ZZ-01', distance_m: Number.POSITIVE_INFINITY }, + 'FC-01-Z99-ZZ-01', + ]; + for (const unit of units) { + expect(unexpected(() => readReverse({ ...base, unit })).status).toBe(200); + } + }); + + it('gives a null unit when the gateway sends none', () => { + expect(readReverse({ ...base, unit: null }).unit).toBeNull(); + expect(readReverse(base).unit).toBeNull(); + }); + + it('reads the names and the address of a unit when the gateway sends them', () => { + const unit = { + postcode: 'FC-01-Z99-ZZ-01', + distance_m: 12.5, + confidence: 'medium', + state_name: 'FEDERAL CAPITAL TERRITORY', + lga_name: 'SYNTHETIC LGA', + locality_name: 'SYNTHETIC LOCALITY', + address: '1 SYNTHETIC STREET', + }; + expect(readReverse({ ...base, unit }).unit).toEqual({ + postcode: UNIT, + distanceM: 12.5, + confidence: 'medium', + stateName: 'FEDERAL CAPITAL TERRITORY', + lgaName: 'SYNTHETIC LGA', + localityName: 'SYNTHETIC LOCALITY', + address: '1 SYNTHETIC STREET', + }); + }); + + it('turns an unknown confidence into low', () => { + const unit = { postcode: 'FC-01-Z99-ZZ-01', distance_m: 1, confidence: 'certain' }; + expect(readReverse({ ...base, unit }).unit?.confidence).toBe('low'); + }); + + it('keeps the area and the district as the text that the gateway sent', () => { + const text = readReverse({ ...base, area: 'FC-01-Z99-ZZ', district: 'FC-01-Z99', state: 'FC' }); + expect([text.area, text.district, text.state]).toEqual(['FC-01-Z99-ZZ', 'FC-01-Z99', 'FC']); + const other = readReverse({ ...base, area: 'ZZ', district: 99 }); + expect([other.area, other.district, other.state]).toEqual(['ZZ', null, null]); + }); + + it('gives a null radius when the body has none or a radius that is not a number', () => { + expect(readReverse({ found: false }).radiusM).toBeNull(); + expect(readReverse({ found: false, radius_m: '25' }).radiusM).toBeNull(); + expect(readReverse({ found: false, radius_m: Number.POSITIVE_INFINITY }).radiusM).toBeNull(); + expect(readReverse(base).radiusM).toBe(250); + }); + + it.each([{ radius_m: 25 }, { found: 'yes', radius_m: 25 }, [], null])( + 'gives unexpected_response for %j', + (data) => { + expect(unexpected(() => readReverse(data)).status).toBe(200); + }, + ); +}); + +describe('readAutocomplete', () => { + it('builds the partial postcode of each segment from the typed text', () => { + const cases = [ + ['F', 'state', 'FC', 'FC'], + ['FC', 'state', 'FC', 'FC'], + ['FC0', 'lga', '01', 'FC-01'], + ['FC01Z9', 'district', 'Z99', 'FC-01-Z99'], + // A district can hold letters. Cut one place later, FC01ZZ plus ZZZ reads as an area. + ['FC01ZZZ', 'district', 'ZZZ', 'FC-01-ZZZ'], + ['FC01Z99Z', 'area', 'ZZ', 'FC-01-Z99-ZZ'], + ['FC01Z99ZZ01', 'unit', '01', 'FC-01-Z99-ZZ-01'], + ] as const; + for (const [typed, segment, code, canonical] of cases) { + const result = readAutocomplete({ segment, suggestions: [{ code }] }, typed); + expect(result.suggestions[0]?.postcode?.canonical).toBe(canonical); + } + }); + + it.each([ + // The cut is at the start of the active segment, so the typed text of that segment is + // never part of the postcode, and a code of the wrong length gives no postcode. + ['FC01Z99', 'district', 'Z9', null], + ['FC01', 'lga', '1', null], + ['FC', 'lga', '01', 'FC-01'], + ['FC01Z', 'district', 'Z99X', null], + ] as const)( + 'cuts the typed text at the segment start: %s, %s, %s', + (typed, segment, code, canonical) => { + const result = readAutocomplete({ segment, suggestions: [{ code }] }, typed); + expect(result.suggestions[0]?.postcode?.canonical ?? null).toBe(canonical); + }, + ); + + it('gives a null postcode when no cut of the typed text makes one', () => { + const result = readAutocomplete({ segment: 'lga', suggestions: [{ code: '00' }] }, 'FC0'); + expect(result.suggestions).toEqual([{ code: '00', label: null, postcode: null }]); + }); + + it.each([[{ label: 'x' }], ['FC'], [null], [{ code: 7 }]])( + 'gives unexpected_response for the item %j, and returns no empty value', + (item) => { + const data = { segment: 'state', suggestions: [{ code: 'FC' }, item] }; + const error = unexpected(() => readAutocomplete(data, 'F')); + expect(error.status).toBe(200); + expect(error.message).toContain('Try again, or update it.'); + }, + ); + + it.each([ + { segment: 'street', suggestions: [] }, + { segment: 'state' }, + { segment: 'state', suggestions: {} }, + { segment: 'state', suggestions: 'FC' }, + [], + ])('gives unexpected_response for %j', (data) => { + expect(unexpected(() => readAutocomplete(data, 'F')).status).toBe(200); + }); +}); diff --git a/packages/client/test/retry.test.ts b/packages/client/test/retry.test.ts new file mode 100644 index 0000000..633ff0f --- /dev/null +++ b/packages/client/test/retry.test.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PostcodeError } from '../src/index.js'; +import { retryAfterMs, retryWaitMs } from '../src/retry.js'; + +const NOW = Date.parse('2026-10-14T09:00:00Z'); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('retryAfterMs', () => { + it.each([ + ['1', 1000], + ['120', 120_000], + ['0', 0], + ['Wed, 14 Oct 2026 09:00:05 GMT', 5000], + ['Wed, 14 Oct 2026 09:00:00 GMT', 0], + ])('reads %j as %i ms', (header, waitMs) => { + expect(retryAfterMs(header, NOW)).toBe(waitMs); + }); + + // The three HTTP-date forms of RFC 9110. The asctime form has no zone, and it means UTC. + it.each([ + ['Wed, 14 Oct 2026 09:00:05 GMT', 5000], + ['Wednesday, 14-Oct-26 09:00:05 GMT', 5000], + ['Wed Oct 14 09:00:05 2026', 5000], + ])('reads the date form %j', (header, waitMs) => { + expect(retryAfterMs(header, NOW)).toBe(waitMs); + }); + + it('pads the day of the asctime form', () => { + const now = Date.parse('2026-11-03T09:00:00Z'); + expect(retryAfterMs('Tue Nov 3 09:00:05 2026', now)).toBe(5000); + }); + + it.each([ + null, + '', + '1.5', + '-1', + 'soon', + 'soon 2027', + '2999-01-01', + '2999-01-01T00:00:00Z', + 'Wed, 14 Oct 2026 08:59:59 GMT', + 'Wed, 14 Oct 2026 09:00:05 +0000', + 'Wed, 14 Oct 2026 09:00:05', + 'Wed Oct 14 09:00:05 2026 GMT', + 'Wed, 14 oct 2026 09:00:05 GMT', + 'Sat, 31 Oct 2026 09:60:05 GMT', + 'Wed, 14 Oct 2026 09:00:05 GMT and more', + ])('gives null for %j', (header) => { + expect(retryAfterMs(header, NOW)).toBeNull(); + }); +}); + +describe('retryWaitMs', () => { + const error = ( + code: PostcodeError['code'], + details: { status?: number; retryAfterMs?: number } = {}, + ) => new PostcodeError(code, 'test', details); + + it('doubles the wait before each retry from 500 ms, with up to 250 ms more at random', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + expect(retryWaitMs(error('server_error', { status: 503 }), 1, true)).toBe(500); + expect(retryWaitMs(error('network_error'), 2, true)).toBe(1000); + expect(retryWaitMs(error('network_error'), 3, true)).toBe(2000); + vi.spyOn(Math, 'random').mockReturnValue(0.999); + expect(retryWaitMs(error('timeout'), 1, true)).toBeCloseTo(749.75); + }); + + it.each([502, 503, 504])('retries the status %i', (status) => { + expect(retryWaitMs(error('server_error', { status }), 1, true)).not.toBeNull(); + }); + + it.each([ + ['server_error', 500], + ['server_error', 200], + ['invalid_input', 400], + ['unauthorized', 401], + ['forbidden', 403], + ] as const)('does not retry %s with status %i', (code, status) => { + expect(retryWaitMs(error(code, { status }), 1, true)).toBeNull(); + }); + + it('does not retry a timeout of autocomplete', () => { + expect(retryWaitMs(error('timeout'), 1, false)).toBeNull(); + }); + + it('waits exactly as long as a Retry-After of 10 seconds or less asks', () => { + expect(retryWaitMs(error('rate_limited', { retryAfterMs: 10_000 }), 2, true)).toBe(10_000); + }); + + it.each([502, 503, 504])('waits as long as a valid Retry-After asks after a %i', (status) => { + const wait = retryWaitMs(error('server_error', { status, retryAfterMs: 7000 }), 1, true); + expect(wait).toBe(7000); + }); + + it('uses its own wait after a 503 with no Retry-After, or with one over 10 seconds', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + expect(retryWaitMs(error('server_error', { status: 503 }), 1, true)).toBe(500); + const long = error('server_error', { status: 503, retryAfterMs: 10_001 }); + expect(retryWaitMs(long, 2, true)).toBe(1000); + }); + + it('ends the call at once for a 429 with a longer Retry-After, or with none', () => { + expect(retryWaitMs(error('rate_limited', { retryAfterMs: 10_001 }), 1, true)).toBeNull(); + expect(retryWaitMs(error('rate_limited'), 1, true)).toBeNull(); + }); +}); diff --git a/packages/client/test/scenarios.ts b/packages/client/test/scenarios.ts new file mode 100644 index 0000000..e2888e3 --- /dev/null +++ b/packages/client/test/scenarios.ts @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Reads the contract scenarios of the spec, and runs one against a mock server, as +// spec/contract/README.md says. The format is fixed there, so the types below copy it. +import { randomUUID } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import type { Postcode } from '@gatepost/core'; +import { PostcodeClient, PostcodeError } from '../src/index.js'; +import type { AutocompleteResult, LookupLevel, LookupResult, ReverseResult } from '../src/index.js'; + +/** One call of a scenario. */ +export type ScenarioCall = + | { readonly method: 'lookup'; readonly code: string; readonly level?: LookupLevel } + | { + readonly method: 'reverse'; + readonly lat: number; + readonly lng: number; + readonly maxDistanceM?: number; + } + | { readonly method: 'autocomplete'; readonly q: string }; + +/** The first request that reached the transport. */ +export interface SeenRequest { + readonly method: string; + readonly path: string; + readonly query: Readonly>; + readonly apiKey: string | null; +} + +/** A scenario file of spec/contract. */ +export interface Scenario { + readonly id: string; + readonly description: string; + readonly client: Readonly<{ + apiKey?: string; + timeoutMs?: number; + maxRetries?: number; + cacheTtlMs?: number; + }>; + readonly order?: 'parallel' | 'sequential'; + readonly calls: readonly ScenarioCall[]; + readonly expect: { + readonly attempts: number; + readonly waitsMs?: readonly { readonly min: number; readonly max: number }[]; + readonly maxInFlight?: number; + readonly request?: SeenRequest; + readonly outcomes: readonly unknown[]; + }; +} + +/** What a run of a scenario saw. */ +export interface ScenarioRun { + readonly outcomes: readonly unknown[]; + readonly attempts: number; + readonly waitsMs: readonly number[]; + readonly maxInFlight: number; + readonly request: SeenRequest | undefined; +} + +const CONTRACT = new URL('../../../spec/contract/', import.meta.url); + +/** + * Reads every scenario of the spec, in the order of the file names. + * + * @returns The scenarios. + */ +export function readScenarios(): readonly Scenario[] { + return readdirSync(CONTRACT) + .filter((name) => name.endsWith('.json')) + .sort() + .map((name) => JSON.parse(readFileSync(new URL(name, CONTRACT), 'utf8')) as Scenario); +} + +function canonical(postcode: Postcode | null): string | null { + return postcode === null ? null : postcode.canonical; +} + +// A result in a scenario writes each postcode in its canonical form. +function lookupOutcome(result: LookupResult): unknown { + return { ...result, postcode: result.postcode.canonical }; +} + +function reverseOutcome(result: ReverseResult): unknown { + const unit = + result.unit === null ? null : { ...result.unit, postcode: result.unit.postcode.canonical }; + return { ...result, unit }; +} + +function autocompleteOutcome(result: AutocompleteResult): unknown { + const suggestions = result.suggestions.map((item) => ({ + ...item, + postcode: canonical(item.postcode), + })); + return { segment: result.segment, suggestions }; +} + +function radius(call: { readonly maxDistanceM?: number }): { maxDistanceM?: number } { + return call.maxDistanceM === undefined ? {} : { maxDistanceM: call.maxDistanceM }; +} + +async function outcomeOf(client: PostcodeClient, call: ScenarioCall): Promise { + try { + switch (call.method) { + case 'lookup': + return { + result: lookupOutcome( + await client.lookup(call.code, call.level === undefined ? {} : { level: call.level }), + ), + }; + case 'reverse': + return { result: reverseOutcome(await client.reverse(call.lat, call.lng, radius(call))) }; + case 'autocomplete': + return { result: autocompleteOutcome(await client.autocomplete(call.q)) }; + } + } catch (error: unknown) { + if (!(error instanceof PostcodeError)) { + throw error; + } + const { code, status, apiCode, retryAfterMs } = error; + return { error: { code, status, apiCode, retryAfterMs } }; + } +} + +/** + * Runs a scenario against a mock server. A transport wraps fetch: it adds the scenario headers, + * with a run value that no other run uses, and it counts the attempts, the waits and the + * requests in flight. + * + * @param scenario - The scenario. + * @param baseUrl - The mock server's address. + * @returns What the run saw. + */ +export async function runScenario(scenario: Scenario, baseUrl: string): Promise { + const run = randomUUID(); + const starts: number[] = []; + const ends: number[] = []; + let inFlight = 0; + let maxInFlight = 0; + let request: SeenRequest | undefined; + const transport: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + const url = new URL(input instanceof Request ? input.url : input); + request ??= { + method: init?.method ?? 'GET', + path: url.pathname, + query: Object.fromEntries(url.searchParams), + apiKey: headers.get('X-API-Key'), + }; + headers.set('X-Scenario-Id', scenario.id); + headers.set('X-Scenario-Run', run); + starts.push(performance.now()); + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + return await fetch(input, { ...init, headers }); + } finally { + inFlight -= 1; + ends.push(performance.now()); + } + }; + const client = new PostcodeClient({ ...scenario.client, baseUrl, transport }); + const outcomes = + scenario.order === 'parallel' + ? await Promise.all(scenario.calls.map((call) => outcomeOf(client, call))) + : await scenario.calls.reduce>( + async (done, call) => [...(await done), await outcomeOf(client, call)], + Promise.resolve([]), + ); + // A wait runs from the end of one attempt to the start of the next one. + const waitsMs = starts.slice(1).map((start, index) => start - (ends[index] ?? start)); + return { outcomes, attempts: starts.length, waitsMs, maxInFlight, request }; +} diff --git a/packages/client/test/send.test.ts b/packages/client/test/send.test.ts new file mode 100644 index 0000000..316fef6 --- /dev/null +++ b/packages/client/test/send.test.ts @@ -0,0 +1,375 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The rules of spec/client.md that no contract scenario tests: cancellation, keys in messages, +// HTTP dates and the clock. A scripted transport and a fake clock stand in for the network and +// for real waits (T-5). +import { describe, expect, it, vi } from 'vitest'; +import { PostcodeError } from '../src/index.js'; +import { createQueue } from '../src/queue.js'; +import type { GatewayRequest } from '../src/requests.js'; +import { send, type SendSettings } from '../src/send.js'; +import { reasonOf, settle, useFakeClock } from './fake-clock.js'; +import { fakeTransport, VALID_LOOKUP, type Answer } from './transport.js'; + +const KEY = 'nipost_test_mock_l3'; +const LOOKUP: GatewayRequest = { + path: '/v1/lookup', + query: { code: 'FC-01-Z99-ZZ-01', level: '1' }, +}; + +useFakeClock(); + +function settingsWith(changes: Partial, ...answers: readonly Answer[]) { + const fake = fakeTransport(...answers); + const settings: SendSettings = { + baseUrl: 'https://gateway.invalid', + apiKey: KEY, + transport: fake.transport, + timeoutMs: 8000, + maxRetries: 2, + queue: createQueue(4), + ...changes, + }; + return { settings, requests: fake.requests }; +} + +function sendLookup( + settings: SendSettings, + options: { signal?: AbortSignal; timeoutRetried?: boolean } = {}, +): Promise { + const { signal = new AbortController().signal, timeoutRetried = true } = options; + return send(settings, LOOKUP, { signal, timeoutRetried }); +} + +describe('a request', () => { + it('adds the path and the query to a base URL with a path and any closing slash', async () => { + for (const baseUrl of ['http://localhost:4010/gateway', 'http://localhost:4010/gateway/']) { + const { settings, requests } = settingsWith({ baseUrl }, VALID_LOOKUP); + await sendLookup(settings); + expect(requests[0]?.url.href).toBe( + 'http://localhost:4010/gateway/v1/lookup?code=FC-01-Z99-ZZ-01&level=1', + ); + } + }); + + it('sends the key in X-API-Key, and no such header when the client has no key', async () => { + const keyed = settingsWith({}, VALID_LOOKUP); + const keyless = settingsWith({ apiKey: undefined }, VALID_LOOKUP); + await sendLookup(keyed.settings); + await sendLookup(keyless.settings); + expect(keyed.requests[0]?.headers.get('X-API-Key')).toBe(KEY); + expect(keyless.requests[0]?.headers.has('X-API-Key')).toBe(false); + }); + + it('gives the data field of a response with status 200', async () => { + const { settings } = settingsWith({}, VALID_LOOKUP); + expect(await sendLookup(settings)).toEqual({ + postcode: 'FC-01-Z99-ZZ-01', + valid: true, + status: 'valid', + }); + }); +}); + +describe('timeouts', () => { + it('ends each attempt at timeoutMs, and retries it after 500 ms and 1000 ms', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const { settings, requests } = settingsWith({}, 'hang'); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(8000 + 500 + 8000 + 1000 + 7999); + expect(requests).toHaveLength(3); + const error = reasonOf(await settle(call, 1)) as PostcodeError; + expect([error.code, error.status]).toEqual(['timeout', null]); + expect(error.message).toContain('8000 ms'); + expect(requests.every((request) => request.signal.aborted)).toBe(true); + }); + + it('does not retry a timeout when the call says so, as autocomplete does', async () => { + const { settings, requests } = settingsWith({}, 'hang'); + const error = reasonOf(await settle(sendLookup(settings, { timeoutRetried: false }), 20_000)); + expect((error as PostcodeError).code).toBe('timeout'); + expect(requests).toHaveLength(1); + }); +}); + +describe('the total deadline', () => { + const unavailable = (retryAfter: string): Answer => ({ + status: 503, + body: { error: { code: 'unavailable' } }, + headers: { 'Retry-After': retryAfter }, + }); + + it('raises the last error at once when the next wait ends after the deadline', async () => { + // The deadline is 2 x 2000 ms and one wait of up to 750 ms, so 4750 ms. + const { settings, requests } = settingsWith( + { timeoutMs: 2000, maxRetries: 1 }, + unavailable('7'), + ); + const error = reasonOf(await settle(sendLookup(settings), 0)) as PostcodeError; + expect([error.code, error.status, error.retryAfterMs]).toEqual(['server_error', 503, 7000]); + expect(requests).toHaveLength(1); + }); + + it('counts the time of earlier attempts and waits', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const { settings, requests } = settingsWith({ timeoutMs: 2000, maxRetries: 2 }, 'hang', { + status: 503, + body: {}, + headers: { 'Retry-After': '6' }, + }); + // The first attempt times out at 2000 ms and the wait ends at 2500 ms. A wait of 6 s from + // there ends at 8500 ms, and the deadline is 3 x 2000 + 750 + 1250 = 8000 ms. + const error = reasonOf(await settle(sendLookup(settings), 2500)) as PostcodeError; + expect([error.code, error.retryAfterMs]).toEqual(['server_error', 6000]); + expect(requests).toHaveLength(2); + }); + + it('retries after a Retry-After that ends exactly at the deadline', async () => { + // The deadline is 2 x 1125 ms and one wait of up to 750 ms, so 3000 ms. + const { settings, requests } = settingsWith( + { timeoutMs: 1125, maxRetries: 1 }, + unavailable('3'), + VALID_LOOKUP, + ); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(3000); + await call; + expect(requests).toHaveLength(2); + }); +}); + +describe('a 503 with Retry-After', () => { + it('waits exactly as long as it asks, then tries again', async () => { + const answer = { status: 503, body: {}, headers: { 'Retry-After': '1' } }; + const { settings, requests } = settingsWith({}, answer, VALID_LOOKUP); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(999); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await call; + expect(requests).toHaveLength(2); + }); + + it('uses its own wait for an invalid value', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const answer = { status: 503, body: {}, headers: { 'Retry-After': 'soon' } }; + const { settings, requests } = settingsWith({}, answer, VALID_LOOKUP); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(499); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await call; + expect(requests).toHaveLength(2); + }); +}); + +describe('timers', () => { + it.each([ + ['a success', [VALID_LOOKUP]], + ['a failed response', [{ status: 500, body: {} }]], + ['a dropped connection', ['drop']], + ] as const)('leaves no timer after %s', async (_, answers) => { + const { settings } = settingsWith({ maxRetries: 0 }, ...answers); + await Promise.allSettled([sendLookup(settings)]); + expect(vi.getTimerCount()).toBe(0); + }); + + it('leaves no timer after a cancelled attempt, or a cancelled wait', async () => { + const attempt = settingsWith({ maxRetries: 0 }, 'hang'); + const wait = settingsWith({}, { status: 503, body: {} }); + for (const { settings } of [attempt, wait]) { + const controller = new AbortController(); + const call = sendLookup(settings, { signal: controller.signal }); + await vi.advanceTimersByTimeAsync(100); + controller.abort(); + await Promise.allSettled([call]); + expect(vi.getTimerCount()).toBe(0); + } + }); + + it('ends an attempt whose body does not arrive in time', async () => { + const transport: typeof fetch = (_, init) => { + const signal = init?.signal ?? new AbortController().signal; + const body = new ReadableStream({ + start(stream) { + signal.addEventListener('abort', () => { + stream.error(signal.reason); + }); + }, + }); + return Promise.resolve(new Response(body, { status: 200 })); + }; + const { settings } = settingsWith({ transport, timeoutMs: 1000, maxRetries: 0 }); + const error = reasonOf(await settle(sendLookup(settings), 1000)) as PostcodeError; + expect(error.code).toBe('timeout'); + }); +}); + +describe('error messages', () => { + const SECRET = 'nipost_live_do_not_show_this_key'; + const failures: readonly (readonly [string, Answer])[] = [ + ['401', { status: 401, body: { error: { code: 'invalid_api_key', message: SECRET } } }], + ['403', { status: 403, body: { error: { code: 'scope_not_granted' } } }], + ['429', { status: 429, body: {}, headers: { 'Retry-After': '60' } }], + ['500', { status: 500, body: 'not json' }], + ['a body with no data', { status: 200, body: { error: SECRET } }], + ['a dropped connection', 'drop'], + ['no answer', 'hang'], + ]; + + it.each(failures)('never hold the API key, for %s', async (_, answer) => { + const { settings } = settingsWith({ apiKey: SECRET, maxRetries: 0 }, answer); + const error = reasonOf(await settle(sendLookup(settings), 8000)); + expect(error).toBeInstanceOf(PostcodeError); + expect((error as PostcodeError).message).not.toContain('do_not_show'); + }); +}); + +describe('cancellation (API-11)', () => { + it('stops an attempt with the reason of the signal, and does not retry it', async () => { + const { settings, requests } = settingsWith({}, 'hang'); + const controller = new AbortController(); + const call = sendLookup(settings, { signal: controller.signal }); + await vi.advanceTimersByTimeAsync(100); + controller.abort(); + const reason = reasonOf(await settle(call, 10_000)); + expect(reason).toBe(controller.signal.reason); + expect(reason).not.toBeInstanceOf(PostcodeError); + expect(requests).toHaveLength(1); + expect(requests[0]?.signal.aborted).toBe(true); + }); + + it('stops during the wait before a retry, and sends no more requests', async () => { + const { settings, requests } = settingsWith({}, { status: 503, body: {} }); + const controller = new AbortController(); + const call = sendLookup(settings, { signal: controller.signal }); + await vi.advanceTimersByTimeAsync(100); + controller.abort(); + // The call ends at once, not when the wait of 500 ms or more ends. + expect(reasonOf(await settle(call, 0))).toBe(controller.signal.reason); + expect(requests).toHaveLength(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not wait for a retry when the signal aborted during the attempt', async () => { + const controller = new AbortController(); + const fake = fakeTransport({ status: 503, body: {} }); + const transport: typeof fetch = async (input, init) => { + const response = await fake.transport(input, init); + controller.abort(); + return response; + }; + const { settings } = settingsWith({ transport }); + const call = sendLookup(settings, { signal: controller.signal }); + expect(reasonOf(await settle(call, 0))).toBe(controller.signal.reason); + expect(fake.requests).toHaveLength(1); + }); + + it('takes a waiting request out of the queue, so the next one takes its place', async () => { + const { settings, requests } = settingsWith({ maxRetries: 0 }, 'hang'); + const running = [1, 2, 3, 4].map(() => sendLookup(settings)); + const controller = new AbortController(); + const leaving = sendLookup(settings, { signal: controller.signal }); + const next = send( + settings, + { ...LOOKUP, path: '/v1/next' }, + { + signal: new AbortController().signal, + timeoutRetried: true, + }, + ); + await vi.advanceTimersByTimeAsync(10); + expect(requests).toHaveLength(4); + controller.abort(); + expect(reasonOf(await settle(leaving, 0))).toBe(controller.signal.reason); + await Promise.allSettled([...running, next, vi.advanceTimersByTimeAsync(16_000)]); + expect(requests.map((request) => request.url.pathname)).toEqual([ + '/v1/lookup', + '/v1/lookup', + '/v1/lookup', + '/v1/lookup', + '/v1/next', + ]); + }); +}); + +describe('Retry-After', () => { + it('waits until an HTTP date that is 3 seconds ahead, then tries again', async () => { + const limited = { + status: 429, + body: { error: { code: 'rate_limited' } }, + headers: { 'Retry-After': 'Wed, 14 Oct 2026 09:00:03 GMT' }, + }; + const { settings, requests } = settingsWith({}, limited, VALID_LOOKUP); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(2999); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await call; + expect(requests).toHaveLength(2); + }); + + it('never waits less than it asks, when a timer fires early', async () => { + const limited = { status: 429, body: {}, headers: { 'Retry-After': '1' } }; + const { settings, requests } = settingsWith({}, limited, VALID_LOOKUP); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(0); + // The timer fires on time, but the finer clock says that half a millisecond is left. + const clock = vi.spyOn(performance, 'now').mockReturnValueOnce(performance.now() + 999.5); + await vi.advanceTimersByTimeAsync(1000); + expect(clock).toHaveBeenCalled(); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await call; + expect(requests).toHaveLength(2); + }); + + it('gives back its place in the queue while it waits', async () => { + const limited = { status: 429, body: {}, headers: { 'Retry-After': '5' } }; + const answers = [limited, limited, limited, limited, VALID_LOOKUP]; + const { settings, requests } = settingsWith({}, ...answers); + const waiting = [1, 2, 3, 4].map(() => sendLookup(settings)); + await vi.advanceTimersByTimeAsync(10); + await sendLookup(settings); + expect(requests).toHaveLength(5); + await Promise.allSettled([...waiting, vi.advanceTimersByTimeAsync(5000)]); + }); +}); + +describe('retryAfterMs of an error', () => { + const asking = (status: number): Answer => ({ + status, + body: {}, + headers: { 'Retry-After': '5' }, + }); + + it.each([429, 502, 503, 504])('holds the wait of a %i', async (status) => { + const { settings } = settingsWith({ maxRetries: 0 }, asking(status)); + const error = reasonOf(await settle(sendLookup(settings), 0)) as PostcodeError; + expect(error.retryAfterMs).toBe(5000); + }); + + it.each([400, 401, 402, 403, 404, 500, 501])('holds no wait for a %i', async (status) => { + const { settings } = settingsWith({ maxRetries: 0 }, asking(status)); + const error = reasonOf(await settle(sendLookup(settings), 0)) as PostcodeError; + expect([error.status, error.retryAfterMs]).toEqual([status, null]); + }); +}); + +describe('a transport that fails with its own abort error', () => { + it('still gives a timeout, and does not retry it for autocomplete', async () => { + const attempts = vi.fn(); + const transport: typeof fetch = (_input, init) => { + attempts(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')); + }); + }); + }; + const { settings } = settingsWith({ timeoutMs: 1000, transport }); + const error = reasonOf(await settle(sendLookup(settings, { timeoutRetried: false }), 20_000)); + expect((error as PostcodeError).code).toBe('timeout'); + expect(attempts).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/client/test/transport.ts b/packages/client/test/transport.ts new file mode 100644 index 0000000..dd268a9 --- /dev/null +++ b/packages/client/test/transport.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// A transport for unit tests. It answers from a script, with no network, and it records each +// request (T-5). + +/** One answer of the script. */ +export type Answer = + | { + readonly status: number; + readonly body: unknown; + readonly headers?: Readonly>; + } + | 'hang' + | 'drop'; + +/** A request that the transport saw. */ +export interface SentRequest { + readonly url: URL; + readonly headers: Headers; + readonly signal: AbortSignal; +} + +/** A transport with the requests that it saw. */ +export interface FakeTransport { + readonly transport: typeof fetch; + readonly requests: SentRequest[]; +} + +/** The body of a lookup at level 1 for a known unit. */ +export const VALID_LOOKUP: Answer = { + status: 200, + body: { data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid' } }, +}; + +// A request that hangs ends only when its signal aborts, with the signal's reason, as fetch does. +function hang(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error); + }); + }); +} + +/** + * Makes a transport that gives the answers in order, and repeats the last one. + * + * @param answers - The answers. + * @returns The transport and its record of requests. + */ +export function fakeTransport(...answers: readonly Answer[]): FakeTransport { + const requests: SentRequest[] = []; + const transport: typeof fetch = async (input, init) => { + const signal = init?.signal ?? new AbortController().signal; + const url = new URL(input instanceof Request ? input.url : input); + requests.push({ url, headers: new Headers(init?.headers), signal }); + const answer = answers[Math.min(requests.length, answers.length) - 1] ?? 'drop'; + if (answer === 'hang') { + return await hang(signal); + } + if (answer === 'drop') { + throw new TypeError('fetch failed'); + } + const headers = new Headers(answer.headers); + return new Response(JSON.stringify(answer.body), { status: answer.status, headers }); + }; + return { transport, requests }; +} diff --git a/packages/client/test/tsdoc.test.ts b/packages/client/test/tsdoc.test.ts new file mode 100644 index 0000000..1863582 --- /dev/null +++ b/packages/client/test/tsdoc.test.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { docExamples, readDocComments, runExamples } from '../../core/test/examples.js'; +import * as client from '../src/index.js'; +import { CLIENT_SOURCES, useMockServer } from './examples.js'; + +const EXAMPLES = docExamples(readDocComments(new URL('../src/', import.meta.url))); + +describe('TSDoc examples', () => { + useMockServer(); + runExamples(EXAMPLES, CLIENT_SOURCES); + + it.each(Object.keys(client))('shows %s in an example', (name) => { + expect(EXAMPLES.map((example) => example.source).join('\n')).toMatch( + new RegExp(`\\b${name}\\b`), + ); + }); +}); diff --git a/packages/client/test/types.test.ts b/packages/client/test/types.test.ts new file mode 100644 index 0000000..500c858 --- /dev/null +++ b/packages/client/test/types.test.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expectTypeOf, it } from 'vitest'; +import type { LookupResult, LookupStatus, ReverseResult } from '../src/index.js'; + +describe('the result types of spec/client.md', () => { + it('lets a lookup status be any text, because a new gateway can send one', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toExtend(); + }); + + it('gives a reverse result a nullable radius, and the area and district as gateway text', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/client/test/version.test.ts b/packages/client/test/version.test.ts new file mode 100644 index 0000000..aed8828 --- /dev/null +++ b/packages/client/test/version.test.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { SPEC_VERSION } from '../src/index.js'; + +describe('SPEC_VERSION', () => { + it('matches the version of the spec submodule', () => { + const specVersion = readFileSync(new URL('../../../spec/VERSION', import.meta.url), 'utf8'); + expect(SPEC_VERSION).toBe(specVersion.trim()); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..ccc6840 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "allowImportingTsExtensions": true, + "paths": { "@gatepost/core": ["../core/src/index.ts"] } + }, + "include": ["src", "test"] +} diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts new file mode 100644 index 0000000..93f11fd --- /dev/null +++ b/packages/client/tsdown.config.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { defineConfig } from 'tsdown'; + +// The bundler drops the package comment of src/index.ts from the declarations, and API Extractor +// reads that comment from the top of dist/index.d.ts. So the build puts the comment back. +function packageComment(): string { + const source = readFileSync(new URL('./src/index.ts', import.meta.url), 'utf8'); + const comments = source.match(/\/\*\*[\s\S]*?\*\//g) ?? []; + const comment = comments.find((text) => text.includes('@packageDocumentation')); + if (comment === undefined) { + throw new Error('src/index.ts has no doc comment with the tag @packageDocumentation.'); + } + return comment; +} + +export default defineConfig({ + entry: ['src/index.ts'], + format: 'esm', + platform: 'neutral', + target: 'es2022', + dts: true, + banner: { dts: packageComment() }, + clean: true, +}); diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts new file mode 100644 index 0000000..71a0439 --- /dev/null +++ b/packages/client/vitest.config.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + // The tests run against the source of the core, so they need no build of it first. + resolve: { + alias: { '@gatepost/core': fileURLToPath(new URL('../core/src/index.ts', import.meta.url)) }, + }, + test: { + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + thresholds: { branches: 90, functions: 90, lines: 90, statements: 90 }, + }, + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json index b7c55a4..d906f36 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,7 @@ "package:check": "run-s package:lint package:smoke", "package:lint": "publint && attw --pack --profile esm-only && size-limit && api-extractor run", "package:smoke": "node scripts/smoke.mjs", - "api:update": "api-extractor run --local" + "api:update": "api-extractor run --local", + "licence-files:check": "cmp ../../LICENSE LICENSE && cmp ../../NOTICE NOTICE" } } diff --git a/packages/core/test/examples.test.ts b/packages/core/test/examples.test.ts index 0a73510..7f8ca5a 100644 --- a/packages/core/test/examples.test.ts +++ b/packages/core/test/examples.test.ts @@ -36,6 +36,11 @@ describe('toModule', () => { expect(module).not.toContain("'@gatepost/core'"); }); + it('imports a package from the module that the sources name', () => { + const module = toModule("import { b } from '@gatepost/b';\nb();", { '@gatepost/b': '/b.ts' }); + expect(module).toContain("import { b } from '/b.ts';"); + }); + it('imports each function that an example without an import uses', () => { const module = toModule("parse('x'); // 'y'\nredact(x);"); expect(module).toContain("import { parse, redact } from '../../src/index.js';"); diff --git a/packages/core/test/examples.ts b/packages/core/test/examples.ts index c38f5d6..ea8ec2b 100644 --- a/packages/core/test/examples.ts +++ b/packages/core/test/examples.ts @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { afterAll, beforeAll, expect, it } from 'vitest'; @@ -12,6 +12,73 @@ export interface Example { readonly source: string; } +/** + * The module that each package name in an example stands for. A path is relative to the folder + * of the module that the test writes, or absolute. + */ +export type Sources = Readonly>; + +// The examples of the core run against its source. +const CORE_SOURCES: Sources = { '@gatepost/core': '../../src/index.js' }; + +/** The doc comments of one source file, each without its asterisks. */ +export interface SourceDocs { + readonly file: string; + readonly docs: readonly string[]; +} + +const DOC_COMMENT = /\/\*\*[\s\S]*?\*\//g; + +// The text of a doc comment without its asterisks, so that a code fence starts a line. +function docText(comment: string): string { + return comment + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map((line) => line.replace(/^\s*\* ?/, '')) + .join('\n'); +} + +/** + * Reads the doc comments of each TypeScript file in a folder. + * + * @param folder - The folder, such as the `src` folder of a package. + * @returns The doc comments of each file, in the order of the file names. + */ +export function readDocComments(folder: URL): readonly SourceDocs[] { + return readdirSync(folder) + .filter((name) => name.endsWith('.ts')) + .sort() + .map((file) => { + const text = readFileSync(new URL(file, folder), 'utf8'); + return { + file, + docs: Array.from(text.matchAll(DOC_COMMENT), ([comment]) => docText(comment)), + }; + }); +} + +/** + * Finds the code of each `@example` tag. Each tag must hold a code fence, or its code would never + * run. + * + * @param sources - The doc comments of each file. + * @returns The examples, named after their files. + */ +export function docExamples(sources: readonly SourceDocs[]): readonly Example[] { + return sources.flatMap(({ file, docs }) => { + const sections = docs.flatMap((doc) => doc.split(/^@example\b/m).slice(1)); + const blocks = sections.flatMap((section, index) => { + const inSection = tsBlocks(section); + if (inSection.length === 0) { + throw new Error(`The @example number ${String(index)} in ${file} has no ts code fence.`); + } + return inSection; + }); + return blocks.map((source, index) => ({ name: `${file} example ${String(index)}`, source })); + }); +} + // A line such as `canonical; // 'EK-01-A03-FK-01'` states a result. The test turns it into a call // of `strictEqual`, so the docs cannot show a result that the code does not give. A guard such // as `if (parsed.ok)` can skip such a call. So the module counts the calls that run, and the test @@ -62,13 +129,15 @@ function withImports(source: string): string { * Turns an example into a module that checks each result that the example shows. * * @param example - The code of the example. - * @returns The text of a TypeScript module that imports the package from its source. + * @param sources - The module for each package name. The default is the source of the core. + * @returns The text of a TypeScript module that imports each package from its source. */ -export function toModule(example: string): string { - const source = withImports(example) - .replaceAll("'@gatepost/core'", "'../../src/index.js'") - .replace(SHOWN_RESULT, '$1strictEqual($2, $3);'); - return `${COUNTING_PREAMBLE}\n${source}`; +export function toModule(example: string, sources: Sources = CORE_SOURCES): string { + const imported = Object.entries(sources).reduce( + (source, [name, module]) => source.replaceAll(`'${name}'`, `'${module}'`), + withImports(example), + ); + return `${COUNTING_PREAMBLE}\n${imported.replace(SHOWN_RESULT, '$1strictEqual($2, $3);')}`; } /** @@ -97,8 +166,9 @@ export function unreadableResults(source: string): readonly string[] { * wrong or is not compared. * * @param examples - The examples to run. + * @param sources - The module for each package name. The default is the source of the core. */ -export function runExamples(examples: readonly Example[]): void { +export function runExamples(examples: readonly Example[], sources: Sources = CORE_SOURCES): void { let folder: string; beforeAll(() => { @@ -121,7 +191,7 @@ export function runExamples(examples: readonly Example[]): void { [], ); const file = join(folder, `example-${String(index)}.ts`); - writeFileSync(file, toModule(source)); + writeFileSync(file, toModule(source, sources)); const { checkedResults } = (await import(pathToFileURL(file).href)) as { checkedResults: number; }; diff --git a/packages/core/test/tsdoc.test.ts b/packages/core/test/tsdoc.test.ts index 27b3cf9..9de7f06 100644 --- a/packages/core/test/tsdoc.test.ts +++ b/packages/core/test/tsdoc.test.ts @@ -1,45 +1,11 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 -import { readdirSync, readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import * as core from '../src/index.js'; -import { runExamples, tsBlocks, type Example } from './examples.js'; +import { docExamples, readDocComments, runExamples } from './examples.js'; -const SOURCE_FOLDER = new URL('../src/', import.meta.url); -const DOC_COMMENT = /\/\*\*[\s\S]*?\*\//g; - -// The text of a doc comment without its asterisks, so that a code fence starts a line. -function docText(comment: string): string { - return comment - .replace(/^\/\*\*/, '') - .replace(/\*\/$/, '') - .split('\n') - .map((line) => line.replace(/^\s*\* ?/, '')) - .join('\n'); -} - -const SOURCES = readdirSync(SOURCE_FOLDER) - .filter((name) => name.endsWith('.ts')) - .sort() - .map((file) => { - const text = readFileSync(new URL(file, SOURCE_FOLDER), 'utf8'); - return { file, docs: Array.from(text.matchAll(DOC_COMMENT), ([comment]) => docText(comment)) }; - }); - -// Each @example tag must hold a code fence, or its code would never run. -function examplesOf({ file, docs }: (typeof SOURCES)[number]): readonly Example[] { - const sections = docs.flatMap((doc) => doc.split(/^@example\b/m).slice(1)); - const blocks = sections.flatMap((section, index) => { - const inSection = tsBlocks(section); - if (inSection.length === 0) { - throw new Error(`The @example number ${String(index)} in ${file} has no ts code fence.`); - } - return inSection; - }); - return blocks.map((source, index) => ({ name: `${file} example ${String(index)}`, source })); -} - -const EXAMPLES = SOURCES.flatMap(examplesOf); +const SOURCES = readDocComments(new URL('../src/', import.meta.url)); +const EXAMPLES = docExamples(SOURCES); describe('TSDoc examples', () => { runExamples(EXAMPLES); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9612e03..4c4d020 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,16 @@ importers: specifier: ^2.9.1 version: 2.9.1 + packages/client: + dependencies: + '@gatepost/core': + specifier: workspace:^ + version: link:../core + devDependencies: + '@gatepost/mock-server': + specifier: workspace:* + version: link:../mock-server + packages/core: {} packages/mock-server: {}