Skip to content

Commit ac1789b

Browse files
razor-xclaude
andauthored
feat: implement the URL search params serialization standard (#463)
* feat: implement the URL search params serialization standard 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 * feat: apply the URL search params standard and type nullable params 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 * feat: add _strict=true to the URL search params serializer 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 * refactor: drop comments that narrate instead of constrain 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 * docs: align the README sections with the other SDKs 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 * refactor: sort search params by stable byte order 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d9b54ea commit ac1789b

36 files changed

Lines changed: 2170 additions & 123 deletions

‎README.md‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,39 @@ $seam->locks->unlock_door(
175175
);
176176
```
177177

178+
### Setting a param to null
179+
180+
The Seam API distinguishes three states for an updatable param:
181+
omitted (leave the stored value unchanged), null (unset the stored value),
182+
and a value (set it).
183+
184+
PHP's `null` means omitted.
185+
The SDK removes `null` params from the request entirely,
186+
so passing `null` never unsets a value.
187+
To unset a value, pass the `Seam\NullValue::NULL` sentinel,
188+
which the SDK sends as JSON `null` in request bodies
189+
and as an empty value in query strings:
190+
191+
```php
192+
use Seam\NullValue;
193+
194+
// Leaves the name unchanged.
195+
$seam->devices->update(device_id: $device_id, name: null);
196+
197+
// Unsets the name.
198+
$seam->devices->update(device_id: $device_id, name: NullValue::NULL);
199+
```
200+
201+
The other Seam SDKs spell the sentinel `NULL` and its type `Null`,
202+
but those names are reserved in PHP, so both live on one enum:
203+
`Seam\NullValue` is the type, and its single case `NullValue::NULL`
204+
is the value to pass.
205+
206+
Only pass `NullValue::NULL` for params the API documents as nullable.
207+
Generated methods type nullable params as a union with the sentinel,
208+
e.g. `string|NullValue|null`, so passing it anywhere else fails with a
209+
`TypeError`.
210+
178211
### Pagination
179212

180213
Some Seam API endpoints that return lists of resources support pagination.
@@ -388,6 +421,69 @@ $client = new GuzzleHttp\Client([
388421
$seam = Seam\Seam::from_client($client);
389422
```
390423

424+
#### Serializing URL search params
425+
426+
The Seam API parses URL search params as complex types.
427+
If you call it with your own HTTP client,
428+
`Seam\StrictUrlSearchParamsSerializer` is exported for that purpose.
429+
The `_strict=true` param is added to any non-empty query
430+
so the Seam API uses strict, schema-aware parsing.
431+
A query with no serializable params remains empty.
432+
433+
```php
434+
use Seam\StrictUrlSearchParamsSerializer;
435+
436+
$query = StrictUrlSearchParamsSerializer::serialize([
437+
"device_ids" => ["device1", "device2"],
438+
]);
439+
440+
$response = file_get_contents(
441+
"https://connect.getseam.com/devices/list?{$query}",
442+
context: stream_context_create([
443+
"http" => ["header" => "Authorization: Bearer your-api-key"],
444+
]),
445+
);
446+
```
447+
448+
The serialization defines the name and value of each search param,
449+
where every value is a string.
450+
`Seam\UrlSearchParams` holds those pairs and renders the query string,
451+
as [URLSearchParams] does for the [reference implementation]:
452+
453+
```php
454+
use Seam\StrictUrlSearchParamsSerializer;
455+
use Seam\UrlSearchParams;
456+
457+
$search_params = new UrlSearchParams();
458+
459+
StrictUrlSearchParamsSerializer::update($search_params, [
460+
"device_ids" => ["device1", "device2"],
461+
]);
462+
463+
iterator_to_array($search_params);
464+
// => [["device_ids", "device1"], ["device_ids", "device2"], ["_strict", "true"]]
465+
466+
(string) $search_params;
467+
// => 'device_ids=device1&device_ids=device2&_strict=true'
468+
```
469+
470+
Pass either the query string or the pairs to your HTTP client.
471+
A client may percent-encode a few characters differently than
472+
`URLSearchParams` does, e.g. Guzzle escapes `*` and leaves `~` unescaped,
473+
which the Seam API reads as the same params either way.
474+
475+
A param set to `null` is omitted,
476+
while a param set to `NullValue::NULL` is serialized to an empty value,
477+
which the Seam API reads as null,
478+
as described in [Setting a param to null](#setting-a-param-to-null).
479+
A param that cannot be represented raises a `Seam\UnserializableParamError`.
480+
481+
The Seam API parses these params with the corresponding [parser].
482+
483+
[URLSearchParams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
484+
[reference implementation]: https://github.com/seamapi/url-search-params-serializer
485+
[parser]: https://github.com/seamapi/url-search-params-parser
486+
391487
#### Errors
392488

393489
Every exception the SDK raises implements `Seam\SeamException`, so it can be

‎codegen/layouts/seam-client.hbs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use {{this}};
99
use GuzzleHttp\Client;
1010
use GuzzleHttp\ClientInterface;
1111
use Seam\Http\ClientFactory;
12+
use Seam\Http\SerializingClient;
1213

1314
/**
1415
* Client for the Seam API.
@@ -33,6 +34,10 @@ class Seam
3334

3435
/**
3536
* The Guzzle client this instance makes its requests with.
37+
*
38+
* Query params given as a map and NullValue::NULL sentinels in JSON
39+
* bodies are serialized with the Seam standard before the request goes
40+
* out; see Seam\Http\SerializingClient.
3641
*/
3742
public ClientInterface $client;
3843

@@ -78,13 +83,13 @@ class Seam
7883
"timeout" => $timeout,
7984
]);
8085

81-
$this->client = $client ?? ClientFactory::create(
86+
$this->client = SerializingClient::wrap($client ?? ClientFactory::create(
8287
Options::get_endpoint($endpoint),
8388
Auth::get_auth_headers($api_key, $personal_access_token, $workspace_id),
8489
$guzzle_options,
8590
$retries,
8691
$timeout
87-
);
92+
));
8893

8994
{{#each parentClients}}
9095
$this->{{namespace}} = new {{clientName}}Client($this->client, $this->defaults);

‎codegen/lib/layouts/route.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010

1111
const clientInterfaceClass = 'GuzzleHttp\\ClientInterface'
1212
const bodyClass = 'Seam\\Http\\Body'
13+
const nullValueClass = 'Seam\\NullValue'
1314
const resolveActionAttemptClass = 'Seam\\Http\\ResolveActionAttempt'
1415
const resourcesNamespace = 'Seam\\Resources'
1516

@@ -74,6 +75,21 @@ const onResponseParameter = {
7475
required: false,
7576
}
7677

78+
// A nullable param accepts the NullValue::NULL sentinel, which sends an
79+
// explicit null to unset a value. A merely optional param does not: optional
80+
// means omit by passing null, and sending null there would unset a value
81+
// instead. Optionality composes with nullability rather than replacing it.
82+
const getParameterPhpType = (parameter: {
83+
type: string
84+
isOptional: boolean
85+
isNullable: boolean
86+
}): string => {
87+
const { type, isOptional, isNullable } = parameter
88+
if (type === 'mixed') return type
89+
if (isNullable) return `${type}|NullValue${isOptional ? '|null' : ''}`
90+
return `${isOptional ? '?' : ''}${type}`
91+
}
92+
7793
const getMethodLayoutContext = (
7894
method: PhpClientMethod,
7995
): MethodLayoutContext => {
@@ -96,7 +112,7 @@ const getMethodLayoutContext = (
96112
const signatureParams = sortedParameters
97113
.map(
98114
(p) =>
99-
`${(p.isNullable || p.isOptional) && p.type !== 'mixed' ? '?' : ''}${p.type} $${p.name}${p.isOptional ? ' = null' : ''}`,
115+
`${getParameterPhpType(p)} $${p.name}${p.isOptional ? ' = null' : ''}`,
100116
)
101117
.concat(
102118
usesActionAttempt
@@ -109,7 +125,7 @@ const getMethodLayoutContext = (
109125
const documentedEndpointParameters = sortedParameters.map(
110126
({ name, type, description, isOptional, isNullable }) => ({
111127
name,
112-
type,
128+
type: isNullable && type !== 'mixed' ? `${type}|NullValue` : type,
113129
description,
114130
required: !isOptional,
115131
isOptional,
@@ -165,9 +181,16 @@ const getUseStatements = (client: PhpClient): string[] => {
165181
// Void endpoints never read the response, so they do not decode it.
166182
const readsBody = client.methods.some((m) => m.returnResource !== '')
167183

184+
// Only nullable params reference the null sentinel type; importing it
185+
// elsewhere would trip the unused-import lint.
186+
const usesNullValue = client.methods.some((m) =>
187+
m.parameters.some((p) => p.isNullable && p.type !== 'mixed'),
188+
)
189+
168190
return [
169191
clientInterfaceClass,
170192
...(readsBody ? [bodyClass] : []),
193+
...(usesNullValue ? [nullValueClass] : []),
171194
...(usesActionAttempt ? [resolveActionAttemptClass] : []),
172195
...[...resourceNames].map((name) => `${resourcesNamespace}\\${name}`),
173196
].sort((a, b) => a.localeCompare(b))

‎src/Http/SerializingClient.php‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
namespace Seam\Http;
4+
5+
use GuzzleHttp\ClientInterface;
6+
use GuzzleHttp\Promise\PromiseInterface;
7+
use Psr\Http\Message\RequestInterface;
8+
use Psr\Http\Message\ResponseInterface;
9+
use Seam\NullValue;
10+
use Seam\StrictUrlSearchParamsSerializer;
11+
12+
/**
13+
* Applies the Seam serialization standard to every request the wrapped
14+
* client sends.
15+
*
16+
* Query params given as a map are serialized with
17+
* StrictUrlSearchParamsSerializer and handed to Guzzle as a raw query
18+
* string, because Guzzle's own encoder follows different rules: it escapes
19+
* `*`, leaves `~` alone, and drops an empty array instead of sending
20+
* `name=`. A query already given as a string is a representation the
21+
* caller chose, so it passes through untouched.
22+
*
23+
* NullValue::NULL sentinels in a JSON body become JSON null, so the same
24+
* sentinel works whether a route sends a query string or a body.
25+
*/
26+
final class SerializingClient implements ClientInterface
27+
{
28+
private function __construct(private ClientInterface $client) {}
29+
30+
/**
31+
* Wraps a client, or returns it unchanged when it is already wrapped.
32+
*/
33+
public static function wrap(ClientInterface $client): self
34+
{
35+
return $client instanceof self ? $client : new self($client);
36+
}
37+
38+
#[\Override]
39+
public function send(
40+
RequestInterface $request,
41+
array $options = [],
42+
): ResponseInterface {
43+
return $this->client->send($request, self::serialize_options($options));
44+
}
45+
46+
#[\Override]
47+
public function sendAsync(
48+
RequestInterface $request,
49+
array $options = [],
50+
): PromiseInterface {
51+
return $this->client->sendAsync(
52+
$request,
53+
self::serialize_options($options),
54+
);
55+
}
56+
57+
#[\Override]
58+
public function request(
59+
string $method,
60+
$uri = "",
61+
array $options = [],
62+
): ResponseInterface {
63+
return $this->client->request(
64+
$method,
65+
$uri,
66+
self::serialize_options($options),
67+
);
68+
}
69+
70+
#[\Override]
71+
public function requestAsync(
72+
string $method,
73+
$uri = "",
74+
array $options = [],
75+
): PromiseInterface {
76+
return $this->client->requestAsync(
77+
$method,
78+
$uri,
79+
self::serialize_options($options),
80+
);
81+
}
82+
83+
#[\Override]
84+
public function getConfig(?string $option = null)
85+
{
86+
return $this->client->getConfig($option);
87+
}
88+
89+
/**
90+
* @param array<string, mixed> $options
91+
* @return array<string, mixed>
92+
*/
93+
private static function serialize_options(array $options): array
94+
{
95+
if (
96+
isset($options["query"]) &&
97+
($options["query"] instanceof \stdClass ||
98+
is_array($options["query"]))
99+
) {
100+
$serialized = StrictUrlSearchParamsSerializer::serialize(
101+
$options["query"],
102+
);
103+
104+
if ($serialized === "") {
105+
unset($options["query"]);
106+
} else {
107+
$options["query"] = $serialized;
108+
}
109+
}
110+
111+
if (array_key_exists("json", $options)) {
112+
$options["json"] = NullValue::replace($options["json"]);
113+
}
114+
115+
return $options;
116+
}
117+
}

‎src/NullValue.php‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
namespace Seam;
4+
5+
/**
6+
* The explicit null sentinel used by request params.
7+
*
8+
* PHP has a single absence value, null, but the Seam API distinguishes an
9+
* omitted param from a param explicitly set to null. For example, in an
10+
* update request, an omitted param leaves the current value unchanged,
11+
* while a null param unsets the current value.
12+
*
13+
* Since sending null is rarely intended and unsetting a value cannot be
14+
* undone, null means the safe option of omitting the param. Sending null is
15+
* explicit and always spelled NullValue::NULL:
16+
*
17+
* ```php
18+
* use Seam\NullValue;
19+
* use Seam\UrlSearchParamsSerializer;
20+
*
21+
* UrlSearchParamsSerializer::serialize(["name" => NullValue::NULL, "limit" => 20]);
22+
* // => 'limit=20&name='
23+
*
24+
* UrlSearchParamsSerializer::serialize(["name" => null, "limit" => 20]);
25+
* // => 'limit=20'
26+
* ```
27+
*
28+
* Use it wherever the Seam API documents null as a meaningful value, e.g.,
29+
* to unset a value in an update request, or to filter by an unset value.
30+
*/
31+
enum NullValue
32+
{
33+
/**
34+
* Sentinel for a param explicitly set to null.
35+
*/
36+
case NULL;
37+
38+
/**
39+
* Returns a copy of a value with every NullValue::NULL replaced by null.
40+
*
41+
* The sentinel only distinguishes an explicit null from an omitted param
42+
* within this SDK. Once a request body is being serialized, the param is
43+
* known to be present, so the sentinel becomes the null that JSON has.
44+
*
45+
* Recurses into arrays and stdClass objects without mutating them; every
46+
* other value is returned unchanged.
47+
*/
48+
public static function replace(mixed $value): mixed
49+
{
50+
if ($value instanceof self) {
51+
return null;
52+
}
53+
54+
if (is_array($value)) {
55+
return array_map(self::replace(...), $value);
56+
}
57+
58+
if ($value instanceof \stdClass) {
59+
return (object) array_map(
60+
self::replace(...),
61+
get_object_vars($value),
62+
);
63+
}
64+
65+
return $value;
66+
}
67+
}

0 commit comments

Comments
 (0)