feat: apply the URL search params standard and type nullable params - #614
Merged
Conversation
The Seam API parses URL search params as complex types, so the SDK has to build the query string itself. Serialize any mapping passed as params and set the result on the url, rather than letting httpx encode the params with its own rules, which represent arrays and nested objects differently. Replace the NULL sentinel with null in request bodies as well, so a param set to NULL is sent as null on either transport, and document how NULL tells an explicitly null param apart from an omitted one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
razor-x
commented
Aug 13, 2026
razor-x
commented
Aug 13, 2026
Co-authored-by: Evan Sosenko <razorx@evansosenko.com>
Consume the blueprint isNullable flag so a param the Seam API documents as nullable is typed to accept NULL, and a param that is merely optional is not. Optional params are omitted by passing None, where sending null would unset a value instead, so accepting the sentinel everywhere would invite exactly the mistake it exists to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
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.
Hooks the serializer added in #611 into the client, so requests actually go
out with the standard applied, and consumes the blueprint
isNullableflag soNULLis typed precisely. No method changes: routes keep usingpreferredMethodexactly as they do today.Serializing request params
SeamHttpClient.requestnow does two things before handing off to httpx:paramsis run throughserialize_url_search_params, and the resulting query string is set on theurl with
httpx.URL(url, query=query.encode()). httpx never sees theparams, so it never re-encodes them.
NULLsentinels in ajsonbody are replaced withNone, via a newreplace_nullinseam/null.py, sojson.dumpsemits them asnull.Without it a body carrying the sentinel raises
TypeError: Object of type Null is not JSON serializable.Params that are not a mapping — an already-built query string, or the pairs
of a
UrlSearchParams— are passed through to httpx untouched, so a callerwho serialized them already keeps control of the representation.
Why the query is set on the url
httpx would otherwise re-encode what the serializer produced. The two
disagree on arrays and nested objects, and on exactly two characters:
{"device_ids": []}?device_ids={"custom_metadata_has": {"k": "v"}}custom_metadata_has=%7B%27k%27%3A+%27v%27%7Dcustom_metadata_has.k=v{"search": "a *~ b"}search=a+%2A~+bsearch=a+*%7E+bThe character differences are cosmetic — the API decodes both to the same
pairs — but the array and nested-object rows are not: the empty array
currently disappears from the query entirely, and a nested object currently
goes out as the repr of a Python dict.
The query survives verbatim, including through relative-url merging against
base_url, because httpx carries it inraw_path.Typing nullable params
nullableTyperenders a param the API documents as nullable asUnion[<type>, Null], so it accepts the sentinel:A param that is merely optional deliberately does not accept it. Optional
params are omitted by passing
None, where sending null would unset a valueinstead, so accepting the sentinel everywhere would invite exactly the mistake
it exists to prevent.
This covers 49 params across 20 route modules — 43
Union[str, Null], 4Union[float, Null], 2Union[int, Null]— and thefrom ..null import Nullimport is emitted only for modules that need it.
Scope
Under
preferredMethoda route only getsGET/DELETEwhen all of itsparams are simple, since
getPreferredMethodfalls back toPOSTforcomplex ones. So generated routes exercise the simple path, and the array and
nested-object paths are reached through direct
seam.client.get(...)calls.Tests
test/search_params_test.pyasserts on the exact bytes the SDK puts on thewire via the recording server: standard serialization, no re-encoding,
Noneomitted,
NULLsent empty, no?when there are no params, every verb,non-mapping pass-through,
UnserializableParamErrorraised before therequest,
NULLin a JSON body, and a generated route.test/nullable_param_test.pycovers the three states end to end —NULLsentas
null,Noneomitted, a real value sent — on both a nullable string and anullable number. Because
mypyruns overtest/in CI, these also fail theLint job if a nullable param ever loses its
Nulltype.The recording server now records
method, the rawtarget, andpath/querysplit apart, and answers every verb rather than just
GETandPOST—/devices/updateisPATCH, which the old handler answered with a 501.159 passing, up from 141 on beta.
pylint10.00/10,black,mypy,rstcheck,tsc,eslint, andprettierclean;npm run generateisidempotent.
The end-to-end round trip through the API's parser — e.g.
seam.client.get("/devices/list", params={"device_ids": []})returning zerodevices — still needs the
@seamapi/fake-seam-connect2.x bump, since thepinned 1.86.0 has no parser and rejects
device_ids=. That bump belongs withthe
semanticMethodchange, where it is required.README
Adds a Setting a Param to Null section covering the omit-versus-null
distinction,
NoneversusNULL, why null is never the default,Nullasthe exported type, and which params accept the sentinel.