Skip to content

feat: implement the URL search params serialization standard - #597

Closed
razor-x wants to merge 11 commits into
betafrom
claude/python-sdk-ts-url-serializer-xukp7s
Closed

feat: implement the URL search params serialization standard#597
razor-x wants to merge 11 commits into
betafrom
claude/python-sdk-ts-url-serializer-xukp7s

Conversation

@razor-x

@razor-x razor-x commented Aug 5, 2026

Copy link
Copy Markdown
Member

Implements the Seam URL search params serialization standard in Python and puts it to work, so each endpoint is called with its semantic method.

Serialization

A port of the reference implementation, producing byte-identical output:

  • values encoded with the application/x-www-form-urlencoded serializer, which differs from urllib in its treatment of * and ~
  • params sorted by name, compared by UTF-16 code unit, so 😀 sorts before
  • floats formatted with the ECMAScript Number::toString algorithm, which differs from repr for integral floats and around the exponent-notation thresholds

Verified against the reference implementation running under Node: 116 hand-built cases covering every rule in its README, 4,000 randomized nested-param cases, and 60,000 floats including every power of ten from 1e-330 to 1e308, denormals, and random binary64 bit patterns. All match. Separately, 22 cases round-trip through the parser the API uses in strict mode, which is documented as a true inverse of the serializer.

Omitted params and null params

The API distinguishes an omitted param from one explicitly set to null, and Python has a single absence value. Since sending null is rarely intended and unsetting a value cannot be undone, None keeps meaning omit and seam.NULL sends null. This adds the capability without changing the behavior of any existing call.

It also closes a gap: access_grants.list documents null as a filter for Access Grants with no access_grant_key, and passing None dropped the filter and returned every Access Grant.

Now that blueprint reports isNullable for request parameters, only the params the API documents as nullable accept NULL, and a type checker rejects it elsewhere:

seam.access_grants.list(access_grant_key=NULL)       # ok
seam.devices.update(device_id="d", is_managed=NULL)  # error: "Null" is not assignable to "bool | None"

Semantic methods

Route methods are generated against the endpoint's semanticMethod rather than its preferredMethod. The preferred method falls back to POST when an endpoint takes array or object params, because those had no unambiguous query string representation — which is what this serialization provides.

The 132 endpoints whose semantics are a GET now send params in the query string; every other method, DELETE included, reads them from a JSON body. Confirmed against the fake server, where a DELETE with its params in the query string reports the record as not found while the same params in a body succeed.

The query is set on the URL rather than handed to httpx as params, because httpx re-encodes a query string it is given: it escapes * and unescapes ~.

Retries

This settles the retries option. httpx-retries treats GET, PUT and DELETE as retryable and POST as not, so the option had no effect while every request was a POST. It now reaches API requests without the SDK exposing the HTTP method, so the xfail markers are removed from the two tests that recorded that.

Two consequences worth knowing

  • A query string carries no types. A wrong-typed value is no longer rejected as invalid input when it can be read as the expected type: devices.get(device_id=4242) used to 400 because JSON preserved the int, and now reads as the id "4242" and reports not found. The test that covered this was retargeted at a missing required param, which still 400s.
  • Retries now depend on an endpoint's verb. GET, PUT and DELETE endpoints retry under the default policy; POST and PATCH do not. Worth deciding whether DEFAULT_RETRIES should cover them so behavior does not vary by endpoint.

Also

@seamapi/types moves one release, to 1.984.0, the first that gives submit_args a type — blueprint 1.4.0 turns an untyped property from a warning into an error. It adds between to events.list. @seamapi/fake-seam-connect moves to 2.0.3, the first release carrying the parser.

One gap: custom_metadata_has object filtering is unverified end to end, because the fake server ignores both custom_metadata on update and that filter, identically over POST and GET. The wire format is covered by the parser round-trip.

Checks

154 tests pass, pylint 10.00/10, black, rstcheck, mypy seam test clean across 113 files, and codegen reruns with zero drift.

@razor-x
razor-x requested a review from a team as a code owner August 5, 2026 18:07
@razor-x
razor-x force-pushed the claude/python-sdk-ts-url-serializer-xukp7s branch 3 times, most recently from 1f842df to 946a7d0 Compare August 13, 2026 04:25
@razor-x
razor-x changed the base branch from main to beta August 13, 2026 04:25
claude added 10 commits August 13, 2026 07:41
Port @seamapi/url-search-params-serializer to Python so the SDK can
serialize objects to URL search params for HTTP GET requests.

Output is byte-for-byte identical to the reference implementation:

- Values are encoded with the application/x-www-form-urlencoded
  serializer, which differs from urllib in its treatment of "*" and "~".
- Params are sorted by name, compared by UTF-16 code unit.
- Floats are formatted using the ECMAScript Number::toString algorithm,
  which differs from repr for integral floats and around the exponent
  notation thresholds.

Python has no undefined, so UNDEFINED is provided as the sentinel for a
removed param, while None serializes to an empty value as null does.
Temporal.Instant and Date both map to datetime, where a naive datetime is
interpreted as UTC and microseconds are truncated to millisecond
precision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The Seam API distinguishes an omitted param from a param explicitly set to
null: in an update request, an omitted param leaves the current value
unchanged while a null param unsets it, and some endpoints accept null as a
meaningful filter value. Python has a single absence value, so route methods
omitted both cases and there was no way to send null. For example,
access_grants.list documents null as a filter for Access Grants without an
access_grant_key, but passing None dropped the filter and returned every
Access Grant.

Add the NULL sentinel for a param explicitly set to null. Since sending null
is rarely intended and unsetting a value cannot be undone, None keeps meaning
the safe option of omitting the param, so this adds the capability without
changing the behavior of any existing call.

The existing generated route methods need no change: they already omit params
set to None, and the client now replaces any remaining NULL sentinel with None
so that json serializes it to null. NULL works at any depth, e.g., to clear a
single key of an object param.

Bind the URL search params serializer to the same convention, replacing its
UNDEFINED sentinel: None is JavaScript undefined and is removed, while NULL is
JavaScript null and serializes to an empty value.

NULL is typed as Any so it may be passed to any param without a type error.
Once blueprint exposes isNullable on Parameter, codegen can type nullable
params precisely instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
Blueprint now reports isNullable for request parameters, so codegen can
distinguish the params the Seam API documents as nullable from the rest.
Type a nullable param as Union[T, Null] so it accepts the NULL sentinel, and
leave every other param as it was.

This makes NULL checkable. Previously NULL had to be typed as Any to be
passed anywhere, which meant a type checker could not report sending null to
a param that does not accept it. NULL is now typed as Null, so passing it to
a non-nullable param such as devices.update(is_managed=...) is an error while
access_grants.list(access_grant_key=NULL) is accepted.

Reading isNullable requires blueprint 1.4.0 or later, which turns an
untyped property from a warning into an error. The pinned types release
leaves submit_args untyped for /seam/connect_webview/v1/submit, so generation
fails against it; bump types to the next release, which defines that type and
adds the between parameter to events.list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The fake Seam Connect server now parses URL search params with
@seamapi/url-search-params-parser, the inverse of the serialization standard
this SDK implements, and fixes the status codes it returns for
unauthenticated requests.

The previous release did not have the parser, so it read the serialization of
the empty array as an array containing the empty string and then failed to
serialize that back into its pagination links, answering a request for
device_ids= with a 500. It now reads that as the empty array.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The example was carried over from the reference implementation, which is
JavaScript, leaving a camelCase parameter name in a Python test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
Generate route methods against the semantic method of the endpoint instead of
the preferred method. The preferred method falls back to POST when an endpoint
takes array or object parameters, because those have no unambiguous query
string representation. This SDK now implements the Seam URL search params
serialization standard, so they do, and the fake Seam Connect server parses
that serialization with the standard's parser.

The 132 endpoints whose semantics are those of a GET now send their params in
the query string. Every other method, DELETE included, reads them from a JSON
request body, so only GET moves. niquests omits json from its delete
signature, so the client provides one that accepts it.

Search params are serialized to the standard rather than left to niquests,
which implements a different one. Requests made against an endpoint directly,
in tests and when polling an action attempt, use its semantic method too.

Two consequences worth knowing:

- A query string carries no types, so a value of the wrong type is no longer
  rejected as invalid input when it can be read as the expected type, e.g.
  a device_id of 4242 is now read as the id "4242" and reported as not found.
- GET is idempotent, so urllib3 retries it under its default policy, where
  it never retried a POST.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
Serving each endpoint over its semantic method also settles the retries
option. httpx-retries treats GET, PUT and DELETE as retryable and POST as
not, so the option had no effect while every request was a POST. It now
reaches API requests without the SDK exposing the HTTP method, so remove the
xfail markers from the tests that record that.

Set the serialized query on the URL rather than handing it to httpx as
params, because httpx re-encodes a query string it is given: it escapes "*"
and unescapes "~", neither of which the serialization standard does.

The README documented how the serialization works, which is an internal
detail of the SDK. Say instead that the serializer is exported for callers
using their own HTTP client, and reference both the reference implementation
and the parser the Seam API uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The serialization defines the name and value of each search param, where
every value is a string, and leaves rendering the query string to
URLSearchParams. UrlSearchParams is that layer here, so hand the query it
renders to httpx as params, the way the reference implementation hands it to
axios as a paramsSerializer.

httpx re-encodes the query it is given, escaping "*" and unescaping "~", so
the earlier commit set the query on the URL to keep those bytes. That was
unnecessary: re-encoding changes no param. Across the 3141 query strings of
the randomized corpus, 1714 differ from ours in bytes and none differ in
decoded name-value pairs.

The README said to avoid a client's params for that reason, which was wrong.
Describe the pairs and the layer that renders them instead.

Narrow the null module to NULL and the Null type it instantiates. Whether a
value is the sentinel, and replacing it for JSON serialization, are internal
concerns of this SDK: a caller building their own request body writes None,
which already serializes to null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The package __init__ is the public surface, so the helpers the SDK uses to
recognize the sentinel and replace it for JSON serialization need no
underscore to be internal: not exporting them is enough. Export NULL to pass
and Null to annotate, since generated route methods type a nullable param as
Union[T, Null].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The lock resolved pylint 3.2.2, whose astroid cannot resolve
collections.abc under Python 3.14. The lint job runs on 3.14, so importing
it failed the run.

Also drive the invalid input error from a value that cannot be read as the
declared type. The wrong-typed id it used no longer reaches validation:
/devices/list is served over its semantic method, GET, and a query string
carries no types, so 4242 is read as the number it declares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
@razor-x
razor-x force-pushed the claude/python-sdk-ts-url-serializer-xukp7s branch from 23e512a to 528a93f Compare August 13, 2026 07:54
It sits with null.py, route.py and the other modules of the SDK rather than
under utils, which holds the two helpers that back generated resources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
@razor-x razor-x closed this Aug 13, 2026
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.

2 participants