Skip to content

Commit d75e355

Browse files
committed
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
1 parent 351c862 commit d75e355

6 files changed

Lines changed: 130 additions & 19 deletions

‎README.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -400,17 +400,22 @@ query strings with
400400
[`@seamapi/url-search-params-parser`](https://github.com/seamapi/url-search-params-parser).
401401

402402
The serializer is exported for callers building requests with their own
403-
HTTP client:
403+
HTTP client. Use `StrictUrlSearchParamsSerializer` when calling the Seam
404+
API: it adds `_strict=true` to any non-empty query, which tells the API to
405+
use strict, schema-aware parsing, and is what the SDK's own requests use. A
406+
query with no serializable params remains empty.
407+
`UrlSearchParamsSerializer` is the same serialization without the flag — a
408+
pure implementation of the standard.
404409

405410
```php
406-
use Seam\UrlSearchParamsSerializer;
411+
use Seam\StrictUrlSearchParamsSerializer;
407412

408-
$query = UrlSearchParamsSerializer::serialize([
413+
$query = StrictUrlSearchParamsSerializer::serialize([
409414
"device_ids" => ["device1", "device2"],
410415
"custom_metadata_has" => ["tag" => "front"],
411416
"limit" => 20,
412417
]);
413-
// => 'custom_metadata_has.tag=front&device_ids=device1&device_ids=device2&limit=20'
418+
// => 'custom_metadata_has.tag=front&device_ids=device1&device_ids=device2&limit=20&_strict=true'
414419
```
415420

416421
A param that cannot be represented in the standard, such as `NAN` or a key

‎src/Http/SerializingClient.php‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,18 @@
77
use Psr\Http\Message\RequestInterface;
88
use Psr\Http\Message\ResponseInterface;
99
use Seam\NullValue;
10-
use Seam\UrlSearchParamsSerializer;
10+
use Seam\StrictUrlSearchParamsSerializer;
1111

1212
/**
1313
* Applies the Seam serialization standard to every request the wrapped
1414
* client sends.
1515
*
16-
* Query params given as a map are serialized with UrlSearchParamsSerializer
17-
* and handed to Guzzle as a raw query string, because Guzzle's own encoder
18-
* follows different rules: it escapes `*`, leaves `~` alone, and drops an
19-
* empty array instead of sending `name=`. A query already given as a string
20-
* is a representation the caller chose, so it passes through untouched.
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.
2122
*
2223
* NullValue::NULL sentinels in a JSON body become JSON null, so the same
2324
* sentinel works whether a route sends a query string or a body.
@@ -96,7 +97,7 @@ private static function serialize_options(array $options): array
9697
($options["query"] instanceof \stdClass ||
9798
is_array($options["query"]))
9899
) {
99-
$serialized = UrlSearchParamsSerializer::serialize(
100+
$serialized = StrictUrlSearchParamsSerializer::serialize(
100101
$options["query"],
101102
);
102103

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace Seam;
4+
5+
/**
6+
* Serializes params for the Seam API: the URL search params standard plus
7+
* `_strict=true` appended to any non-empty query, which tells the API to
8+
* use strict, schema-aware parsing. A query with no serializable params
9+
* remains empty.
10+
*
11+
* The strict flag is Seam API behavior, not part of the serialization
12+
* standard, so it lives here rather than in UrlSearchParamsSerializer,
13+
* which stays a pure implementation of the standard.
14+
*/
15+
final class StrictUrlSearchParamsSerializer
16+
{
17+
/**
18+
* Serializes params to a URL search param query string with strict API
19+
* validation enabled, without a leading `?`.
20+
*
21+
* @param array<string, mixed>|\stdClass $params
22+
*
23+
* @throws UnserializableParamError If any param could not be serialized
24+
*/
25+
public static function serialize(array|\stdClass $params): string
26+
{
27+
$search_params = new UrlSearchParams();
28+
self::update($search_params, $params);
29+
30+
return $search_params->to_string();
31+
}
32+
33+
/**
34+
* Updates existing URL search params with serialized params and strict
35+
* API validation enabled.
36+
*
37+
* @param array<string, mixed>|\stdClass $params
38+
*
39+
* @throws UnserializableParamError If any param could not be serialized
40+
*/
41+
public static function update(
42+
UrlSearchParams $search_params,
43+
array|\stdClass $params,
44+
): void {
45+
UrlSearchParamsSerializer::update($search_params, $params);
46+
47+
if (count($search_params) > 0) {
48+
// Replaced rather than repeated, and appended after the sort so
49+
// it always sits last.
50+
$search_params->delete("_strict");
51+
$search_params->append("_strict", "true");
52+
}
53+
}
54+
}

‎tests/HeadersTest.php‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ public function testSendsDefaultHeaders(): void
3535
$request = $recorder->request();
3636

3737
$this->assertSame("/devices/get", $request->getUri()->getPath());
38-
$this->assertSame("device_id=d1", $request->getUri()->getQuery());
38+
$this->assertSame(
39+
"device_id=d1&_strict=true",
40+
$request->getUri()->getQuery(),
41+
);
3942

4043
$this->assertSame(
4144
"Bearer seam_apikey_token",

‎tests/SearchParamsTest.php‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ public function testClientSerializesSearchParams(): void
4747
"&custom_metadata_has.tag=front" .
4848
"&device_ids=device1" .
4949
"&device_ids=device2" .
50-
"&limit=20",
50+
"&limit=20" .
51+
"&_strict=true",
5152
$recording->request()->getUri()->getQuery(),
5253
);
5354
$this->assertSame("GET", $recording->request()->getMethod());
@@ -74,7 +75,7 @@ public function testClientDoesNotReencodeTheSerializedSearchParams(): void
7475
]);
7576

7677
$this->assertSame(
77-
"search=a+*%7E+b",
78+
"search=a+*%7E+b&_strict=true",
7879
$recording->request()->getUri()->getQuery(),
7980
);
8081
}
@@ -92,7 +93,7 @@ public function testClientSerializesEmptyArraysToAnEmptyValue(): void
9293
// Not omitted and not a bare name: the parser reads `device_ids=`
9394
// as the empty array, while no param at all means unfiltered.
9495
$this->assertSame(
95-
"device_ids=",
96+
"device_ids=&_strict=true",
9697
$recording->request()->getUri()->getQuery(),
9798
);
9899
}
@@ -108,7 +109,7 @@ public function testClientOmitsSearchParamsSetToNull(): void
108109
]);
109110

110111
$this->assertSame(
111-
"limit=20",
112+
"limit=20&_strict=true",
112113
$recording->request()->getUri()->getQuery(),
113114
);
114115
}
@@ -124,7 +125,7 @@ public function testClientSerializesSearchParamsSetToNullValue(): void
124125
]);
125126

126127
$this->assertSame(
127-
"limit=20&search=",
128+
"limit=20&search=&_strict=true",
128129
$recording->request()->getUri()->getQuery(),
129130
);
130131
}
@@ -168,7 +169,7 @@ public function testClientSerializesSearchParamsOfEveryVerb(): void
168169
foreach ($verbs as $index => $verb) {
169170
$this->assertSame($verb, $recording->request($index)->getMethod());
170171
$this->assertSame(
171-
"sync=true",
172+
"sync=true&_strict=true",
172173
$recording->request($index)->getUri()->getQuery(),
173174
);
174175
}
@@ -276,7 +277,7 @@ public function testClientSerializesTheSearchParamsOfAGeneratedRoute(): void
276277
$recording->request()->getUri()->getPath(),
277278
);
278279
$this->assertSame(
279-
"name=Front+Door",
280+
"name=Front+Door&_strict=true",
280281
$recording->request()->getUri()->getQuery(),
281282
);
282283
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Seam\StrictUrlSearchParamsSerializer;
9+
use Seam\UrlSearchParams;
10+
11+
final class StrictUrlSearchParamsSerializerTest extends TestCase
12+
{
13+
public function testAddsStrictToNonEmptyQueryStrings(): void
14+
{
15+
$this->assertSame("", StrictUrlSearchParamsSerializer::serialize([]));
16+
$this->assertSame(
17+
"foo=d&_strict=true",
18+
StrictUrlSearchParamsSerializer::serialize(["foo" => "d"]),
19+
);
20+
}
21+
22+
public function testAppendsStrictAfterTheSortedParams(): void
23+
{
24+
// `_strict` would sort between the uppercase and lowercase names;
25+
// it is appended after the sort so it always sits last.
26+
$this->assertSame(
27+
"B=2&a=1&_strict=true",
28+
StrictUrlSearchParamsSerializer::serialize(["a" => 1, "B" => 2]),
29+
);
30+
}
31+
32+
public function testReplacesACallerSuppliedStrictParam(): void
33+
{
34+
$this->assertSame(
35+
"_strict=true",
36+
StrictUrlSearchParamsSerializer::serialize(["_strict" => false]),
37+
);
38+
}
39+
40+
public function testUpdateCountsExistingParamsAsNonEmpty(): void
41+
{
42+
$search_params = new UrlSearchParams([["foo", "bar"]]);
43+
StrictUrlSearchParamsSerializer::update($search_params, []);
44+
45+
$this->assertSame("foo=bar&_strict=true", $search_params->to_string());
46+
}
47+
}

0 commit comments

Comments
 (0)