feat: implement the URL search params serialization standard - #463
Merged
Conversation
Port @seamapi/url-search-params-serializer to PHP as Seam\UrlSearchParamsSerializer over a Seam\UrlSearchParams pair collection, byte-for-byte identical to the TypeScript reference implementation. The unit test suite mirrors the reference and Python SDK suites, covering every branch of the standard. PHP's own primitives each diverge from the standard, so the port implements them directly: urlencode() is RFC 3986 flavored (escapes *, keeps ~) where the WHATWG form encoding does the opposite; float casts render 1.0, switch to exponents at the wrong thresholds, and spell them E+21, so floats follow the ECMAScript Number::toString algorithm; sorting compares UTF-16 code units, not UTF-8 bytes; and dates always carry exactly three fractional digits and a literal Z. PHP has a single absence value, so the Seam\NullValue enum adds the explicit null sentinel: null means the safe option of omitting a param, and sending null is always spelled NullValue::NULL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
Wrap the Guzzle client in Seam\Http\SerializingClient so every request follows the serialization standard. Query params given as a map are serialized with UrlSearchParamsSerializer and handed to Guzzle as a raw query string, since Guzzle's own encoder escapes *, keeps ~, and drops an empty array entirely instead of sending name= (which the API reads as the empty array rather than an unfiltered request). NullValue::NULL sentinels in JSON bodies become JSON null, so the sentinel works on both transports. A query already given as a string passes through untouched, and nothing serialized means no query at all rather than a bare trailing ?. Consume the blueprint's isNullable flag in the codegen: a nullable param is typed string|NullValue|null and accepts the sentinel, while a merely optional one keeps ?string and rejects it, so the type system catches sending an accidental null where it would unset a value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
razor-x
force-pushed
the
claude/php-beta-pr-1d60h3
branch
from
August 13, 2026 19:26
abae20d to
351c862
Compare
Mirror seamapi/python#617: the new StrictUrlSearchParamsSerializer wraps the base serializer and appends _strict=true to any non-empty query, telling the Seam API to use strict, schema-aware parsing. The flag is appended after the sort so it always sits last, a caller-supplied _strict param is replaced rather than repeated, and a query with no serializable params stays empty. The flag is Seam API behavior, not part of the serialization standard, so it is isolated in the wrapper and the base UrlSearchParamsSerializer stays a pure implementation of the standard. The SDK client serializes every request with the strict wrapper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
razor-x
force-pushed
the
claude/php-beta-pr-1d60h3
branch
from
August 14, 2026 04:26
618b945 to
d75e355
Compare
Keep a comment only when it says something the code cannot: a non-obvious why, an external constraint, or an invariant a future edit would break. Comments that narrate a test, restate an assertion, or argue the code is correct are deleted; the tests are the explanation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
razor-x
force-pushed
the
claude/php-beta-pr-1d60h3
branch
from
August 14, 2026 04:46
8f02093 to
d694874
Compare
Setting a param to null becomes a top-level usage section and Serializing URL search params follows the structure of the Python and Ruby READMEs, including the note explaining why PHP spells the sentinel NullValue::NULL where the other SDKs spell it NULL with type Null: both names are reserved in PHP, so the type and the value live on one enum. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
Drop the UTF-16 sort key: a stable sort is what the standard needs to keep array element order, and byte order matches URLSearchParams.sort() for every ASCII name, which all Seam param names are. Only a name beyond the Basic Multilingual Plane could order differently than the reference implementation, and then only against a name in U+E000 to U+FFFF. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports
@seamapi/url-search-params-serializerto PHP, wires it into the HTTP client, and types nullable params with an explicit null sentinel. Reviewable in sequence: the serializer alone (purely additive), the client wiring and codegen typing,_strict=truesupport mirroring seamapi/python#617, then cleanup commits.Why this is needed, on the wire
Before, query params went through Guzzle's
http_build_query(RFC 3986 rules). Measured before/after for the same inputs:["device_ids" => []]device_ids=&_strict=true(the empty array — returns nothing)["custom_metadata_has" => ["tag" => "front", "floor" => 2]]custom_metadata_has%5Btag%5D=front&custom_metadata_has%5Bfloor%5D=2custom_metadata_has.floor=2&custom_metadata_has.tag=front&_strict=true["device_ids" => ["d1", "d2"]]device_ids%5B0%5D=d1&device_ids%5B1%5D=d2device_ids=d1&device_ids=d2&_strict=true["search" => "a *~ b"]search=a%20%2A~%20bsearch=a+*%7E+b&_strict=true["sync" => true]sync=1sync=true&_strict=trueThe empty-array row is the severe one: HTTP 200 with the wrong data, invisible to status-code checks.
What's included
Seam\UrlSearchParamsSerializer+Seam\UrlSearchParams— the two-layer port: an ordered pair collection implementing the parts of theURLSearchParamsinterface the serializer needs, and the serializer walk on top (dot-joined nesting, array append vs scalar set, empty-array →name=, typedSeam\UnserializableParamErrorraised before any request goes out). The unit test suite mirrors the reference implementation's and the Python SDK's suites, covering every branch of the standard.Seam\StrictUrlSearchParamsSerializer— mirrors seamapi/python#617: appends_strict=trueto any non-empty query (after the sort, so it always sits last; a caller-supplied_strictis replaced, not repeated; an empty query stays empty), telling the Seam API to use strict, schema-aware parsing. The flag is Seam API behavior rather than part of the serialization standard, so it is isolated in this wrapper and the base serializer stays a pure implementation of the standard. The SDK client serializes every request with the strict wrapper.Seam\NullValue— the explicit null sentinel, as a unit enum (NullValue::NULL), since PHP has one absence value and the API distinguishes omit from set to null.nullalways means omit (the safe option); sending null is always spelled explicitly. The other SDKs spell the sentinelNULLwith typeNull; both names are reserved in PHP, so the type and the value live on one enum. Detected by type (instanceof), impossible to forge a second instance, and replaced by realnullin JSON bodies without mutating the caller's payload.Client wiring —
Seam\Http\SerializingClientdecorates the Guzzle client (bothSeamandSeamWithoutWorkspace, including caller-supplied clients viafrom_client). Map queries are serialized and handed to Guzzle as a raw query string (Guzzle's escape hatch: a stringqueryoption is used verbatim); string queries pass through untouched; an empty serialization removes the option so no bare?is emitted;NullValue::NULLinjsonbodies becomes JSONnull.Nullable typing in codegen — the blueprint's
isNullableflag now renders as a union with the sentinel, composed with optionality rather than replacing it: nullable+optional isstring|NullValue|null = null, merely optional stays?string = null. 49 generated params across 20 route clients are nullable today (all also optional). The import is emitted only in files that reference it.Docs — README sections aligned with the Python and Ruby SDKs: a top-level Setting a param to null section and a Serializing URL search params section under Advanced Usage, linking the reference implementation and the parser.
Sorting: stable byte order, deliberately
UrlSearchParams::sort()is a stable sort comparing names by byte. Stability is what the standard actually depends on (it preserves array element order); byte order matches JavaScript'sURLSearchParams.sort()for every ASCII name, which all Seam param names are. A name beyond the Basic Multilingual Plane (an astral emoji key) may order differently than the reference against a name in U+E000–U+FFFF — an accepted, documented deviation in exchange for not carrying a UTF-16 sort key implementation.Conformance
The serializer was verified against the reference with an external harness (not committed — the serializer is stable, and the spec-derived unit tests are the in-repo record). It generated a shared JSON fixture with tagged values so types survive the trip (
{"$date": ms},{"$null": true}, ...), fed the identical fixture to this port and to@seamapi/url-search-params-serializerv3, and diffed the outputs byte for byte. Results, re-run at the final commit:@seamapi/url-search-params-parserin strict mode, 0 mismatches, against Zod schemas derived per case — both sides agree on what the bytes mean.Stdlib functions that had to be replaced, per the probe:
urlencode("a *~ b")returnsa+%2A%7E+b— wrong on both*and~— so the WHATWG form encoder is hand-written (~15 lines over the UTF-8 bytes).(string)float casts render1.0, use PHP's precision ini, and spell exponents1.0E+21; floats instead follow the ECMAScriptNumber::toStringalgorithm, seeded with the shortest round-tripping digits fromvar_exportatserialize_precision=-1(set and restored around the call).DateTimeInterface::formathas no always-three-digit milliseconds; dates are formatted manually in UTC with microseconds truncated (never rounded). PHP datetimes always carry a timezone, so there is no naive value to interpret.usort(stable since PHP 8.0) with plainstrcmp— see the sorting note above.Verification beyond the harness
tests/SearchParamsTest.php) assert on the rawRequestInterfacequery string through the real Guzzle stack, base-URL resolution included: arrays and nested objects, the*/~non-re-encoding, absent params omitted, sentinel asname=,_strict=trueon every non-empty query, no bare?, all five verbs, string-query pass-through, the error raised with zero requests sent, the sentinel in a JSON body, and a generated route end-to-end. The raw query survives Guzzle because a stringqueryoption is applied verbatim and PSR-7'sUri::withQuerypreserves*,+,%XX,&, and=.devices->update(name: NullValue::NULL)is accepted whiledevices->update(is_managed: NullValue::NULL)is rejected (expects bool|null, but enum(Seam\NullValue::NULL) provided). Note the repo'spsalm.xmldeliberately excludes generated code from project scope, so this check was run with a one-off config rather than being CI-locked; the native parameter types (?boolvsstring|NullValue|null) additionally enforce the contract at runtime with aTypeError.composer lint(validate, syntax, Psalm),npm run lint(eslint + prettier), andtscare clean; re-runningnpm run generateproduces no drift. No new dependencies, dev or runtime.Notes and limitations
preferredMethodfalls back to POST for routes with complex params — so generated GET routes carry only scalars, and the array/nested-object query paths are reached through direct$seam->clientcalls (covered by the wire tests, plus a generated scalar GET route end-to-end).@seamapi/fake-seam-connect1.86.0 already parses the standard and accepts_strict=true— the pre-existingSerializationTestnow sendsdevice_ids=&_strict=truefor an empty array against the fake and still passes (0 devices returned).[]is the empty JavaScript array (name=); an empty plain object is spellednew \stdClass(). A map key PHP would cast to an integer (e.g."0") is rejected as a non-string key, since PHP cannot represent it as a string key — the one input class the two languages cannot share.NULLname, exported type) holds.string|NullValue, no default) should one appear.