Skip to content

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

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

MobileMage merged 21 commits into
mainfrom
feat/client

Conversation

@MobileMage

Copy link
Copy Markdown
Contributor

gatepost/postcode now also calls NIPOST's postcode gateway: lookup,
reverse and autocomplete, with the results, errors and limits that the
spec's client contract sets. It takes PSR-18, PSR-17 and PSR-16 objects,
retries what can succeed, honours Retry-After, and keeps an optional
cache whose keys are hashed with the API key, so no store holds the key.
A cache that fails never fails a call.

The client is synchronous, so it runs every contract scenario except the
four that need parallel calls. CI plays them on PHP 8.1 and 8.5 against
the mock gateway server from the js repo. The README examples run there
too.

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

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

Spec 0.2.0 adds the client contract, the fixtures and the contract
scenarios that the PHP client must pass. 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 glossary.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The client must give the same errors and the same retries as every
other Gatepost client, so the status map and the retry rules follow
the spec's client contract. PSR-18 has no timeout, so the client times
each attempt and reports a failure at the timeout as a timeout. A call
stops at its total deadline, and a Retry-After of 10 seconds or less
on a 429, 502, 503 or 504 sets the wait. A fake clock drives the
tests, so none of them sleeps. The package now requires the PSR
interfaces for HTTP and caching, which ADR 0003 records.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A lookup is the call that every integrator needs first. The client
checks the code with the core, so a typo never costs a request, and it
reads the level from the fields of the response, because the gateway
does not state it. A postcode that the gateway does not know is a
result, not an error. A status stays text, so a new gateway word is not
lost, and a body that the client cannot read is an unexpected_response.
Levels 2 to 5 follow NIPOST's docs, since the test keys hold level 1
only.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The spec calls a Retry-After date in the past invalid, so a 429 with
one now fails at once with rate_limited, and a 502, 503 or 504 uses
the client's own wait. A date that does not exist is invalid too, and a
whole number of up to 12 digits is valid. A test now pins the 250 ms
limit of the random part of a wait.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A level 2 or 3 body can lack one address name, or hold a recent address
or a building use of another type. The gateway has charged for such a
call, so failing it would lose a paid answer. Only valid and status can
now fail a lookup, as the contract lists. The tests also check that the
client keeps the caller's postcode when the body echoes another one.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A checkout field needs the postcode at a GPS fix and suggestions while
the user types. The gateway can find an area with no unit in the
radius, so a reverse result allows found with no unit. The client
refuses a coordinate or a radius outside its range, because the gateway
cuts a larger radius without a sign, and it writes each number in its
shortest decimal form, so every client sends the same query.
Autocomplete sends no empty text, because the gateway never answers it,
and it builds the postcode of each suggestion, because the gateway
sends the value of one segment only. It waits 15 s by default, and it
does not retry a timeout, because the next keystroke replaces the call.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The cache stays off unless the caller sets cacheTtlMs and passes a
PSR-16 cache, as the spec asks. Each key of the caller's cache holds a
generation, so clearCache() hides every kept result without wiping the
caller's other entries. A call takes its key before it sends the
request, so a request that started before clearCache() keeps no result.
Each API key keeps its own results, because two keys can hold different
lookup levels, and the client keeps no error.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
Every client must pass the contract scenarios of the spec against the
mock server, so the PHP client gives the errors, waits and outcomes of
the TypeScript client. The contract job runs the mock server from the js
repo with this repo's own spec, on PHP 8.1 and the newest PHP. The
synchronous client never has two requests in flight, so the runner
leaves out each scenario whose order is parallel. A unit test in the
normal check reads the scenario files and checks that rule.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The client reads a failed attempt as a timeout when it took timeoutMs or
more, and as a network error when it took less. With two defaults, 8 s
and 15 s, a caller who set the documented 8 s on the transport got a
network error from autocomplete and three attempts. The docs now say
that the transport timeout must be the longest in use, and tests pin
the limits at 14999 and 15000 ms for autocomplete and 7999 and 8000 ms
for a lookup. An optional clock lets the tests run with no sleep and no
reflection.

A JSON object that holds no field is now kept apart from an empty list,
so suggestions with the value {} give an unexpected_response, as in the
other clients. Text over the input limit and text that is not UTF-8 now
get their own message, because the old one named a rule that the text
had not broken.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A cache is an extra, so a store that throws on a read or a write now
costs a request and nothing more, and the caller still gets the result.
A kept entry that the client cannot read counts as a miss, and the new
result replaces it, so an entry from an older version or a damaged entry
cannot fail every call until its TTL ends. The cache key now uses an
HMAC of the request with the API key as the HMAC key, so a reader of the
cache cannot test guesses of the key with a plain hash.
clearCache() still passes a failure of the store to the caller, because
a clear that did not happen must not look as if it did.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
DOC-3 asks that CI runs each code example of a README. The new examples
call the gateway, so the contract job runs them against the mock server
with a mock key, and the unit suite runs the examples that need no
network. The README says that levels 2 to 5 follow NIPOST's docs, that
the client is synchronous, and that a caller sets the timeout on the
PSR-18 client, which has none, to the longest timeout in use. It tells
callers to use a cache store with eviction, because the client sets no
size bound of its own.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A PSR-18 transport holds one timeout, so the client uses one timeoutMs
for every call. The README said to set the transport timeout to 15 s and
pass no timeoutMs. A hanging gateway then made each lookup attempt last
15 s, which the client read as a timeout, and a call lasted about 31 s.

The README, the transport doc and the decision record now say to pass
the same value as the transport timeout. The README examples do so. Two
tests pin the setup: one reads the examples, and one runs a hanging
gateway against the fake clock.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
http_build_query() reads the ini value arg_separator.output when it gets
no separator. A host that sets it to &amp; made every reverse call lose
its second parameter, and every lookup send a parameter named amp;level.
The client now passes the separator and the encoding itself.

The client also sends Accept: application/json, as the TypeScript client
does.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The dump left out a symbol when its doc comment held the text @internal
anywhere. The doc of the timer parameter held it, so the dump lost the
whole constructor of PostcodeClient, and a rename of an option did not
change the dump.

The dump now reads only a tag at the start of a doc line. The timer
parameter doc no longer holds the word, and the dump shows the
constructor again.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The decimal writer stripped trailing zeros that its own search never
leaves, so the call could not change any result. Infection's console
reporter crashed on the mutant that removed it, because it put a style
tag right after the backslash of the native call name. The command then
exited 1, and the weekly mutation workflow would fail on its first run.

The dead call is gone. The mutation script also passes --show-mutations=0,
so that a later mutant of that kind cannot crash the reporter. The text
log still lists each escaped mutant.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
RFC 9110 says that a recipient must accept IMF-fixdate, RFC 850 and
asctime dates. The client read only the first, so a 429 with an RFC 850
or asctime date gave no wait. The format that PHP uses to read a weekday
also moves the date to that weekday, so a date with a wrong weekday gave
a wait of days.

The client now reads the three forms, with the same shape rules as the
TypeScript client, and it counts a date whose weekday does not match as
no header.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
The reader collapsed spaces with preg_replace() and kept the original
text for a null result. The pattern is valid, so the result is never
null, and the fallback could not run. Only the asctime form can hold two
spaces in a row, so a plain replace of two spaces is enough.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
A reverse call that times out must retry. A client with no maxRetries
must retry twice. A lookup response with the fields of levels 3 and 4
must read as level 4. A store that fails only when the client reads an
entry must cost a request and nothing more. No test failed when one of
these rules changed.

The tests now fail for each of those changes. They also pin the 429 wait
message at its rounding edges, the two autocomplete input messages and
the decimal form of 1. The 429 messages are made inside the test body,
because the mutation tool changed the code only after a data provider had
made them.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
retryAfterMs() holds the wait of a 502, 503 or 504 too, and the README
named only the 429. It also left out that a 5xx uses a valid Retry-After
in place of its own wait, that the cache hash covers the base URL, and
that the Node range of the mock server skips Node 23.

The exception of a failed connection holds the failure of the transport,
which can hold the request with the X-API-Key header. The README now says
not to log it with its objects. Two doc comments lose a broken wrap and
a note that a caller of the constructor cannot use.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
RFC 9110 does not require a recipient to check the day name of an HTTP
date, and the TypeScript client ignores it. Commit 8806436 made the PHP
client count a date with a wrong day name as no header. The two clients
then gave different waits for the same header.

The client still reads the day name in each of the three date forms, but
it does not compare it. A date with a wrong day name is valid and gives
the wait of its date. The tests that expected no wait now expect the wait
of the date.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
@MobileMage
MobileMage marked this pull request as ready for review October 3, 2026 04:08
The spec and the mock server are merged. The spec submodule now names
the spec release v0.2.0, and the contract job takes the mock server
from the js commit on main, which holds the same server.

Signed-off-by: MobileMage <aidelojejoshua@gmail.com>
@MobileMage
MobileMage merged commit 8025240 into main Oct 3, 2026
9 checks passed
@MobileMage
MobileMage deleted the feat/client branch October 3, 2026 11:10
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