Skip to content

feat(client): add the API client for the postcode gateway - #4

Merged
MobileMage merged 37 commits into
mainfrom
feat/client
Oct 3, 2026
Merged

MobileMage merged 37 commits into
mainfrom
feat/client

Conversation

@MobileMage

Copy link
Copy Markdown
Collaborator

@gatepost/client calls NIPOST's postcode gateway: lookup, reverse and
autocomplete, with the results, errors and limits that the spec's client
contract sets. It retries what can succeed, honours Retry-After, keeps at
most 4 requests in flight, shares calls for the same postcode and keeps an
optional cache. Its only runtime dependency is @gatepost/core.

Every contract scenario runs against the mock gateway server in the same
process, and every README and doc example runs too. A nightly workflow
checks the staging gateway once its address and key exist; until then it
makes no call.

This change is larger than the 400 lines that GIT-5 suggests, because it
adds a package. Most of it is tests.

Signed-off-by: MobileMage aidelojejoshua@gmail.com

Spec 0.2.0 adds the client contract and the fixtures that the mock
server reads. Its grammar and vectors are the same as before, so the
core passes them unchanged and only its SPEC_VERSION moves. CONTEXT.md
takes the new terms of the spec's glossary.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The mock server answers from the spec's files, so a new observation
changes data and no code. It reads them once at start and stops with the
name of a file that has the wrong shape, so a broken spec change cannot
serve a wrong body in silence. The package runs on Node, so the lint
config lets it import Node modules, and a test pins that exception.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
Client tests and the docs playground need answers in the gateway's
shapes, with its keys, levels, errors and rate-limit headers. Each
answer comes from a fixture, and a test checks each body that the code
builds or changes against the spec's OpenAPI file. The rules follow the
calls of 2 Oct 2026 where the docs disagree: a level above the key's
gets 403, and an empty autocomplete query gets no answer.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A spec file that is not JSON now fails with its path and the parser
message. A response must have exactly one kind field, and a fixture must
have a body, so the server cannot send the text "undefined". A repeated
scenario id or key fails with both places, and a scenario id must equal
its file name. Status, delay, level and rate values must be whole numbers
in range. Messages name the response or key entry. Folders are read in
sorted order, so the map order does not depend on the file system.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The spec now plays 70 scenarios, adds the lookup-no-data scenario and
changes the confidence of the reverse unit fixture to low. The search
test expected the old value, so it follows the fixture. The truth check
of the spec also plays each scenario as the client contract describes.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A client test sends X-Scenario-Id and X-Scenario-Run, and gets the next
response of that run. A run keeps its own place, so two test runs never
share a response. The server holds a connection open for a hang and
closes it for a drop, as the timeout and network scenarios need, and it
closes every open connection when a test ends. The command starts it on
Node with no build.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A rate-limited key now shows no requests left, as the 429 response
says. Reverse no longer returns a unit that lies outside the radius.
Numbers in a query read as decimal text by value, so a level of 2.0
works and 0x9 does not. The OpenAPI test now compares the headers and
the error code of each response with the file, and new tests pin the
order of the key, path, origin, scope, level and credit checks.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A delayed response kept a timer alive after the client left or the
server closed, so a process with a long delay did not exit for seconds.
The delay now ends when the client closes the connection and when the
server closes. A second close call now returns the first result, so two
signals no longer give an unhandled rejection. A failure inside the
error reply now closes the connection instead of leaving a rejection.
The URL of an IPv6 host has brackets, and the host :: gives localhost.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The PHP client and the docs need the mock server without a TypeScript
setup, so it ships as an image that runs the source on Node. CI builds
the image and looks up the synthetic unit on each pull request, so a
change cannot break the image unseen. A separate workflow publishes it
with a provenance attestation, and only a release tag or a person starts
it, because a pushed image is public.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
Docker accepts the first connection on the published port before Node
listens, and then resets it. The option --retry-connrefused does not
retry a reset, so the probe failed with exit code 56. The option
--retry-all-errors retries it.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
An empty HOST gave an empty string, and Node then bound every
interface. The command now treats an empty HOST as unset, as it does
for PORT. Two tests pin the loopback default, and the README says that
the server trusts a checked spec folder.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The new spec commit makes the deadline scenario less sensitive to a slow
runner, checks the postcode of each suggestion, and adds evidence marks
and notes to the contract.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
Every Gatepost client must raise the same error codes for the same
responses of the gateway. The client package starts with PostcodeError
and the map from each HTTP status to one code that spec/client.md fixes,
so the later parts share one mapping. The package depends on the core,
which parses each postcode before a request goes out, and an ADR says
why that dependency is the only one.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A reverse result can come without a radius, so radiusM may be null.
The client does not parse the area and the district of a reverse
result, so both are the text that the gateway sent. A new gateway can
send a lookup status that the client does not know, and the client
keeps it as text, so the status type is text and not a closed list.

Smaller fixes: the doc of timeoutMs gives the longer wait of
autocomplete, the smoke script reads the spec version from the spec
folder, the decision record no longer cites private planning work, and
the error tests cover more status values.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The client checks its input before it sends anything, so a code that
does not parse, a level, coordinate or radius out of range, or an
autocomplete query that is empty or too long, never reaches the
gateway. Numbers in a query use the shortest decimal form, so every
client sends the same query for the same input. The readers narrow each
response and read only the fields that they know, because NIPOST's API
is at version 0.1 and new fields will come. A part of a reply that is
missing or of the wrong type raises unexpected_response.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The gateway can stall, as it did for an empty autocomplete query, so
each attempt has a timeout, and a cancelled call frees its place in the
queue at once. A 502, 503 or 504, a lost connection and a timeout get a
retry. A 429 waits only for a short Retry-After, and so does a 502, 503
or 504 that carries a valid one. The queue keeps a client at four
requests at a time. A call also has a total deadline, so a long
Retry-After ends it with the last error instead of a late retry.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
PostcodeClient joins the parts, and adds the shared calls and the cache
that spec/client.md asks for. A caller can leave a shared call, and the
request stops when the last caller leaves, so a field that cancels each
keystroke never fills the queue. Every contract scenario of the spec
runs against the mock server in the same process, so this client and
the PHP client prove the same behaviour.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The reader dropped an item with no text in code and returned the rest.
If the gateway renamed the field, a caller got an empty list and no
error. The spec says the client never returns a guessed empty value, so
the reader now raises unexpected_response, and the message tells the
caller what to do. A radius or distance of Infinity counts as missing
or broken, as any other non-finite number does. Dead code in the
decimal writer is gone.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
Five faults passed the tests: a timer that is never cleared, a queue
that keeps a cancelled task in line, a queue that starts the newest
task first, a wait that ignores a cancel, and a timer that stops before
the body arrives. Each fault now fails a test. A call whose signal
aborts during an attempt now ends at once and does not wait before it
gives up. The test of the deadline now ends exactly on it.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
Autocomplete used the 8 s timeout of a lookup, and the spec gives it
15 s. The cache kept every result until its key came again, so a server
that asks about many coordinates grew without limit. It now drops
expired results, keeps at most 1000, and refuses a result that a
clearCache call overtook. The total deadline no longer loops once for
each retry, so a very large maxRetries does not hang the first call.
Tests now fail for a share key that ignores the level, and for a shared
request that stops when one caller leaves. A scenario gets a time limit
from its own waits, and an empty spec folder fails.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
DOC-3 asks that every code example runs in CI. The examples of the
client call the gateway, so their tests send each call to the mock
server instead, and refuse to run without it. The example runner of the
core now takes the modules that an example imports, so both packages
share one runner and one rule for the results that an example shows.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The README says that none of the client's error messages holds the
caller's key, and no test checked it. The new test makes a client with a
key, triggers every error code through the public calls, and reads the
name, message, code fields, stack and cause chain of each error. It also
covers the secret key refusal and the option checks. A key planted in
the timeout message and in a wrapped cause made it fail first.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The roadmap asks for a nightly check against NIPOST's staging gateway
with the test key, so a change of the gateway shows up within a day.
The contract scenarios need the scripted answers of the mock server, so
the job makes three plain calls that hold on any gateway. It makes no
call until NIPOST gives a staging address, and it prints no response.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The key-secrecy test held a made-up key in one line, and the secrets job of CI read it as
a real key. The test now builds the key at run time, so the tree holds no such line.
History cannot change, so the fingerprint of the old finding goes into .gitleaksignore.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A gateway that echoes the API key as its error code put the key into the message of the
error. No message may hold the key, so the message now names the status only. The code
stays in the apiCode field.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The README named nine error codes and the client has ten, so a reader who handled each
listed code left out a 200 reply with a broken body. A test now compares the list with the
PostcodeErrorCode type.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
Dropping lng from the reverse key, or keying autocomplete on the raw text, passed every test.
The new tests send two points that differ only in lng or lat, and two spellings of one
text.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The client tried every cut of the typed text and took the first that parsed at the segment
precision. A value of the wrong length then gave a postcode that held typed characters of
the active segment, and a short typed text gave null where the segments before it and the
value parse. The client now makes one cut at the segment start and checks the precision,
as the PHP client does.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A fork with Actions on ran the nightly job and used minutes, so the job now runs only in the
gatepost-dev/js repository. A failed assertion also printed the values that the gateway sent.
Each check is now a boolean, so a failure prints none.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The record carried a date later than every commit of the branch. It now has the date on which
the decision was made.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A caller that changes a result also changes what the next caller of the cache gets. The README
now says so, because a copy of each result would cost bundle size.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
An empty apiKey sent an empty X-API-Key header, which is no key. A caller who reads the key
from an unset variable now gets an error when the client is made.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A fault that removed the check of the abort signal passed every test, because the fake
transport always rejected with the reason of the signal. A real transport rejects with its own
AbortError after the timer fires. The new test checks that the call still ends with a timeout
and that autocomplete does not retry it.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
Date.parse read any text with a letter, so 'soon 2027' gave a wait of months, and it refused the
asctime form. The client now accepts the three HTTP-date forms of RFC 9110 and no other text.
A two-digit year of the RFC 850 form follows Date.parse, and a date such as 31 Nov rolls over.

retryAfterMs was set for every status. It now holds a wait for 429, 502, 503 and 504 only, as
the spec says. The change adds bytes, so the gateway detail of unexpected_response and the
advice texts are shorter, and the client keeps the bundle under 5 kB. The header is no longer
trimmed, because the platform has trimmed it already.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
@MobileMage
MobileMage marked this pull request as ready for review October 3, 2026 03:05
An empty apiKey is a configuration mistake, like a bad timeoutMs, maxRetries or cacheTtlMs. A
caller who reads the key from an unset variable made the mistake in code, so the constructor
now throws a RangeError, as it does for the other bad options. It no longer throws a
PostcodeError with the code invalid_input. That code is for a bad input of a call, not for a
bad option of the client.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The client sat a few bytes under its 5 KB limit, and some error
messages had been cut short to fit. The owner chose 6 KB, which leaves
room for the next feature without shorter messages.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
Base automatically changed from feat/mock-server to main October 3, 2026 11:01
The mock server branch is merged as one commit on main. This brings
that commit in, keeps the client's versions of AGENTS.md, README.md
and the lockfile, which hold every change of main, and points the spec
submodule at the spec release v0.2.0.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
@MobileMage
MobileMage merged commit f933b93 into main Oct 3, 2026
9 checks passed
@MobileMage
MobileMage deleted the feat/client branch October 3, 2026 11:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant