From a7a7b9c957675ad0b3784f8a30ffe7299b42a8a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:25:07 +0000 Subject: [PATCH 1/6] 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 Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2 --- README.md | 29 ++ src/NullValue.php | 70 +++ src/UnserializableParamError.php | 28 + src/UrlSearchParams.php | 301 +++++++++++ src/UrlSearchParamsSerializer.php | 357 +++++++++++++ tests/NullValueTest.php | 79 +++ tests/UrlSearchParamsSerializerTest.php | 661 ++++++++++++++++++++++++ 7 files changed, 1525 insertions(+) create mode 100644 src/NullValue.php create mode 100644 src/UnserializableParamError.php create mode 100644 src/UrlSearchParams.php create mode 100644 src/UrlSearchParamsSerializer.php create mode 100644 tests/NullValueTest.php create mode 100644 tests/UrlSearchParamsSerializerTest.php diff --git a/README.md b/README.md index 5366cad..03be50d 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,35 @@ $seam = new Seam\Seam(retries: 5); $seam = new Seam\Seam(retries: 0); ``` +#### URL search params serialization + +Requests with query params follow the [Seam URL search params serialization +standard](https://github.com/seamapi/url-search-params-serializer): nested +objects join keys with dots (`{"page": {"size": 10}}` becomes +`page.size=10`), arrays repeat the name (`ids=a&ids=b`), an empty array +serializes to an empty value (`ids=`), and values are encoded and sorted +exactly as JavaScript's `URLSearchParams` would. Servers can read such +query strings with +[`@seamapi/url-search-params-parser`](https://github.com/seamapi/url-search-params-parser). + +The serializer is exported for callers building requests with their own +HTTP client: + +```php +use Seam\UrlSearchParamsSerializer; + +$query = UrlSearchParamsSerializer::serialize([ + "device_ids" => ["device1", "device2"], + "custom_metadata_has" => ["tag" => "front"], + "limit" => 20, +]); +// => 'custom_metadata_has.tag=front&device_ids=device1&device_ids=device2&limit=20' +``` + +A param that cannot be represented in the standard, such as `NAN` or a key +containing a dot, raises `Seam\UnserializableParamError` before any request +is sent. + #### Using the Guzzle client `$seam->client` is the [Guzzle] client, already carrying the endpoint and diff --git a/src/NullValue.php b/src/NullValue.php new file mode 100644 index 0000000..56b11a2 --- /dev/null +++ b/src/NullValue.php @@ -0,0 +1,70 @@ + NullValue::NULL, "limit" => 20]); + * // => 'limit=20&name=' + * + * UrlSearchParamsSerializer::serialize(["name" => null, "limit" => 20]); + * // => 'limit=20' + * ``` + * + * Use it wherever the Seam API documents null as a meaningful value, e.g., + * to unset a value in an update request, or to filter by an unset value. + */ +enum NullValue +{ + /** + * Sentinel for a param explicitly set to null. + * + * An enum case cannot be instantiated or subclassed, so this is the only + * value of this type, and `$value instanceof NullValue` detects it. + */ + case NULL; + + /** + * Returns a copy of a value with every NullValue::NULL replaced by null. + * + * The sentinel only distinguishes an explicit null from an omitted param + * within this SDK. Once a request body is being serialized, the param is + * known to be present, so the sentinel becomes the null that JSON has. + * + * Recurses into arrays and stdClass objects without mutating them; every + * other value is returned unchanged. + */ + public static function replace(mixed $value): mixed + { + if ($value instanceof self) { + return null; + } + + if (is_array($value)) { + return array_map(self::replace(...), $value); + } + + if ($value instanceof \stdClass) { + return (object) array_map( + self::replace(...), + get_object_vars($value), + ); + } + + return $value; + } +} diff --git a/src/UnserializableParamError.php b/src/UnserializableParamError.php new file mode 100644 index 0000000..c127ca3 --- /dev/null +++ b/src/UnserializableParamError.php @@ -0,0 +1,28 @@ +name; + } +} diff --git a/src/UrlSearchParams.php b/src/UrlSearchParams.php new file mode 100644 index 0000000..6754a37 --- /dev/null +++ b/src/UrlSearchParams.php @@ -0,0 +1,301 @@ + + */ +class UrlSearchParams implements \Countable, \IteratorAggregate +{ + /** @var list */ + private array $pairs = []; + + /** + * @param string|array|list|null $init + * A query string, a map of names to values, or a list of + * name-value pairs + */ + public function __construct(string|array|null $init = null) + { + if ($init === null) { + return; + } + + if (is_string($init)) { + $query = str_starts_with($init, "?") ? substr($init, 1) : $init; + + foreach (explode("&", $query) as $pair) { + if ($pair === "") { + continue; + } + + $parts = explode("=", $pair, 2); + $this->pairs[] = [ + urldecode($parts[0]), + urldecode($parts[1] ?? ""), + ]; + } + + return; + } + + foreach ($init as $name => $value) { + if (is_array($value) && is_int($name)) { + $this->pairs[] = [(string) $value[0], (string) $value[1]]; + } else { + $this->pairs[] = [(string) $name, (string) $value]; + } + } + } + + /** + * Appends a name-value pair, keeping any existing pairs with this name. + */ + public function append(string $name, string $value): void + { + $this->pairs[] = [$name, $value]; + } + + /** + * Sets the value associated with a name. + * + * Replaces the first pair with this name and removes any others, so the + * pair keeps its position. Appends a new pair if no pair with this name + * exists. + */ + public function set(string $name, string $value): void + { + if (!$this->has($name)) { + $this->append($name, $value); + return; + } + + $pairs = []; + $is_set = false; + + foreach ($this->pairs as $pair) { + if ($pair[0] !== $name) { + $pairs[] = $pair; + } elseif (!$is_set) { + $pairs[] = [$name, $value]; + $is_set = true; + } + } + + $this->pairs = $pairs; + } + + /** + * Returns the value of the first pair with this name, or null if no pair + * with this name exists. + */ + public function get(string $name): ?string + { + foreach ($this->pairs as [$existing_name, $value]) { + if ($existing_name === $name) { + return $value; + } + } + + return null; + } + + /** + * Returns the values of all pairs with this name, in insertion order. + * + * @return list + */ + public function get_all(string $name): array + { + $values = []; + + foreach ($this->pairs as [$existing_name, $value]) { + if ($existing_name === $name) { + $values[] = $value; + } + } + + return $values; + } + + /** + * Returns whether a pair with this name exists. + */ + public function has(string $name): bool + { + foreach ($this->pairs as [$existing_name, $_]) { + if ($existing_name === $name) { + return true; + } + } + + return false; + } + + /** + * Removes all pairs with this name. + */ + public function delete(string $name): void + { + $this->pairs = array_values( + array_filter($this->pairs, fn(array $pair) => $pair[0] !== $name), + ); + } + + /** + * Sorts all pairs by name. + * + * Sorting is stable, so the relative order of pairs with the same name + * is preserved. Names are compared by UTF-16 code units to match the + * URLSearchParams.sort() specification + * (https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/sort). + */ + public function sort(): void + { + usort( + $this->pairs, + fn(array $a, array $b) => strcmp( + self::utf16_sort_key($a[0]), + self::utf16_sort_key($b[0]), + ), + ); + } + + /** + * Serializes all pairs to a query string, without a leading `?`. + * + * Every pair gets an `=`, including empty values, e.g. `name=`. + */ + public function to_string(): string + { + return implode( + "&", + array_map( + fn(array $pair) => self::encode_form_component($pair[0]) . + "=" . + self::encode_form_component($pair[1]), + $this->pairs, + ), + ); + } + + public function __toString(): string + { + return $this->to_string(); + } + + #[\Override] + public function count(): int + { + return count($this->pairs); + } + + /** + * @return \ArrayIterator, array{string, string}> + */ + #[\Override] + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->pairs); + } + + /** + * Percent-encodes a string with the WHATWG + * application/x-www-form-urlencoded serializer, applied to the UTF-8 + * bytes of the string. + * + * The safe set is not the RFC 3986 unreserved set, so neither urlencode + * nor rawurlencode produces it: `*` is emitted literally and `~` is + * escaped, the exact opposite of both. + */ + private static function encode_form_component(string $value): string + { + $encoded = ""; + $length = strlen($value); + + for ($i = 0; $i < $length; $i++) { + $character = $value[$i]; + $byte = ord($character); + + $is_safe = + ($byte >= 0x30 && $byte <= 0x39) || + ($byte >= 0x41 && $byte <= 0x5a) || + ($byte >= 0x61 && $byte <= 0x7a) || + $character === "*" || + $character === "-" || + $character === "." || + $character === "_"; + + if ($is_safe) { + $encoded .= $character; + } elseif ($character === " ") { + $encoded .= "+"; + } else { + $encoded .= sprintf("%%%02X", $byte); + } + } + + return $encoded; + } + + /** + * Converts a UTF-8 name to its UTF-16 big-endian encoding, so a byte + * comparison of the keys orders names by UTF-16 code unit. + * + * This is not code-point order and not UTF-8 byte order: both would sort + * anything above the Basic Multilingual Plane after U+E000 to U+FFFF, + * while surrogate pairs (0xD800 to 0xDFFF) sort below them. + */ + private static function utf16_sort_key(string $name): string + { + $key = ""; + $length = strlen($name); + $i = 0; + + while ($i < $length) { + $byte = ord($name[$i]); + + if ($byte < 0x80) { + $code_point = $byte; + $i += 1; + } elseif (($byte & 0xe0) === 0xc0) { + $code_point = + (($byte & 0x1f) << 6) | (ord($name[$i + 1]) & 0x3f); + $i += 2; + } elseif (($byte & 0xf0) === 0xe0) { + $code_point = + (($byte & 0x0f) << 12) | + ((ord($name[$i + 1]) & 0x3f) << 6) | + (ord($name[$i + 2]) & 0x3f); + $i += 3; + } else { + $code_point = + (($byte & 0x07) << 18) | + ((ord($name[$i + 1]) & 0x3f) << 12) | + ((ord($name[$i + 2]) & 0x3f) << 6) | + (ord($name[$i + 3]) & 0x3f); + $i += 4; + } + + if ($code_point >= 0x10000) { + $code_point -= 0x10000; + $key .= pack( + "n2", + 0xd800 | ($code_point >> 10), + 0xdc00 | ($code_point & 0x3ff), + ); + } else { + $key .= pack("n", $code_point); + } + } + + return $key; + } +} diff --git a/src/UrlSearchParamsSerializer.php b/src/UrlSearchParamsSerializer.php new file mode 100644 index 0000000..8ad082e --- /dev/null +++ b/src/UrlSearchParamsSerializer.php @@ -0,0 +1,357 @@ +|\stdClass $params + * + * @throws UnserializableParamError If any param could not be serialized + */ + public static function serialize(array|\stdClass $params): string + { + $search_params = new UrlSearchParams(); + self::update($search_params, $params); + + return $search_params->to_string(); + } + + /** + * Updates existing URL search params with serialized params. + * + * Existing params are preserved unless overwritten by a serialized + * param. All params are sorted by name. + * + * @param array|\stdClass $params + * + * @throws UnserializableParamError If any param could not be serialized + */ + public static function update( + UrlSearchParams $search_params, + array|\stdClass $params, + ): void { + self::nested_update($search_params, $params, []); + $search_params->sort(); + } + + /** + * @param array|\stdClass $params + * @param list $path + */ + private static function nested_update( + UrlSearchParams $search_params, + array|\stdClass $params, + array $path, + ): void { + $entries = + $params instanceof \stdClass ? get_object_vars($params) : $params; + + foreach ($entries as $key => $value) { + // PHP silently casts a numeric-string key to an integer, so a + // non-string key cannot be told apart from one; both are + // rejected rather than serialized ambiguously. + if (!is_string($key)) { + throw new UnserializableParamError( + (string) $key, + "is a " . + get_debug_type($key) . + " which is unsupported as a parameter name", + ); + } + + if (str_contains($key, ".")) { + throw new UnserializableParamError( + $key, + 'contains one or more dots "." in its name which is unsupported', + ); + } + + $current_path = [...$path, $key]; + + if (self::is_plain_object($value)) { + /** @var array|\stdClass $value */ + self::nested_update($search_params, $value, $current_path); + continue; + } + + $name = implode(".", $current_path); + + if ($value === null) { + continue; + } + + if ($value === "") { + continue; + } + + if (is_array($value)) { + self::update_from_array($search_params, $name, $value); + continue; + } + + $search_params->set($name, self::serialize_value($name, $value)); + } + } + + /** + * An array is a plain object when its keys are not the sequential + * integers of a list. The empty array is a list: it is the empty + * JavaScript Array, not an empty plain object. + */ + private static function is_plain_object(mixed $value): bool + { + if ($value instanceof \stdClass) { + return true; + } + + return is_array($value) && $value !== [] && !array_is_list($value); + } + + /** + * @param list $values + */ + private static function update_from_array( + UrlSearchParams $search_params, + string $name, + array $values, + ): void { + if ($values === []) { + // The one case where an empty value is meaningful: the parser + // reads `name=` as the empty array. + $search_params->set($name, ""); + return; + } + + if (count($values) === 1 && $values[0] === "") { + throw new UnserializableParamError( + $name, + "is a single element array containing the empty string which is unsupported", + ); + } + + if (in_array("", $values, true)) { + throw new UnserializableParamError( + $name, + "is an array containing the empty string which is unsupported", + ); + } + + foreach ($values as $value) { + if ($value === null || $value instanceof NullValue) { + throw new UnserializableParamError( + $name, + "is an array containing null or undefined values which is unsupported", + ); + } + } + + foreach ($values as $value) { + $search_params->append($name, self::serialize_value($name, $value)); + } + } + + /** + * @throws UnserializableParamError If the value could not be serialized + */ + private static function serialize_value(string $name, mixed $value): string + { + if ($value instanceof NullValue) { + return ""; + } + + if (is_string($value)) { + return $value; + } + + if (is_bool($value)) { + return $value ? "true" : "false"; + } + + if (is_int($value)) { + return (string) $value; + } + + if (is_float($value)) { + return self::format_number($name, $value); + } + + if ($value instanceof \DateTimeInterface) { + return self::format_datetime($value); + } + + throw new UnserializableParamError( + $name, + "is a " . get_debug_type($value), + ); + } + + /** + * Formats an instant as JavaScript's Date.prototype.toISOString does: + * always UTC, always exactly three fractional digits, always a literal + * `Z`. Sub-millisecond precision is truncated, not rounded. + */ + private static function format_datetime(\DateTimeInterface $value): string + { + $utc = \DateTimeImmutable::createFromInterface($value)->setTimezone( + new \DateTimeZone("UTC"), + ); + $milliseconds = intdiv((int) $utc->format("u"), 1000); + + return sprintf( + "%04d-%s.%03dZ", + (int) $utc->format("Y"), + $utc->format("m-d\TH:i:s"), + $milliseconds, + ); + } + + /** + * Formats a float with the ECMAScript Number::toString algorithm. + * + * PHP's own float formatting differs from it in several ways: an + * integral float renders as `1.0` rather than `1`, the exponent + * threshold is not at 1e21 and 1e-7, and exponents are spelled `E+21` + * rather than `e+21`. + */ + private static function format_number(string $name, float $value): string + { + if (is_nan($value)) { + throw new UnserializableParamError($name, "is NaN"); + } + + if (is_infinite($value)) { + throw new UnserializableParamError( + $name, + $value > 0 ? "is Infinity" : "is -Infinity", + ); + } + + if ($value === 0.0) { + return "0"; + } + + $sign = $value < 0 ? "-" : ""; + [$digits, $point] = self::shortest_digits(abs($value)); + + return $sign . self::format_digits($digits, $point); + } + + /** + * Returns the shortest digit string that round-trips to the float, with + * the position of the decimal point relative to those digits, as the + * ECMAScript Number::toString algorithm requires. + * + * @return array{string, int} + */ + private static function shortest_digits(float $value): array + { + // At -1, PHP's float-to-string conversion produces the shortest + // string that round-trips, which is exactly the digit string the + // algorithm needs. The ini setting is restored because it also + // affects the caller's own serialize() and json_encode() calls. + $precision = ini_set("serialize_precision", "-1"); + + try { + $repr = var_export($value, true); + } finally { + if ($precision !== false) { + ini_set("serialize_precision", $precision); + } + } + + if ( + preg_match( + '/^(\d+)(?:\.(\d+))?(?:E([+-]?\d+))?$/i', + $repr, + $matches, + ) !== 1 + ) { + // Unreachable for a finite positive float, but a silent + // mis-parse would serialize a wrong number. + throw new \RuntimeException( + "Could not parse the PHP float representation: {$repr}", + ); + } + + $digits = $matches[1] . ($matches[2] ?? ""); + $point = strlen($matches[1]) + (int) ($matches[3] ?? "0"); + + $stripped = ltrim($digits, "0"); + $point -= strlen($digits) - strlen($stripped); + $digits = rtrim($stripped, "0"); + + return [$digits, $point]; + } + + /** + * Formats digits and a decimal point position per ECMAScript + * Number::toString. The four branches and the constants 21 and -6 are + * the specification. + * + * @param string $digits Significant digits, without trailing zeros + * @param int $point Position of the decimal point relative to the digits + */ + private static function format_digits(string $digits, int $point): string + { + $count = strlen($digits); + + if ($count <= $point && $point <= 21) { + return $digits . str_repeat("0", $point - $count); + } + + if (0 < $point && $point <= 21) { + return substr($digits, 0, $point) . "." . substr($digits, $point); + } + + if (-6 < $point && $point <= 0) { + return "0." . str_repeat("0", -$point) . $digits; + } + + $exponent = $point - 1; + $exponent_sign = $exponent >= 0 ? "+" : "-"; + $mantissa = + $count === 1 ? $digits : $digits[0] . "." . substr($digits, 1); + + return $mantissa . "e" . $exponent_sign . abs($exponent); + } +} diff --git a/tests/NullValueTest.php b/tests/NullValueTest.php new file mode 100644 index 0000000..3131ad5 --- /dev/null +++ b/tests/NullValueTest.php @@ -0,0 +1,79 @@ +assertInstanceOf(NullValue::class, NullValue::NULL); + $this->assertNotInstanceOf(NullValue::class, null); + $this->assertNotInstanceOf(NullValue::class, "NULL"); + } + + public function testIsASingleton(): void + { + $this->assertSame(NullValue::NULL, NullValue::NULL); + $this->assertCount(1, NullValue::cases()); + } + + public function testReadsAsItsOwnName(): void + { + $this->assertSame("NULL", NullValue::NULL->name); + } + + public function testReplaceReplacesTheSentinel(): void + { + $this->assertNull(NullValue::replace(NullValue::NULL)); + } + + public function testReplaceLeavesOtherValuesUnchanged(): void + { + foreach ([null, "NULL", 0, false, 20.5, ["a"]] as $value) { + $this->assertSame($value, NullValue::replace($value)); + } + } + + public function testReplaceRecursesIntoArrays(): void + { + $this->assertSame( + [ + "name" => null, + "codes" => [null, "1234"], + "nested" => ["code" => null], + ], + NullValue::replace([ + "name" => NullValue::NULL, + "codes" => [NullValue::NULL, "1234"], + "nested" => ["code" => NullValue::NULL], + ]), + ); + } + + public function testReplaceCopiesStdClassObjectsWithoutMutatingThem(): void + { + $payload = (object) [ + "name" => NullValue::NULL, + "nested" => (object) ["code" => NullValue::NULL], + ]; + + $replaced = NullValue::replace($payload); + + $this->assertNotSame($payload, $replaced); + $this->assertNull($replaced->name); + $this->assertNull($replaced->nested->code); + // The caller's payload is untouched. + $this->assertSame(NullValue::NULL, $payload->name); + $this->assertSame(NullValue::NULL, $payload->nested->code); + } + + public function testReplaceDoesNotDescendIntoStrings(): void + { + $this->assertSame("a NULL b", NullValue::replace("a NULL b")); + } +} diff --git a/tests/UrlSearchParamsSerializerTest.php b/tests/UrlSearchParamsSerializerTest.php new file mode 100644 index 0000000..60778cb --- /dev/null +++ b/tests/UrlSearchParamsSerializerTest.php @@ -0,0 +1,661 @@ +assertSame("", self::serialize([])); + $this->assertSame("", self::serialize(new \stdClass())); + } + + public function testSerializesString(): void + { + $this->assertSame("foo=d", self::serialize(["foo" => "d"])); + $this->assertSame("foo=null", self::serialize(["foo" => "null"])); + $this->assertSame( + "foo=undefined", + self::serialize(["foo" => "undefined"]), + ); + $this->assertSame("foo=0", self::serialize(["foo" => "0"])); + } + + public function testRemovesTheEmptyString(): void + { + // Serializing the empty string would conflict with NullValue::NULL. + $this->assertSame("", self::serialize(["foo" => ""])); + $this->assertSame( + "foo=d", + self::serialize(["foo" => "d", "bar" => ""]), + ); + } + + public function testSerializesInt(): void + { + $this->assertSame("foo=1", self::serialize(["foo" => 1])); + $this->assertSame("foo=0", self::serialize(["foo" => 0])); + $this->assertSame("foo=-42", self::serialize(["foo" => -42])); + } + + public function testSerializesLargeIntWithFullPrecision(): void + { + $this->assertSame( + "foo=9007199254740993", + self::serialize(["foo" => 9007199254740993]), + ); + $this->assertSame( + "foo=9223372036854775807", + self::serialize(["foo" => PHP_INT_MAX]), + ); + } + + public function testSerializesFloat(): void + { + $this->assertSame("foo=23.8", self::serialize(["foo" => 23.8])); + $this->assertSame("foo=-23.8", self::serialize(["foo" => -23.8])); + $this->assertSame( + "foo=0.30000000000000004", + self::serialize(["foo" => 0.1 + 0.2]), + ); + } + + public function testSerializesFloatUsingTheEcmascriptNumberFormat(): void + { + // A float is serialized exactly as JavaScript would serialize the + // number, which is not always the same as the PHP string cast. + $this->assertSame("foo=1", self::serialize(["foo" => 1.0])); + $this->assertSame("foo=0", self::serialize(["foo" => -0.0])); + $this->assertSame("foo=100", self::serialize(["foo" => 100.0])); + $this->assertSame( + "foo=10000000000000000", + self::serialize(["foo" => 1e16]), + ); + $this->assertSame( + "foo=100000000000000000000", + self::serialize(["foo" => 1e20]), + ); + $this->assertSame("foo=1e%2B21", self::serialize(["foo" => 1e21])); + $this->assertSame("foo=0.0001", self::serialize(["foo" => 0.0001])); + $this->assertSame("foo=0.000001", self::serialize(["foo" => 1e-6])); + $this->assertSame("foo=1e-7", self::serialize(["foo" => 1e-7])); + $this->assertSame("foo=5e-324", self::serialize(["foo" => 5e-324])); + $this->assertSame( + "foo=1.7976931348623157e%2B308", + self::serialize(["foo" => PHP_FLOAT_MAX]), + ); + } + + public function testSerializesBool(): void + { + $this->assertSame("foo=true", self::serialize(["foo" => true])); + $this->assertSame("foo=false", self::serialize(["foo" => false])); + $this->assertSame( + "bar=false&foo=true", + self::serialize(["foo" => true, "bar" => false]), + ); + } + + public function testRemovesNullParams(): void + { + $this->assertSame("", self::serialize(["bar" => null])); + $this->assertSame( + "foo=1", + self::serialize(["foo" => 1, "bar" => null]), + ); + } + + public function testSerializesNullValueParams(): void + { + $this->assertSame("bar=", self::serialize(["bar" => NullValue::NULL])); + $this->assertSame( + "bar=&foo=1", + self::serialize(["foo" => 1, "bar" => NullValue::NULL]), + ); + } + + public function testRemovesNullParamsAtAnyDepth(): void + { + $this->assertSame( + "foo.baz=1", + self::serialize(["foo" => ["bar" => null, "baz" => 1]]), + ); + $this->assertSame("", self::serialize(["foo" => ["bar" => null]])); + } + + public function testSerializesEmptyArrayParams(): void + { + $this->assertSame("bar=", self::serialize(["bar" => []])); + $this->assertSame( + "bar=&foo=1", + self::serialize(["foo" => 1, "bar" => []]), + ); + } + + public function testSerializesArrayParamsWithOneValue(): void + { + $this->assertSame("bar=a", self::serialize(["bar" => ["a"]])); + $this->assertSame( + "bar=a&foo=1", + self::serialize(["foo" => 1, "bar" => ["a"]]), + ); + } + + public function testSerializesArrayParamsWithManyValues(): void + { + $this->assertSame( + "bar=a&bar=2&foo=1", + self::serialize(["foo" => 1, "bar" => ["a", "2"]]), + ); + $this->assertSame( + "bar=null&bar=2&bar=undefined&foo=1", + self::serialize(["foo" => 1, "bar" => ["null", "2", "undefined"]]), + ); + } + + public function testSerializesArrayParamsWithMixedValues(): void + { + $this->assertSame( + "bar=1&bar=a&bar=true&bar=1970-01-01T00%3A00%3A00.000Z", + self::serialize([ + "bar" => [ + 1, + "a", + true, + new \DateTimeImmutable("1970-01-01T00:00:00Z"), + ], + ]), + ); + } + + public function testSerializesDatetime(): void + { + $this->assertSame( + "foo=1&now=2025-02-24T18%3A44%3A39.000Z", + self::serialize([ + "foo" => 1, + "now" => new \DateTimeImmutable("2025-02-24T18:44:39Z"), + ]), + ); + } + + public function testSerializesMutableDatetime(): void + { + $now = new \DateTime("2025-02-24T18:44:39Z"); + + $this->assertSame( + "now=2025-02-24T18%3A44%3A39.000Z", + self::serialize(["now" => $now]), + ); + // Converting to UTC must not mutate the caller's value. + $this->assertSame("2025-02-24T18:44:39+00:00", $now->format("c")); + } + + public function testSerializesDatetimeWithMilliseconds(): void + { + $this->assertSame( + "now=2025-02-24T18%3A44%3A39.123Z", + self::serialize([ + "now" => new \DateTimeImmutable("2025-02-24T18:44:39.123Z"), + ]), + ); + } + + public function testTruncatesDatetimeMicroseconds(): void + { + $this->assertSame( + "now=2025-02-24T18%3A44%3A39.123Z", + self::serialize([ + "now" => new \DateTimeImmutable("2025-02-24T18:44:39.123999Z"), + ]), + ); + } + + public function testSerializesDatetimeAsUtc(): void + { + $this->assertSame( + "now=2025-02-24T18%3A44%3A39.000Z", + self::serialize([ + "now" => new \DateTimeImmutable("2025-02-24T13:44:39-05:00"), + ]), + ); + } + + public function testSerializesDatetimeBeforeTheEpoch(): void + { + $this->assertSame( + "then=1969-12-31T23%3A59%3A59.000Z", + self::serialize([ + "then" => new \DateTimeImmutable("1969-12-31T23:59:59Z"), + ]), + ); + } + + public function testZeroPadsTheYearToFourDigits(): void + { + $this->assertSame( + "then=0050-01-02T03%3A04%3A05.000Z", + self::serialize([ + "then" => new \DateTimeImmutable("0050-01-02T03:04:05Z"), + ]), + ); + } + + public function testSerializesNestedParams(): void + { + $this->assertSame( + "bar.baz=a&foo=1", + self::serialize(["foo" => 1, "bar" => ["baz" => "a"]]), + ); + + $this->assertSame( + "bar.baz.x.z=1&foo=1", + self::serialize([ + "foo" => 1, + "bar" => ["baz" => ["x" => ["z" => 1]]], + ]), + ); + + $this->assertSame( + "bar.baz.x.z=&foo=1", + self::serialize([ + "foo" => 1, + "bar" => ["baz" => ["x" => ["z" => NullValue::NULL]]], + ]), + ); + + $this->assertSame( + "bar.baz=1&bar.baz=a&foo=1", + self::serialize(["foo" => 1, "bar" => ["baz" => [1, "a"]]]), + ); + + $this->assertSame( + "bar=2", + self::serialize(["foo" => new \stdClass(), "bar" => 2]), + ); + + $this->assertSame( + "bar=2", + self::serialize(["foo" => ["x" => new \stdClass()], "bar" => 2]), + ); + + $this->assertSame( + "bar.baz.x.z=", + self::serialize([ + "foo" => new \stdClass(), + "bar" => [ + "baz" => [ + "x" => ["z" => NullValue::NULL, "t" => new \stdClass()], + "q" => new \stdClass(), + ], + ], + ]), + ); + } + + public function testSerializesStdClassParams(): void + { + $this->assertSame( + "bar.baz=a&foo=1", + self::serialize( + (object) ["foo" => 1, "bar" => (object) ["baz" => "a"]], + ), + ); + } + + public function testSortsParamsByName(): void + { + $this->assertSame( + "a=2&b=1&c=3", + self::serialize(["b" => 1, "a" => 2, "c" => 3]), + ); + $this->assertSame( + "A=2&B=4&a=3&b=1", + self::serialize(["b" => 1, "A" => 2, "a" => 3, "B" => 4]), + ); + $this->assertSame( + "a1=3&a10=1&a2=2", + self::serialize(["a10" => 1, "a2" => 2, "a1" => 3]), + ); + $this->assertSame( + "a.b=3&a.z=2&zz=1", + self::serialize(["zz" => 1, "a" => ["z" => 2, "b" => 3]]), + ); + $this->assertSame( + "a.b=2&ab=1", + self::serialize(["ab" => 1, "a" => ["b" => 2]]), + ); + } + + public function testSortsParamsByUtf16CodeUnit(): void + { + // UTF-8 byte order and code-point order would both put U+FFFF first; + // only UTF-16 code-unit order puts the astral emoji first. + $this->assertSame( + "%F0%9F%98%80=2&%EF%BF%BF=1", + self::serialize(["\u{FFFF}" => 1, "\u{1F600}" => 2]), + ); + } + + public function testSortingPreservesArrayOrder(): void + { + $this->assertSame( + "a=1&b=3&b=1&b=2", + self::serialize(["b" => ["3", "1", "2"], "a" => 1]), + ); + } + + public function testEncodesParamsAsFormUrlencoded(): void + { + $this->assertSame("foo=a+b", self::serialize(["foo" => "a b"])); + $this->assertSame("foo=a%2Bb", self::serialize(["foo" => "a+b"])); + $this->assertSame("foo=a%7Eb", self::serialize(["foo" => "a~b"])); + $this->assertSame("foo=a*b", self::serialize(["foo" => "a*b"])); + $this->assertSame( + "foo=abcXYZ019*-._", + self::serialize(["foo" => "abcXYZ019*-._"]), + ); + $this->assertSame("foo=a+*%7E+b", self::serialize(["foo" => "a *~ b"])); + $this->assertSame( + "foo=a%26b%3Dc%3Fd%23e%2Ff", + self::serialize(["foo" => "a&b=c?d#e/f"]), + ); + $this->assertSame("foo=100%25", self::serialize(["foo" => "100%"])); + $this->assertSame("foo=a%0Ab", self::serialize(["foo" => "a\nb"])); + } + + public function testEncodesUnicodeParams(): void + { + $this->assertSame( + "foo=h%C3%A9llo+w%C3%B6rld", + self::serialize(["foo" => "héllo wörld"]), + ); + $this->assertSame( + "foo=%E6%97%A5%E6%9C%AC%E8%AA%9E", + self::serialize(["foo" => "日本語"]), + ); + $this->assertSame("%F0%9F%94%92=a", self::serialize(["🔒" => "a"])); + $this->assertSame("a+b=1", self::serialize(["a b" => 1])); + } + + public function testCannotSerializeKeysContainingADot(): void + { + $this->expectException(UnserializableParamError::class); + + self::serialize(["foo.bar" => 1]); + } + + public function testCannotSerializeNestedKeysContainingADot(): void + { + $this->expectException(UnserializableParamError::class); + + self::serialize(["foo" => ["bar.baz" => 1]]); + } + + public function testCannotSerializeNonStringKeys(): void + { + // PHP casts a numeric-string key to an integer, so both spellings + // arrive here as the same unserializable key. + $this->expectException(UnserializableParamError::class); + + self::serialize(["foo" => [1 => "a", "b" => "c"]]); + } + + public function testCannotSerializeClosures(): void + { + $this->expectException(UnserializableParamError::class); + + self::serialize(["foo" => fn() => null]); + } + + /** + * @dataProvider provideNonFiniteFloats + */ + public function testCannotSerializeNonFiniteFloats( + float $value, + string $message, + ): void { + try { + self::serialize(["foo" => $value]); + $this->fail("Expected an UnserializableParamError"); + } catch (UnserializableParamError $error) { + $this->assertSame( + "Could not serialize parameter: 'foo' {$message}", + $error->getMessage(), + ); + $this->assertSame("foo", $error->getName()); + } + } + + public static function provideNonFiniteFloats(): array + { + return [ + "NaN" => [NAN, "is NaN"], + "Infinity" => [INF, "is Infinity"], + "-Infinity" => [-INF, "is -Infinity"], + ]; + } + + public function testCannotSerializeArbitraryObjects(): void + { + $this->expectException(UnserializableParamError::class); + + self::serialize([ + "foo" => new class { + public string $device_id = "a"; + }, + ]); + } + + /** + * @dataProvider provideUnserializableArrays + */ + public function testCannotSerializeArrayParamsWithUnserializableValues( + array $params, + ): void { + $this->expectException(UnserializableParamError::class); + + self::serialize($params); + } + + public static function provideUnserializableArrays(): array + { + return [ + "single empty string" => [["foo" => [""]]], + "null element" => [["bar" => ["a", null]]], + "NullValue element" => [["bar" => ["a", NullValue::NULL]]], + "nested list" => [["bar" => ["a", ["s"]]]], + "nested empty list" => [["bar" => ["a", []]]], + "nested list with empty string" => [["bar" => ["a", [""]]]], + "nested object" => [["bar" => ["a", new \stdClass()]]], + "nested map" => [["bar" => ["a", ["x" => 2]]]], + "closure element" => [["bar" => ["a", fn() => null]]], + "empty strings around values" => [ + ["foo" => 1, "bar" => ["", "a", ""]], + ], + "leading empty string" => [["foo" => 1, "bar" => ["", "a", "2"]]], + "only empty strings" => [["foo" => 1, "bar" => ["", "", ""]]], + "NaN element" => [["foo" => [1, NAN]]], + ]; + } + + public function testUnserializableParamErrorMessage(): void + { + try { + self::serialize(["foo" => ["bar.baz" => 1]]); + $this->fail("Expected an UnserializableParamError"); + } catch (UnserializableParamError $error) { + $this->assertSame( + "Could not serialize parameter: 'bar.baz' contains one or " . + 'more dots "." in its name which is unsupported', + $error->getMessage(), + ); + $this->assertSame("bar.baz", $error->getName()); + } + } + + public function testUnserializableParamErrorMessageUsesTheFullPath(): void + { + try { + self::serialize(["foo" => ["bar" => NAN]]); + $this->fail("Expected an UnserializableParamError"); + } catch (UnserializableParamError $error) { + $this->assertSame( + "Could not serialize parameter: 'foo.bar' is NaN", + $error->getMessage(), + ); + } + } + + public function testUpdateUrlSearchParams(): void + { + $search_params = new UrlSearchParams(); + UrlSearchParamsSerializer::update($search_params, [ + "foo" => "d", + "bar" => 2, + ]); + + $this->assertSame("bar=2&foo=d", $search_params->to_string()); + } + + public function testUpdatePreservesExistingParams(): void + { + $search_params = new UrlSearchParams([["foo", "bar"]]); + UrlSearchParamsSerializer::update($search_params, [ + "name" => "Dax", + "age" => 27, + "is_admin" => true, + "tags" => ["cars", "planes"], + ]); + + $this->assertSame( + "age=27&foo=bar&is_admin=true&name=Dax&tags=cars&tags=planes", + $search_params->to_string(), + ); + } + + public function testUpdateOverwritesExistingParams(): void + { + $search_params = new UrlSearchParams([ + ["foo", "a"], + ["bar", "x"], + ["foo", "b"], + ]); + UrlSearchParamsSerializer::update($search_params, ["foo" => "new"]); + + $this->assertSame("bar=x&foo=new", $search_params->to_string()); + } + + public function testUpdateAppendsArrayParams(): void + { + $search_params = new UrlSearchParams([["foo", "old"]]); + UrlSearchParamsSerializer::update($search_params, ["foo" => [1, 2]]); + + $this->assertSame("foo=old&foo=1&foo=2", $search_params->to_string()); + } + + public function testUpdateKeepsExistingParamsForAbsentValues(): void + { + foreach ([null, "", new \stdClass()] as $value) { + $search_params = new UrlSearchParams([["foo", "a"]]); + UrlSearchParamsSerializer::update($search_params, [ + "foo" => $value, + ]); + + $this->assertSame("foo=a", $search_params->to_string()); + } + } + + public function testUrlSearchParamsFromQueryString(): void + { + $search_params = new UrlSearchParams( + "?a=1&b=hello+world&c=%F0%9F%94%92&d", + ); + + $this->assertSame("1", $search_params->get("a")); + $this->assertSame("hello world", $search_params->get("b")); + $this->assertSame("🔒", $search_params->get("c")); + $this->assertSame("", $search_params->get("d")); + $this->assertSame( + "a=1&b=hello+world&c=%F0%9F%94%92&d=", + $search_params->to_string(), + ); + } + + public function testUrlSearchParamsFromMap(): void + { + $this->assertSame( + "a=1&b=2", + (new UrlSearchParams(["a" => "1", "b" => "2"]))->to_string(), + ); + } + + public function testUrlSearchParamsAppendAndGet(): void + { + $search_params = new UrlSearchParams(); + $search_params->append("foo", "a"); + $search_params->append("foo", "b"); + + $this->assertSame("a", $search_params->get("foo")); + $this->assertSame(["a", "b"], $search_params->get_all("foo")); + $this->assertNull($search_params->get("bar")); + $this->assertSame([], $search_params->get_all("bar")); + $this->assertCount(2, $search_params); + $this->assertSame( + [["foo", "a"], ["foo", "b"]], + iterator_to_array($search_params), + ); + } + + public function testUrlSearchParamsSetKeepsTheFirstPairsPosition(): void + { + $search_params = new UrlSearchParams([ + ["foo", "a"], + ["bar", "x"], + ["foo", "b"], + ]); + $search_params->set("foo", "c"); + + $this->assertSame( + [["foo", "c"], ["bar", "x"]], + iterator_to_array($search_params), + ); + + $search_params->set("baz", "y"); + + $this->assertSame("y", $search_params->get("baz")); + } + + public function testUrlSearchParamsHasAndDelete(): void + { + $search_params = new UrlSearchParams([["foo", "a"], ["foo", "b"]]); + + $this->assertTrue($search_params->has("foo")); + + $search_params->delete("foo"); + + $this->assertFalse($search_params->has("foo")); + $this->assertCount(0, $search_params); + } + + public function testUrlSearchParamsCastsToString(): void + { + $this->assertSame( + "foo=a+b", + (string) new UrlSearchParams([["foo", "a b"]]), + ); + } +} From 351c862fe8007e0c46ebf5cdd370a64c80f7cd19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:25:21 +0000 Subject: [PATCH 2/6] 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 Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2 --- README.md | 30 ++ codegen/layouts/seam-client.hbs | 9 +- codegen/lib/layouts/route.ts | 27 +- src/Http/SerializingClient.php | 118 ++++++++ src/Routes/AccessCodesClient.php | 5 +- src/Routes/AccessCodesUnmanagedClient.php | 5 +- src/Routes/AccessGrantsClient.php | 25 +- src/Routes/AccessGrantsUnmanagedClient.php | 5 +- src/Routes/AccessMethodsClient.php | 5 +- src/Routes/AcsCredentialsClient.php | 5 +- src/Routes/AcsEncodersClient.php | 5 +- src/Routes/AcsEntrancesClient.php | 9 +- src/Routes/AcsUsersClient.php | 5 +- src/Routes/ActionAttemptsClient.php | 5 +- src/Routes/ConnectWebviewsClient.php | 5 +- src/Routes/ConnectedAccountsClient.php | 5 +- src/Routes/DevicesClient.php | 13 +- src/Routes/DevicesUnmanagedClient.php | 5 +- src/Routes/SpacesClient.php | 5 +- src/Routes/ThermostatsClient.php | 53 ++-- src/Routes/ThermostatsSchedulesClient.php | 9 +- src/Routes/UserIdentitiesClient.php | 37 +-- src/Routes/UserIdentitiesUnmanagedClient.php | 5 +- src/Routes/WorkspacesClient.php | 5 +- src/Seam.php | 28 +- src/SeamWithoutWorkspace.php | 20 +- tests/SearchParamsTest.php | 283 +++++++++++++++++++ 27 files changed, 609 insertions(+), 122 deletions(-) create mode 100644 src/Http/SerializingClient.php create mode 100644 tests/SearchParamsTest.php diff --git a/README.md b/README.md index 03be50d..7464a93 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,36 @@ $seam = new Seam\Seam(retries: 5); $seam = new Seam\Seam(retries: 0); ``` +#### Setting a param to null + +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. PHP has a single absence value, so +the SDK spells the two states differently: + +- `null`, or simply omitting the param, means **omit**: the param is not + sent at all. +- `Seam\NullValue::NULL` means **send null**: the value is unset. + +Since unsetting a value cannot be undone, it is never the default and is +always spelled explicitly. + +```php +use Seam\NullValue; + +// Unset the device name. +$seam->devices->update(device_id: $device_id, name: NullValue::NULL); + +// Leave the device name unchanged. +$seam->devices->update(device_id: $device_id, name: null); +``` + +Only params the API documents as nullable accept the sentinel: a nullable +param is typed `string|NullValue|null`, while a merely optional one is typed +`?string` and rejects it. The sentinel works on every route, whether the +request is sent as a query string (serialized as `name=`) or as a JSON body +(serialized as `null`). + #### URL search params serialization Requests with query params follow the [Seam URL search params serialization diff --git a/codegen/layouts/seam-client.hbs b/codegen/layouts/seam-client.hbs index 24c40fb..e3813c6 100644 --- a/codegen/layouts/seam-client.hbs +++ b/codegen/layouts/seam-client.hbs @@ -9,6 +9,7 @@ use {{this}}; use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; use Seam\Http\ClientFactory; +use Seam\Http\SerializingClient; /** * Client for the Seam API. @@ -33,6 +34,10 @@ class Seam /** * The Guzzle client this instance makes its requests with. + * + * Query params given as a map and NullValue::NULL sentinels in JSON + * bodies are serialized with the Seam standard before the request goes + * out; see Seam\Http\SerializingClient. */ public ClientInterface $client; @@ -78,13 +83,13 @@ class Seam "timeout" => $timeout, ]); - $this->client = $client ?? ClientFactory::create( + $this->client = SerializingClient::wrap($client ?? ClientFactory::create( Options::get_endpoint($endpoint), Auth::get_auth_headers($api_key, $personal_access_token, $workspace_id), $guzzle_options, $retries, $timeout - ); + )); {{#each parentClients}} $this->{{namespace}} = new {{clientName}}Client($this->client, $this->defaults); diff --git a/codegen/lib/layouts/route.ts b/codegen/lib/layouts/route.ts index a1e0b01..868640a 100644 --- a/codegen/lib/layouts/route.ts +++ b/codegen/lib/layouts/route.ts @@ -10,6 +10,7 @@ import { const clientInterfaceClass = 'GuzzleHttp\\ClientInterface' const bodyClass = 'Seam\\Http\\Body' +const nullValueClass = 'Seam\\NullValue' const resolveActionAttemptClass = 'Seam\\Http\\ResolveActionAttempt' const resourcesNamespace = 'Seam\\Resources' @@ -74,6 +75,21 @@ const onResponseParameter = { required: false, } +// A nullable param accepts the NullValue::NULL sentinel, which sends an +// explicit null to unset a value. A merely optional param does not: optional +// means omit by passing null, and sending null there would unset a value +// instead. Optionality composes with nullability rather than replacing it. +const getParameterPhpType = (parameter: { + type: string + isOptional: boolean + isNullable: boolean +}): string => { + const { type, isOptional, isNullable } = parameter + if (type === 'mixed') return type + if (isNullable) return `${type}|NullValue${isOptional ? '|null' : ''}` + return `${isOptional ? '?' : ''}${type}` +} + const getMethodLayoutContext = ( method: PhpClientMethod, ): MethodLayoutContext => { @@ -96,7 +112,7 @@ const getMethodLayoutContext = ( const signatureParams = sortedParameters .map( (p) => - `${(p.isNullable || p.isOptional) && p.type !== 'mixed' ? '?' : ''}${p.type} $${p.name}${p.isOptional ? ' = null' : ''}`, + `${getParameterPhpType(p)} $${p.name}${p.isOptional ? ' = null' : ''}`, ) .concat( usesActionAttempt @@ -109,7 +125,7 @@ const getMethodLayoutContext = ( const documentedEndpointParameters = sortedParameters.map( ({ name, type, description, isOptional, isNullable }) => ({ name, - type, + type: isNullable && type !== 'mixed' ? `${type}|NullValue` : type, description, required: !isOptional, isOptional, @@ -165,9 +181,16 @@ const getUseStatements = (client: PhpClient): string[] => { // Void endpoints never read the response, so they do not decode it. const readsBody = client.methods.some((m) => m.returnResource !== '') + // Only nullable params reference the null sentinel type; importing it + // elsewhere would trip the unused-import lint. + const usesNullValue = client.methods.some((m) => + m.parameters.some((p) => p.isNullable && p.type !== 'mixed'), + ) + return [ clientInterfaceClass, ...(readsBody ? [bodyClass] : []), + ...(usesNullValue ? [nullValueClass] : []), ...(usesActionAttempt ? [resolveActionAttemptClass] : []), ...[...resourceNames].map((name) => `${resourcesNamespace}\\${name}`), ].sort((a, b) => a.localeCompare(b)) diff --git a/src/Http/SerializingClient.php b/src/Http/SerializingClient.php new file mode 100644 index 0000000..0a37062 --- /dev/null +++ b/src/Http/SerializingClient.php @@ -0,0 +1,118 @@ +client->send($request, self::serialize_options($options)); + } + + #[\Override] + public function sendAsync( + RequestInterface $request, + array $options = [], + ): PromiseInterface { + return $this->client->sendAsync( + $request, + self::serialize_options($options), + ); + } + + #[\Override] + public function request( + string $method, + $uri = "", + array $options = [], + ): ResponseInterface { + return $this->client->request( + $method, + $uri, + self::serialize_options($options), + ); + } + + #[\Override] + public function requestAsync( + string $method, + $uri = "", + array $options = [], + ): PromiseInterface { + return $this->client->requestAsync( + $method, + $uri, + self::serialize_options($options), + ); + } + + #[\Override] + public function getConfig(?string $option = null) + { + return $this->client->getConfig($option); + } + + /** + * @param array $options + * @return array + */ + private static function serialize_options(array $options): array + { + if ( + isset($options["query"]) && + ($options["query"] instanceof \stdClass || + is_array($options["query"])) + ) { + $serialized = UrlSearchParamsSerializer::serialize( + $options["query"], + ); + + if ($serialized === "") { + // Nothing serialized must mean no query at all, not a bare + // trailing `?`. + unset($options["query"]); + } else { + $options["query"] = $serialized; + } + } + + if (array_key_exists("json", $options)) { + $options["json"] = NullValue::replace($options["json"]); + } + + return $options; + } +} diff --git a/src/Routes/AccessCodesClient.php b/src/Routes/AccessCodesClient.php index aabfff1..d5fd5a9 100644 --- a/src/Routes/AccessCodesClient.php +++ b/src/Routes/AccessCodesClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\AccessCode; class AccessCodesClient @@ -349,7 +350,7 @@ public function get( * @param string $customer_key Customer key for which you want to list access codes. * @param string $device_id ID of the device for which you want to list access codes. Specify `device_id`, `access_code_ids`, `access_method_id`, `access_grant_id`, or `access_grant_key`. * @param float $limit Numerical limit on the number of access codes to return. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned access codes to include all records that satisfy a partial match using `name`, `code` or `access_code_id`. * @param string $user_identifier_key Your user ID for the user by which to filter access codes. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. @@ -363,7 +364,7 @@ public function list( ?string $customer_key = null, ?string $device_id = null, ?float $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?string $user_identifier_key = null, ?callable $on_response = null, diff --git a/src/Routes/AccessCodesUnmanagedClient.php b/src/Routes/AccessCodesUnmanagedClient.php index 98b1973..8e28e0d 100644 --- a/src/Routes/AccessCodesUnmanagedClient.php +++ b/src/Routes/AccessCodesUnmanagedClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\UnmanagedAccessCode; class AccessCodesUnmanagedClient @@ -130,7 +131,7 @@ public function get( * * @param string $device_id ID of the device for which you want to list unmanaged access codes. * @param float $limit Numerical limit on the number of unmanaged access codes to return. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned access codes to include all records that satisfy a partial match using `name`, `code` or `access_code_id`. * @param string $user_identifier_key Your user ID for the user by which to filter unmanaged access codes. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. @@ -139,7 +140,7 @@ public function get( public function list( string $device_id, ?float $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?string $user_identifier_key = null, ?callable $on_response = null, diff --git a/src/Routes/AccessGrantsClient.php b/src/Routes/AccessGrantsClient.php index 1a17a5f..9d9074f 100644 --- a/src/Routes/AccessGrantsClient.php +++ b/src/Routes/AccessGrantsClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\AccessGrant; use Seam\Resources\Batch; @@ -36,10 +37,10 @@ public function __construct(ClientInterface $client, array $defaults) * @param array $acs_entrance_ids Set of IDs of the [entrances](https://docs.seam.co/api/acs/systems/list) to which access is being granted. * @param string $customization_profile_id ID of the customization profile to apply to the Access Grant and its access methods. * @param array $device_ids Set of IDs of the [devices](https://docs.seam.co/api/devices/list) to which access is being granted. - * @param string $ends_at Date and time at which the validity of the new grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. + * @param string|NullValue $ends_at Date and time at which the validity of the new grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. * @param mixed $location * @param array $location_ids - * @param string $name Name for the access grant. + * @param string|NullValue $name Name for the access grant. * @param string $reservation_key Reservation key for the access grant. * @param array $space_ids Set of IDs of existing spaces to which access is being granted. * @param array $space_keys Set of keys of existing spaces to which access is being granted. @@ -54,10 +55,10 @@ public function create( ?array $acs_entrance_ids = null, ?string $customization_profile_id = null, ?array $device_ids = null, - ?string $ends_at = null, + string|NullValue|null $ends_at = null, mixed $location = null, ?array $location_ids = null, - ?string $name = null, + string|NullValue|null $name = null, ?string $reservation_key = null, ?array $space_ids = null, ?array $space_keys = null, @@ -227,14 +228,14 @@ public function get_related( * * @param string $access_code_id ID of the access code by which you want to filter the list of Access Grants. * @param array $access_grant_ids IDs of the access grants to retrieve. - * @param string $access_grant_key Filter Access Grants by access_grant_key. Use null to filter for Access Grants without an access_grant_key. + * @param string|NullValue $access_grant_key Filter Access Grants by access_grant_key. Use null to filter for Access Grants without an access_grant_key. * @param string $acs_entrance_id ID of the entrance by which you want to filter the list of Access Grants. * @param string $acs_system_id ID of the access system by which you want to filter the list of Access Grants. * @param string $customer_key Customer key for which you want to list access grants. * @param string $device_id ID of the device by which you want to filter the list of Access Grants. * @param float $limit Numerical limit on the number of access grants to return. * @param string $location_id - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $reservation_key Filter Access Grants by reservation_key. * @param string $space_id ID of the space by which you want to filter the list of Access Grants. * @param string $user_identity_id ID of user identity by which you want to filter the list of Access Grants. @@ -244,14 +245,14 @@ public function get_related( public function list( ?string $access_code_id = null, ?array $access_grant_ids = null, - ?string $access_grant_key = null, + string|NullValue|null $access_grant_key = null, ?string $acs_entrance_id = null, ?string $acs_system_id = null, ?string $customer_key = null, ?string $device_id = null, ?float $limit = null, ?string $location_id = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $reservation_key = null, ?string $space_id = null, ?string $user_identity_id = null, @@ -349,16 +350,16 @@ public function request_access_methods( * * @param string $access_grant_id ID of the Access Grant to update. Provide either `access_grant_id` or `access_grant_key`. * @param string $access_grant_key Key of the Access Grant to update. Provide either `access_grant_id` or `access_grant_key`. - * @param string $ends_at Date and time at which the validity of the grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. - * @param string $name Display name for the access grant. + * @param string|NullValue $ends_at Date and time at which the validity of the grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. + * @param string|NullValue $name Display name for the access grant. * @param string $starts_at Date and time at which the validity of the grant starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. * @return void OK */ public function update( ?string $access_grant_id = null, ?string $access_grant_key = null, - ?string $ends_at = null, - ?string $name = null, + string|NullValue|null $ends_at = null, + string|NullValue|null $name = null, ?string $starts_at = null, ): void { if ( diff --git a/src/Routes/AccessGrantsUnmanagedClient.php b/src/Routes/AccessGrantsUnmanagedClient.php index 3b3d665..e872978 100644 --- a/src/Routes/AccessGrantsUnmanagedClient.php +++ b/src/Routes/AccessGrantsUnmanagedClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\UnmanagedAccessGrant; class AccessGrantsUnmanagedClient @@ -51,7 +52,7 @@ public function get(string $access_grant_id): UnmanagedAccessGrant * @param string $acs_entrance_id ID of the entrance by which you want to filter the list of unmanaged Access Grants. * @param string $acs_system_id ID of the access system by which you want to filter the list of unmanaged Access Grants. * @param float $limit Numerical limit on the number of unmanaged access grants to return. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $reservation_key Filter unmanaged Access Grants by reservation_key. * @param string $user_identity_id ID of user identity by which you want to filter the list of unmanaged Access Grants. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. @@ -61,7 +62,7 @@ public function list( ?string $acs_entrance_id = null, ?string $acs_system_id = null, ?float $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $reservation_key = null, ?string $user_identity_id = null, ?callable $on_response = null, diff --git a/src/Routes/AccessMethodsClient.php b/src/Routes/AccessMethodsClient.php index 9f22ddd..561c9cd 100644 --- a/src/Routes/AccessMethodsClient.php +++ b/src/Routes/AccessMethodsClient.php @@ -5,6 +5,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; use Seam\Http\ResolveActionAttempt; +use Seam\NullValue; use Seam\Resources\AccessMethod; use Seam\Resources\ActionAttempt; use Seam\Resources\Batch; @@ -193,7 +194,7 @@ public function get_related( * @param string $acs_entrance_id ID of the entrance for which you want to retrieve all access methods that grant access to it. * @param string $device_id ID of the device by which to filter the returned access methods. Must be combined with `access_grant_id`, `access_grant_key`, or `acs_entrance_id`. * @param int $limit Maximum number of records to return per page. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $space_id ID of the space by which to filter the returned access methods. Must be combined with `access_grant_id`, `access_grant_key`, or `acs_entrance_id`. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK @@ -205,7 +206,7 @@ public function list( ?string $acs_entrance_id = null, ?string $device_id = null, ?int $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $space_id = null, ?callable $on_response = null, ): array { diff --git a/src/Routes/AcsCredentialsClient.php b/src/Routes/AcsCredentialsClient.php index 86a884c..12291c3 100644 --- a/src/Routes/AcsCredentialsClient.php +++ b/src/Routes/AcsCredentialsClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\AcsCredential; use Seam\Resources\AcsEntrance; @@ -190,7 +191,7 @@ public function get(string $acs_credential_id): AcsCredential * @param string $created_before Date and time, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format, before which events to return were created. * @param bool $is_multi_phone_sync_credential Indicates whether you want to retrieve only multi-phone sync credentials or non-multi-phone sync credentials. * @param float $limit Number of credentials to return. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned credentials to include all records that satisfy a partial match using `display_name`, `code`, `card_number`, `acs_user_id` or `acs_credential_id`. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK @@ -202,7 +203,7 @@ public function list( ?string $created_before = null, ?bool $is_multi_phone_sync_credential = null, ?float $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?callable $on_response = null, ): array { diff --git a/src/Routes/AcsEncodersClient.php b/src/Routes/AcsEncodersClient.php index b7cd6c1..e21f8c4 100644 --- a/src/Routes/AcsEncodersClient.php +++ b/src/Routes/AcsEncodersClient.php @@ -5,6 +5,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; use Seam\Http\ResolveActionAttempt; +use Seam\NullValue; use Seam\Resources\AcsEncoder; use Seam\Resources\ActionAttempt; @@ -94,7 +95,7 @@ public function get(string $acs_encoder_id): AcsEncoder * @param array $acs_system_ids IDs of the access systems for which you want to retrieve all encoders. * @param array $acs_encoder_ids IDs of the encoders that you want to retrieve. * @param float $limit Number of encoders to return. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ @@ -103,7 +104,7 @@ public function list( ?array $acs_system_ids = null, ?array $acs_encoder_ids = null, ?float $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?callable $on_response = null, ): array { $request_payload = []; diff --git a/src/Routes/AcsEntrancesClient.php b/src/Routes/AcsEntrancesClient.php index 1cd2c1c..d3c7502 100644 --- a/src/Routes/AcsEntrancesClient.php +++ b/src/Routes/AcsEntrancesClient.php @@ -5,6 +5,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; use Seam\Http\ResolveActionAttempt; +use Seam\NullValue; use Seam\Resources\AcsCredential; use Seam\Resources\AcsEntrance; use Seam\Resources\ActionAttempt; @@ -86,8 +87,8 @@ public function grant_access( * @param string $connected_account_id ID of the connected account for which you want to retrieve all entrances. * @param string $customer_key Customer key for which you want to list entrances. * @param int $limit Maximum number of records to return per page. - * @param string $location_id - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $location_id + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned entrances to include all records that satisfy a partial match using `display_name`. * @param string $space_id ID of the space for which you want to list entrances. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. @@ -101,8 +102,8 @@ public function list( ?string $connected_account_id = null, ?string $customer_key = null, ?int $limit = null, - ?string $location_id = null, - ?string $page_cursor = null, + string|NullValue|null $location_id = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?string $space_id = null, ?callable $on_response = null, diff --git a/src/Routes/AcsUsersClient.php b/src/Routes/AcsUsersClient.php index 5a1b3c1..9c0d839 100644 --- a/src/Routes/AcsUsersClient.php +++ b/src/Routes/AcsUsersClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\AcsEntrance; use Seam\Resources\AcsUser; @@ -189,7 +190,7 @@ public function get( * @param string $acs_system_id ID of the `acs_system` for which you want to retrieve all access system users. * @param string $created_before Timestamp by which to limit returned access system users. Returns users created before this timestamp. * @param int $limit Maximum number of records to return per page. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned access system users to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address`, `acs_user_id`, `user_identity_id`, `user_identity_full_name` or `user_identity_phone_number`. * @param string $user_identity_email_address Email address of the user identity for which you want to retrieve all access system users. * @param string $user_identity_id ID of the user identity for which you want to retrieve all access system users. @@ -201,7 +202,7 @@ public function list( ?string $acs_system_id = null, ?string $created_before = null, ?int $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?string $user_identity_email_address = null, ?string $user_identity_id = null, diff --git a/src/Routes/ActionAttemptsClient.php b/src/Routes/ActionAttemptsClient.php index 11454f3..79d7eaf 100644 --- a/src/Routes/ActionAttemptsClient.php +++ b/src/Routes/ActionAttemptsClient.php @@ -5,6 +5,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; use Seam\Http\ResolveActionAttempt; +use Seam\NullValue; use Seam\Resources\ActionAttempt; class ActionAttemptsClient @@ -60,7 +61,7 @@ public function get( * @param array $action_attempt_ids IDs of the action attempts that you want to retrieve. * @param string $device_id ID of the device to filter action attempts by. * @param int $limit Maximum number of records to return per page. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ @@ -68,7 +69,7 @@ public function list( ?array $action_attempt_ids = null, ?string $device_id = null, ?int $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?callable $on_response = null, ): array { $request_payload = []; diff --git a/src/Routes/ConnectWebviewsClient.php b/src/Routes/ConnectWebviewsClient.php index b5f171b..25caf8a 100644 --- a/src/Routes/ConnectWebviewsClient.php +++ b/src/Routes/ConnectWebviewsClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\ConnectWebview; class ConnectWebviewsClient @@ -153,7 +154,7 @@ public function get(string $connect_webview_id): ConnectWebview * @param mixed $custom_metadata_has Custom metadata pairs by which you want to [filter Connect Webviews](https://docs.seam.co/core-concepts/connect-webviews/filtering-connect-webviews-by-custom-metadata). Returns Connect Webviews with `custom_metadata` that contains all of the provided key:value pairs. * @param string $customer_key Customer key for which you want to list connect webviews. * @param float $limit Maximum number of records to return per page. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned Connect Webviews to include all records that satisfy a partial match using `connect_webview_id`, `accepted_providers`, `custom_metadata`, or `customer_key`. * @param string $user_identifier_key Your user ID for the user by which you want to filter Connect Webviews. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. @@ -163,7 +164,7 @@ public function list( mixed $custom_metadata_has = null, ?string $customer_key = null, ?float $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?string $user_identifier_key = null, ?callable $on_response = null, diff --git a/src/Routes/ConnectedAccountsClient.php b/src/Routes/ConnectedAccountsClient.php index 175a34e..c706e9f 100644 --- a/src/Routes/ConnectedAccountsClient.php +++ b/src/Routes/ConnectedAccountsClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\ConnectedAccount; class ConnectedAccountsClient @@ -89,7 +90,7 @@ public function get( * @param mixed $custom_metadata_has Custom metadata pairs by which you want to filter connected accounts. Returns connected accounts with `custom_metadata` that contains all of the provided key:value pairs. * @param string $customer_key Customer key by which you want to filter connected accounts. * @param int $limit Maximum number of records to return per page. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned connected accounts to include all records that satisfy a partial match using `connected_account_id`, `account_type`, `customer_key`, `custom_metadata`, `user_identifier.username`, `user_identifier.email` or `user_identifier.phone`. * @param string $space_id ID of the space by which you want to filter connected accounts. * @param string $user_identifier_key Your user ID for the user by which you want to filter connected accounts. @@ -100,7 +101,7 @@ public function list( mixed $custom_metadata_has = null, ?string $customer_key = null, ?int $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?string $space_id = null, ?string $user_identifier_key = null, diff --git a/src/Routes/DevicesClient.php b/src/Routes/DevicesClient.php index 0c8ee8b..ef905a9 100644 --- a/src/Routes/DevicesClient.php +++ b/src/Routes/DevicesClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\Device; use Seam\Resources\DeviceProvider; @@ -76,10 +77,10 @@ public function get(?string $device_id = null, ?string $name = null): Device * @param array $device_types Array of device types for which you want to list devices. * @param float $limit Numerical limit on the number of devices to return. * @param string $manufacturer Manufacturer for which you want to list devices. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned devices to include all records that satisfy a partial match using `device_id` (full or partial UUID prefix, minimum 4 characters), `connected_account_id`, `display_name`, `custom_metadata` or `location.location_name`. * @param string $space_id ID of the space for which you want to list devices. - * @param string $unstable_location_id + * @param string|NullValue $unstable_location_id * @param string $user_identifier_key Your own internal user ID for the user for which you want to list devices. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK @@ -96,10 +97,10 @@ public function list( ?array $device_types = null, ?float $limit = null, ?string $manufacturer = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?string $space_id = null, - ?string $unstable_location_id = null, + string|NullValue|null $unstable_location_id = null, ?string $user_identifier_key = null, ?callable $on_response = null, ): array { @@ -224,7 +225,7 @@ public function report_provider_metadata(array $devices): void * @param bool $backup_access_code_pool_enabled Indicates whether the device's [backup access code pool](https://docs.seam.co/low-level-apis/smart-locks/access-codes/backup-access-codes) is enabled. Set to `false` to disable the pool: Seam stops refilling it and removes any backup codes that have not yet been pulled into active use. * @param mixed $custom_metadata Custom metadata that you want to associate with the device. Supports up to 50 JSON key:value pairs. [Adding custom metadata to a device](https://docs.seam.co/core-concepts/devices/adding-custom-metadata-to-a-device) enables you to store custom information, like customer details or internal IDs from your application. Then, you can [filter devices by the desired metadata](https://docs.seam.co/core-concepts/devices/filtering-devices-by-custom-metadata). * @param bool $is_managed Indicates whether the device is managed. To unmanage a device, set `is_managed` to `false`. - * @param string $name Name for the device. + * @param string|NullValue $name Name for the device. * @param mixed $properties * @return void OK */ @@ -233,7 +234,7 @@ public function update( ?bool $backup_access_code_pool_enabled = null, mixed $custom_metadata = null, ?bool $is_managed = null, - ?string $name = null, + string|NullValue|null $name = null, mixed $properties = null, ): void { $request_payload = []; diff --git a/src/Routes/DevicesUnmanagedClient.php b/src/Routes/DevicesUnmanagedClient.php index a241203..436f55d 100644 --- a/src/Routes/DevicesUnmanagedClient.php +++ b/src/Routes/DevicesUnmanagedClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\UnmanagedDevice; class DevicesUnmanagedClient @@ -77,7 +78,7 @@ public function get( * @param array $device_types Array of device types for which you want to list devices. * @param float $limit Numerical limit on the number of devices to return. * @param string $manufacturer Manufacturer for which you want to list devices. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned devices to include all records that satisfy a partial match using `device_id` (full or partial UUID prefix, minimum 4 characters), `connected_account_id`, `display_name`, `custom_metadata` or `location.location_name`. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK @@ -93,7 +94,7 @@ public function list( ?array $device_types = null, ?float $limit = null, ?string $manufacturer = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?callable $on_response = null, ): array { diff --git a/src/Routes/SpacesClient.php b/src/Routes/SpacesClient.php index 9b68f45..0b152a0 100644 --- a/src/Routes/SpacesClient.php +++ b/src/Routes/SpacesClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\Batch; use Seam\Resources\Space; @@ -243,7 +244,7 @@ public function get_related( * * @param string $customer_key Customer key for which you want to list spaces. * @param float $limit Maximum number of records to return per page. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned spaces to include all records that satisfy a partial match using `name`, `space_key`, or `customer_key`. * @param string $space_key Filter spaces by space_key. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. @@ -252,7 +253,7 @@ public function get_related( public function list( ?string $customer_key = null, ?float $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?string $space_key = null, ?callable $on_response = null, diff --git a/src/Routes/ThermostatsClient.php b/src/Routes/ThermostatsClient.php index af0f5e0..1c29970 100644 --- a/src/Routes/ThermostatsClient.php +++ b/src/Routes/ThermostatsClient.php @@ -5,6 +5,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; use Seam\Http\ResolveActionAttempt; +use Seam\NullValue; use Seam\Resources\ActionAttempt; use Seam\Resources\Device; @@ -125,7 +126,7 @@ public function cool( * @param float $heating_set_point_fahrenheit Temperature to which the thermostat should heat (in °F). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). * @param string $hvac_mode_setting Desired [HVAC mode](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. * @param bool $manual_override_allowed Indicates whether a person at the thermostat or using the API can change the thermostat's settings. - * @param string $name User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). + * @param string|NullValue $name User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). * @return void OK */ public function create_climate_preset( @@ -140,7 +141,7 @@ public function create_climate_preset( ?float $heating_set_point_fahrenheit = null, ?string $hvac_mode_setting = null, ?bool $manual_override_allowed = null, - ?string $name = null, + string|NullValue|null $name = null, ): void { $request_payload = []; @@ -517,18 +518,18 @@ public function set_hvac_mode( * Sets a [temperature threshold](https://docs.seam.co/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) for a specified thermostat. Seam emits a `thermostat.temperature_threshold_exceeded` event and adds a warning on a thermostat if it reports a temperature outside the threshold range. * * @param string $device_id ID of the thermostat device for which you want to set a temperature threshold. - * @param float $lower_limit_celsius Lower temperature limit in in °C. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. - * @param float $lower_limit_fahrenheit Lower temperature limit in in °F. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. - * @param float $upper_limit_celsius Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. - * @param float $upper_limit_fahrenheit Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. + * @param float|NullValue $lower_limit_celsius Lower temperature limit in in °C. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. + * @param float|NullValue $lower_limit_fahrenheit Lower temperature limit in in °F. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. + * @param float|NullValue $upper_limit_celsius Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. + * @param float|NullValue $upper_limit_fahrenheit Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. * @return void OK */ public function set_temperature_threshold( string $device_id, - ?float $lower_limit_celsius = null, - ?float $lower_limit_fahrenheit = null, - ?float $upper_limit_celsius = null, - ?float $upper_limit_fahrenheit = null, + float|NullValue|null $lower_limit_celsius = null, + float|NullValue|null $lower_limit_fahrenheit = null, + float|NullValue|null $upper_limit_celsius = null, + float|NullValue|null $upper_limit_fahrenheit = null, ): void { $request_payload = []; @@ -571,7 +572,7 @@ public function set_temperature_threshold( * @param float $heating_set_point_fahrenheit Temperature to which the thermostat should heat (in °F). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). * @param string $hvac_mode_setting Desired [HVAC mode](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. * @param bool $manual_override_allowed Indicates whether a person at the thermostat can change the thermostat's settings. See [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - * @param string $name User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). + * @param string|NullValue $name User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). * @return void OK */ public function update_climate_preset( @@ -586,7 +587,7 @@ public function update_climate_preset( ?float $heating_set_point_fahrenheit = null, ?string $hvac_mode_setting = null, ?bool $manual_override_allowed = null, - ?string $name = null, + string|NullValue|null $name = null, ): void { $request_payload = []; @@ -642,25 +643,25 @@ public function update_climate_preset( * Updates the thermostat weekly program for a thermostat device. To configure a weekly program, specify the ID of the daily program that you want to use for each day of the week. When you update a weekly program, the set of programs that you specify overwrites any previous weekly program for the thermostat. * * @param string $device_id ID of the thermostat device for which you want to update the weekly program. - * @param string $friday_program_id ID of the thermostat daily program to run on Fridays. - * @param string $monday_program_id ID of the thermostat daily program to run on Mondays. - * @param string $saturday_program_id ID of the thermostat daily program to run on Saturdays. - * @param string $sunday_program_id ID of the thermostat daily program to run on Sundays. - * @param string $thursday_program_id ID of the thermostat daily program to run on Thursdays. - * @param string $tuesday_program_id ID of the thermostat daily program to run on Tuesdays. - * @param string $wednesday_program_id ID of the thermostat daily program to run on Wednesdays. + * @param string|NullValue $friday_program_id ID of the thermostat daily program to run on Fridays. + * @param string|NullValue $monday_program_id ID of the thermostat daily program to run on Mondays. + * @param string|NullValue $saturday_program_id ID of the thermostat daily program to run on Saturdays. + * @param string|NullValue $sunday_program_id ID of the thermostat daily program to run on Sundays. + * @param string|NullValue $thursday_program_id ID of the thermostat daily program to run on Thursdays. + * @param string|NullValue $tuesday_program_id ID of the thermostat daily program to run on Tuesdays. + * @param string|NullValue $wednesday_program_id ID of the thermostat daily program to run on Wednesdays. * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function update_weekly_program( string $device_id, - ?string $friday_program_id = null, - ?string $monday_program_id = null, - ?string $saturday_program_id = null, - ?string $sunday_program_id = null, - ?string $thursday_program_id = null, - ?string $tuesday_program_id = null, - ?string $wednesday_program_id = null, + string|NullValue|null $friday_program_id = null, + string|NullValue|null $monday_program_id = null, + string|NullValue|null $saturday_program_id = null, + string|NullValue|null $sunday_program_id = null, + string|NullValue|null $thursday_program_id = null, + string|NullValue|null $tuesday_program_id = null, + string|NullValue|null $wednesday_program_id = null, bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; diff --git a/src/Routes/ThermostatsSchedulesClient.php b/src/Routes/ThermostatsSchedulesClient.php index 38ad4f0..e9d1c21 100644 --- a/src/Routes/ThermostatsSchedulesClient.php +++ b/src/Routes/ThermostatsSchedulesClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\ThermostatSchedule; class ThermostatsSchedulesClient @@ -32,7 +33,7 @@ public function __construct(ClientInterface $client, array $defaults) * @param string $ends_at Date and time at which the new thermostat schedule ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. * @param string $starts_at Date and time at which the new thermostat schedule starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. * @param bool $is_override_allowed Indicates whether a person at the thermostat or using the API can change the thermostat's settings while the new schedule is active. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - * @param int $max_override_period_minutes Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). + * @param int|NullValue $max_override_period_minutes Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). * @param string $name Name of the thermostat schedule. * @return ThermostatSchedule OK */ @@ -42,7 +43,7 @@ public function create( string $ends_at, string $starts_at, ?bool $is_override_allowed = null, - ?int $max_override_period_minutes = null, + int|NullValue|null $max_override_period_minutes = null, ?string $name = null, ): ThermostatSchedule { $request_payload = []; @@ -147,7 +148,7 @@ public function list( * @param string $climate_preset_key Key of the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) to use for the thermostat schedule. * @param string $ends_at Date and time at which the thermostat schedule ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. * @param bool $is_override_allowed Indicates whether a person at the thermostat or using the API can change the thermostat's settings while the schedule is active. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - * @param int $max_override_period_minutes Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). + * @param int|NullValue $max_override_period_minutes Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). * @param string $name Name of the thermostat schedule. * @param string $starts_at Date and time at which the thermostat schedule starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. * @return void OK @@ -157,7 +158,7 @@ public function update( ?string $climate_preset_key = null, ?string $ends_at = null, ?bool $is_override_allowed = null, - ?int $max_override_period_minutes = null, + int|NullValue|null $max_override_period_minutes = null, ?string $name = null, ?string $starts_at = null, ): void { diff --git a/src/Routes/UserIdentitiesClient.php b/src/Routes/UserIdentitiesClient.php index 7acc956..7138c3f 100644 --- a/src/Routes/UserIdentitiesClient.php +++ b/src/Routes/UserIdentitiesClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\AcsEntrance; use Seam\Resources\AcsSystem; use Seam\Resources\AcsUser; @@ -69,18 +70,18 @@ public function add_acs_user( * Creates a new [user identity](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity). * * @param array $acs_system_ids List of access system IDs to associate with the new user identity through access system users. If there's no user with the same email address or phone number in the specified access systems, a new access system user is created. If there is an existing user with the same email or phone number in the specified access systems, the user is linked to the user identity. - * @param string $email_address Unique email address for the new user identity. - * @param string $full_name Full name of the user associated with the new user identity. - * @param string $phone_number Unique phone number for the new user identity in E.164 format (for example, +15555550100). - * @param string $user_identity_key Unique key for the new user identity. + * @param string|NullValue $email_address Unique email address for the new user identity. + * @param string|NullValue $full_name Full name of the user associated with the new user identity. + * @param string|NullValue $phone_number Unique phone number for the new user identity in E.164 format (for example, +15555550100). + * @param string|NullValue $user_identity_key Unique key for the new user identity. * @return UserIdentity OK */ public function create( ?array $acs_system_ids = null, - ?string $email_address = null, - ?string $full_name = null, - ?string $phone_number = null, - ?string $user_identity_key = null, + string|NullValue|null $email_address = null, + string|NullValue|null $full_name = null, + string|NullValue|null $phone_number = null, + string|NullValue|null $user_identity_key = null, ): UserIdentity { $request_payload = []; @@ -225,7 +226,7 @@ public function grant_access_to_device( * @param string $created_before Timestamp by which to limit returned user identities. Returns user identities created before this timestamp. * @param string $credential_manager_acs_system_id `acs_system_id` of the credential manager by which you want to filter the list of user identities. * @param int $limit Maximum number of records to return per page. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned user identities to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address` or `user_identity_id`. * @param array $user_identity_ids Array of user identity IDs by which to filter the list of user identities. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. @@ -235,7 +236,7 @@ public function list( ?string $created_before = null, ?string $credential_manager_acs_system_id = null, ?int $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?array $user_identity_ids = null, ?callable $on_response = null, @@ -418,18 +419,18 @@ public function revoke_access_to_device( * Updates a specified [user identity](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity). * * @param string $user_identity_id ID of the user identity that you want to update. - * @param string $email_address Unique email address for the user identity. - * @param string $full_name Full name of the user associated with the user identity. - * @param string $phone_number Unique phone number for the user identity. - * @param string $user_identity_key Unique key for the user identity. + * @param string|NullValue $email_address Unique email address for the user identity. + * @param string|NullValue $full_name Full name of the user associated with the user identity. + * @param string|NullValue $phone_number Unique phone number for the user identity. + * @param string|NullValue $user_identity_key Unique key for the user identity. * @return void OK */ public function update( string $user_identity_id, - ?string $email_address = null, - ?string $full_name = null, - ?string $phone_number = null, - ?string $user_identity_key = null, + string|NullValue|null $email_address = null, + string|NullValue|null $full_name = null, + string|NullValue|null $phone_number = null, + string|NullValue|null $user_identity_key = null, ): void { $request_payload = []; diff --git a/src/Routes/UserIdentitiesUnmanagedClient.php b/src/Routes/UserIdentitiesUnmanagedClient.php index 92a77c0..5854023 100644 --- a/src/Routes/UserIdentitiesUnmanagedClient.php +++ b/src/Routes/UserIdentitiesUnmanagedClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; +use Seam\NullValue; use Seam\Resources\UnmanagedUserIdentity; class UserIdentitiesUnmanagedClient @@ -50,7 +51,7 @@ public function get(string $user_identity_id): UnmanagedUserIdentity * * @param string $created_before Timestamp by which to limit returned unmanaged user identities. Returns user identities created before this timestamp. * @param int $limit Maximum number of records to return per page. - * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param string|NullValue $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned unmanaged user identities to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address`, `user_identity_id` or `acs_system_id`. * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK @@ -58,7 +59,7 @@ public function get(string $user_identity_id): UnmanagedUserIdentity public function list( ?string $created_before = null, ?int $limit = null, - ?string $page_cursor = null, + string|NullValue|null $page_cursor = null, ?string $search = null, ?callable $on_response = null, ): array { diff --git a/src/Routes/WorkspacesClient.php b/src/Routes/WorkspacesClient.php index 752b182..a1336c9 100644 --- a/src/Routes/WorkspacesClient.php +++ b/src/Routes/WorkspacesClient.php @@ -5,6 +5,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\Body; use Seam\Http\ResolveActionAttempt; +use Seam\NullValue; use Seam\Resources\ActionAttempt; use Seam\Resources\Workspace; @@ -31,7 +32,7 @@ public function __construct(ClientInterface $client, array $defaults) * * @param string $name Name of the new workspace. * @param string $company_name Company name for the new workspace. - * @param string $connect_partner_name Connect partner name for the new workspace. + * @param string|NullValue $connect_partner_name Connect partner name for the new workspace. * @param mixed $connect_webview_customization [Connect Webview](https://docs.seam.co/core-concepts/connect-webviews) customizations for the new workspace. See also [Customize the Look and Feel of Your Connect Webviews](https://docs.seam.co/core-concepts/connect-webviews/customizing-connect-webviews#customize-the-look-and-feel-of-your-connect-webviews). * @param bool $is_sandbox Indicates whether the new workspace is a [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). * @param string $organization_id ID of the organization to associate with the new workspace. @@ -44,7 +45,7 @@ public function __construct(ClientInterface $client, array $defaults) public function create( string $name, ?string $company_name = null, - ?string $connect_partner_name = null, + string|NullValue|null $connect_partner_name = null, mixed $connect_webview_customization = null, ?bool $is_sandbox = null, ?string $organization_id = null, diff --git a/src/Seam.php b/src/Seam.php index d6109e6..43a5ba4 100644 --- a/src/Seam.php +++ b/src/Seam.php @@ -26,6 +26,7 @@ use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; use Seam\Http\ClientFactory; +use Seam\Http\SerializingClient; /** * Client for the Seam API. @@ -67,6 +68,10 @@ class Seam /** * The Guzzle client this instance makes its requests with. + * + * Query params given as a map and NullValue::NULL sentinels in JSON + * bodies are serialized with the Seam standard before the request goes + * out; see Seam\Http\SerializingClient. */ public ClientInterface $client; @@ -112,19 +117,20 @@ public function __construct( "timeout" => $timeout, ]); - $this->client = + $this->client = SerializingClient::wrap( $client ?? - ClientFactory::create( - Options::get_endpoint($endpoint), - Auth::get_auth_headers( - $api_key, - $personal_access_token, - $workspace_id, + ClientFactory::create( + Options::get_endpoint($endpoint), + Auth::get_auth_headers( + $api_key, + $personal_access_token, + $workspace_id, + ), + $guzzle_options, + $retries, + $timeout, ), - $guzzle_options, - $retries, - $timeout, - ); + ); $this->access_codes = new AccessCodesClient( $this->client, diff --git a/src/SeamWithoutWorkspace.php b/src/SeamWithoutWorkspace.php index a75dd84..bf8154e 100644 --- a/src/SeamWithoutWorkspace.php +++ b/src/SeamWithoutWorkspace.php @@ -4,6 +4,7 @@ use GuzzleHttp\ClientInterface; use Seam\Http\ClientFactory; +use Seam\Http\SerializingClient; use Seam\Routes\WorkspacesClient; /** @@ -47,17 +48,18 @@ public function __construct( "timeout" => $timeout, ]); - $this->client = + $this->client = SerializingClient::wrap( $client ?? - ClientFactory::create( - Options::get_endpoint($endpoint), - Auth::get_auth_headers_without_workspace( - $personal_access_token, + ClientFactory::create( + Options::get_endpoint($endpoint), + Auth::get_auth_headers_without_workspace( + $personal_access_token, + ), + $guzzle_options, + $retries, + $timeout, ), - $guzzle_options, - $retries, - $timeout, - ); + ); $this->workspaces = new WorkspacesProxy( new WorkspacesClient($this->client, [ diff --git a/tests/SearchParamsTest.php b/tests/SearchParamsTest.php new file mode 100644 index 0000000..b7582d1 --- /dev/null +++ b/tests/SearchParamsTest.php @@ -0,0 +1,283 @@ + []]; + private const DEVICE = ["device" => ["device_id" => "device1"]]; + + private function seam(RecordingClient $recording): Seam + { + return Seam::from_api_key( + "seam_apikey_token", + endpoint: "https://example.com", + guzzle_options: $recording->guzzle_options(), + ); + } + + public function testClientSerializesSearchParams(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICES), + ); + + $this->seam($recording)->client->request("GET", "/devices/list", [ + "query" => [ + "device_ids" => ["device1", "device2"], + "custom_metadata_has" => ["tag" => "front", "floor" => 2], + "limit" => 20, + ], + ]); + + $this->assertSame( + "custom_metadata_has.floor=2" . + "&custom_metadata_has.tag=front" . + "&device_ids=device1" . + "&device_ids=device2" . + "&limit=20", + $recording->request()->getUri()->getQuery(), + ); + $this->assertSame("GET", $recording->request()->getMethod()); + $this->assertSame( + "/devices/list", + $recording->request()->getUri()->getPath(), + ); + } + + /** + * The serializer and Guzzle disagree on exactly two characters: Guzzle + * escapes `*` and leaves `~` alone, so a query Guzzle encodes is not the + * one the serializer produced. Handing Guzzle the raw string keeps ours, + * including through resolution against the client's base URL. + */ + public function testClientDoesNotReencodeTheSerializedSearchParams(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICES), + ); + + $this->seam($recording)->client->request("GET", "/devices/list", [ + "query" => ["search" => "a *~ b"], + ]); + + $this->assertSame( + "search=a+*%7E+b", + $recording->request()->getUri()->getQuery(), + ); + } + + public function testClientSerializesEmptyArraysToAnEmptyValue(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICES), + ); + + $this->seam($recording)->client->request("GET", "/devices/list", [ + "query" => ["device_ids" => []], + ]); + + // Not omitted and not a bare name: the parser reads `device_ids=` + // as the empty array, while no param at all means unfiltered. + $this->assertSame( + "device_ids=", + $recording->request()->getUri()->getQuery(), + ); + } + + public function testClientOmitsSearchParamsSetToNull(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICES), + ); + + $this->seam($recording)->client->request("GET", "/devices/list", [ + "query" => ["search" => null, "limit" => 20], + ]); + + $this->assertSame( + "limit=20", + $recording->request()->getUri()->getQuery(), + ); + } + + public function testClientSerializesSearchParamsSetToNullValue(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICES), + ); + + $this->seam($recording)->client->request("GET", "/devices/list", [ + "query" => ["search" => NullValue::NULL, "limit" => 20], + ]); + + $this->assertSame( + "limit=20&search=", + $recording->request()->getUri()->getQuery(), + ); + } + + public function testClientSendsNoQueryStringWithoutSearchParams(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICES), + ); + $seam = $this->seam($recording); + + $seam->client->request("GET", "/devices/list", ["query" => []]); + $seam->client->request("GET", "/devices/list", [ + "query" => ["search" => null], + ]); + $seam->client->request("GET", "/devices/list"); + + foreach ([0, 1, 2] as $index) { + $this->assertSame( + "/devices/list", + $recording->request($index)->getRequestTarget(), + ); + } + } + + public function testClientSerializesSearchParamsOfEveryVerb(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICES), + ); + $seam = $this->seam($recording); + + $verbs = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + + foreach ($verbs as $verb) { + $seam->client->request($verb, "/devices/list", [ + "query" => ["sync" => true], + ]); + } + + foreach ($verbs as $index => $verb) { + $this->assertSame($verb, $recording->request($index)->getMethod()); + $this->assertSame( + "sync=true", + $recording->request($index)->getUri()->getQuery(), + ); + } + } + + /** + * A query already given as a string is a representation the caller + * chose, so it is forwarded to Guzzle untouched. + */ + public function testClientPassesSearchParamsItDidNotSerializeToGuzzle(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICES), + ); + + $this->seam($recording)->client->request("GET", "/devices/list", [ + "query" => "device_ids=device1", + ]); + + $this->assertSame( + "device_ids=device1", + $recording->request()->getUri()->getQuery(), + ); + } + + public function testClientRejectsASearchParamItCannotSerialize(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICES), + ); + $seam = $this->seam($recording); + + try { + $seam->client->request("GET", "/devices/list", [ + "query" => ["search" => new \SplStack()], + ]); + $this->fail("Expected an UnserializableParamError"); + } catch (UnserializableParamError $error) { + $this->assertSame("search", $error->getName()); + } + + // The error is raised before any request goes out. + $this->assertSame(0, $recording->request_count()); + } + + public function testClientSerializesNullValueInAJsonBodyToNull(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICE), + ); + + $this->seam($recording)->client->request("POST", "/devices/update", [ + "json" => (object) [ + "device_id" => "device1", + "name" => NullValue::NULL, + "properties" => ["code" => NullValue::NULL], + ], + ]); + + $this->assertSame("POST", $recording->request()->getMethod()); + $this->assertSame( + [ + "device_id" => "device1", + "name" => null, + "properties" => ["code" => null], + ], + json_decode((string) $recording->request()->getBody(), true), + ); + } + + public function testClientLeavesAJsonBodyWithoutNullValueUnchanged(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICE), + ); + + $body = [ + "device_id" => "device1", + "name" => "Front Door", + "limit" => 20, + "sync" => true, + ]; + + $this->seam($recording)->client->request("POST", "/devices/update", [ + "json" => (object) $body, + ]); + + $this->assertSame( + $body, + json_decode((string) $recording->request()->getBody(), true), + ); + } + + public function testClientSerializesTheSearchParamsOfAGeneratedRoute(): void + { + $recording = RecordingClient::repeating( + RecordingClient::json(200, self::DEVICE), + ); + + $this->seam($recording)->devices->get(name: "Front Door"); + + $this->assertSame("GET", $recording->request()->getMethod()); + $this->assertSame( + "/devices/get", + $recording->request()->getUri()->getPath(), + ); + $this->assertSame( + "name=Front+Door", + $recording->request()->getUri()->getQuery(), + ); + } +} From d75e3554c8b774b53fb4a918de3dce7c61c129ea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 04:23:58 +0000 Subject: [PATCH 3/6] 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 Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2 --- README.md | 13 +++-- src/Http/SerializingClient.php | 15 +++--- src/StrictUrlSearchParamsSerializer.php | 54 +++++++++++++++++++ tests/HeadersTest.php | 5 +- tests/SearchParamsTest.php | 15 +++--- tests/StrictUrlSearchParamsSerializerTest.php | 47 ++++++++++++++++ 6 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 src/StrictUrlSearchParamsSerializer.php create mode 100644 tests/StrictUrlSearchParamsSerializerTest.php diff --git a/README.md b/README.md index 7464a93..69a5df2 100644 --- a/README.md +++ b/README.md @@ -400,17 +400,22 @@ query strings with [`@seamapi/url-search-params-parser`](https://github.com/seamapi/url-search-params-parser). The serializer is exported for callers building requests with their own -HTTP client: +HTTP client. Use `StrictUrlSearchParamsSerializer` when calling the Seam +API: it adds `_strict=true` to any non-empty query, which tells the API to +use strict, schema-aware parsing, and is what the SDK's own requests use. A +query with no serializable params remains empty. +`UrlSearchParamsSerializer` is the same serialization without the flag — a +pure implementation of the standard. ```php -use Seam\UrlSearchParamsSerializer; +use Seam\StrictUrlSearchParamsSerializer; -$query = UrlSearchParamsSerializer::serialize([ +$query = StrictUrlSearchParamsSerializer::serialize([ "device_ids" => ["device1", "device2"], "custom_metadata_has" => ["tag" => "front"], "limit" => 20, ]); -// => 'custom_metadata_has.tag=front&device_ids=device1&device_ids=device2&limit=20' +// => 'custom_metadata_has.tag=front&device_ids=device1&device_ids=device2&limit=20&_strict=true' ``` A param that cannot be represented in the standard, such as `NAN` or a key diff --git a/src/Http/SerializingClient.php b/src/Http/SerializingClient.php index 0a37062..b9bd718 100644 --- a/src/Http/SerializingClient.php +++ b/src/Http/SerializingClient.php @@ -7,17 +7,18 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Seam\NullValue; -use Seam\UrlSearchParamsSerializer; +use Seam\StrictUrlSearchParamsSerializer; /** * Applies the Seam serialization standard to every request the wrapped * client sends. * - * Query params given as a map are serialized with UrlSearchParamsSerializer - * and handed to Guzzle as a raw query string, because Guzzle's own encoder - * follows different rules: it escapes `*`, leaves `~` alone, and drops an - * empty array instead of sending `name=`. A query already given as a string - * is a representation the caller chose, so it passes through untouched. + * Query params given as a map are serialized with + * StrictUrlSearchParamsSerializer and handed to Guzzle as a raw query + * string, because Guzzle's own encoder follows different rules: it escapes + * `*`, leaves `~` alone, and drops an empty array instead of sending + * `name=`. A query already given as a string is a representation the + * caller chose, so it passes through untouched. * * NullValue::NULL sentinels in a JSON body become JSON null, so the same * sentinel works whether a route sends a query string or a body. @@ -96,7 +97,7 @@ private static function serialize_options(array $options): array ($options["query"] instanceof \stdClass || is_array($options["query"])) ) { - $serialized = UrlSearchParamsSerializer::serialize( + $serialized = StrictUrlSearchParamsSerializer::serialize( $options["query"], ); diff --git a/src/StrictUrlSearchParamsSerializer.php b/src/StrictUrlSearchParamsSerializer.php new file mode 100644 index 0000000..15c10db --- /dev/null +++ b/src/StrictUrlSearchParamsSerializer.php @@ -0,0 +1,54 @@ +|\stdClass $params + * + * @throws UnserializableParamError If any param could not be serialized + */ + public static function serialize(array|\stdClass $params): string + { + $search_params = new UrlSearchParams(); + self::update($search_params, $params); + + return $search_params->to_string(); + } + + /** + * Updates existing URL search params with serialized params and strict + * API validation enabled. + * + * @param array|\stdClass $params + * + * @throws UnserializableParamError If any param could not be serialized + */ + public static function update( + UrlSearchParams $search_params, + array|\stdClass $params, + ): void { + UrlSearchParamsSerializer::update($search_params, $params); + + if (count($search_params) > 0) { + // Replaced rather than repeated, and appended after the sort so + // it always sits last. + $search_params->delete("_strict"); + $search_params->append("_strict", "true"); + } + } +} diff --git a/tests/HeadersTest.php b/tests/HeadersTest.php index 6afecd9..520d08f 100644 --- a/tests/HeadersTest.php +++ b/tests/HeadersTest.php @@ -35,7 +35,10 @@ public function testSendsDefaultHeaders(): void $request = $recorder->request(); $this->assertSame("/devices/get", $request->getUri()->getPath()); - $this->assertSame("device_id=d1", $request->getUri()->getQuery()); + $this->assertSame( + "device_id=d1&_strict=true", + $request->getUri()->getQuery(), + ); $this->assertSame( "Bearer seam_apikey_token", diff --git a/tests/SearchParamsTest.php b/tests/SearchParamsTest.php index b7582d1..57d70d3 100644 --- a/tests/SearchParamsTest.php +++ b/tests/SearchParamsTest.php @@ -47,7 +47,8 @@ public function testClientSerializesSearchParams(): void "&custom_metadata_has.tag=front" . "&device_ids=device1" . "&device_ids=device2" . - "&limit=20", + "&limit=20" . + "&_strict=true", $recording->request()->getUri()->getQuery(), ); $this->assertSame("GET", $recording->request()->getMethod()); @@ -74,7 +75,7 @@ public function testClientDoesNotReencodeTheSerializedSearchParams(): void ]); $this->assertSame( - "search=a+*%7E+b", + "search=a+*%7E+b&_strict=true", $recording->request()->getUri()->getQuery(), ); } @@ -92,7 +93,7 @@ public function testClientSerializesEmptyArraysToAnEmptyValue(): void // Not omitted and not a bare name: the parser reads `device_ids=` // as the empty array, while no param at all means unfiltered. $this->assertSame( - "device_ids=", + "device_ids=&_strict=true", $recording->request()->getUri()->getQuery(), ); } @@ -108,7 +109,7 @@ public function testClientOmitsSearchParamsSetToNull(): void ]); $this->assertSame( - "limit=20", + "limit=20&_strict=true", $recording->request()->getUri()->getQuery(), ); } @@ -124,7 +125,7 @@ public function testClientSerializesSearchParamsSetToNullValue(): void ]); $this->assertSame( - "limit=20&search=", + "limit=20&search=&_strict=true", $recording->request()->getUri()->getQuery(), ); } @@ -168,7 +169,7 @@ public function testClientSerializesSearchParamsOfEveryVerb(): void foreach ($verbs as $index => $verb) { $this->assertSame($verb, $recording->request($index)->getMethod()); $this->assertSame( - "sync=true", + "sync=true&_strict=true", $recording->request($index)->getUri()->getQuery(), ); } @@ -276,7 +277,7 @@ public function testClientSerializesTheSearchParamsOfAGeneratedRoute(): void $recording->request()->getUri()->getPath(), ); $this->assertSame( - "name=Front+Door", + "name=Front+Door&_strict=true", $recording->request()->getUri()->getQuery(), ); } diff --git a/tests/StrictUrlSearchParamsSerializerTest.php b/tests/StrictUrlSearchParamsSerializerTest.php new file mode 100644 index 0000000..85dd2c4 --- /dev/null +++ b/tests/StrictUrlSearchParamsSerializerTest.php @@ -0,0 +1,47 @@ +assertSame("", StrictUrlSearchParamsSerializer::serialize([])); + $this->assertSame( + "foo=d&_strict=true", + StrictUrlSearchParamsSerializer::serialize(["foo" => "d"]), + ); + } + + public function testAppendsStrictAfterTheSortedParams(): void + { + // `_strict` would sort between the uppercase and lowercase names; + // it is appended after the sort so it always sits last. + $this->assertSame( + "B=2&a=1&_strict=true", + StrictUrlSearchParamsSerializer::serialize(["a" => 1, "B" => 2]), + ); + } + + public function testReplacesACallerSuppliedStrictParam(): void + { + $this->assertSame( + "_strict=true", + StrictUrlSearchParamsSerializer::serialize(["_strict" => false]), + ); + } + + public function testUpdateCountsExistingParamsAsNonEmpty(): void + { + $search_params = new UrlSearchParams([["foo", "bar"]]); + StrictUrlSearchParamsSerializer::update($search_params, []); + + $this->assertSame("foo=bar&_strict=true", $search_params->to_string()); + } +} From d6948748bc88628ddd63ec35bea6c5b2aa1ad0f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 04:44:17 +0000 Subject: [PATCH 4/6] 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 Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2 --- src/Http/SerializingClient.php | 2 -- src/NullValue.php | 3 --- src/StrictUrlSearchParamsSerializer.php | 2 -- src/UnserializableParamError.php | 6 ++---- src/UrlSearchParamsSerializer.php | 7 ++----- tests/NullValueTest.php | 1 - tests/SearchParamsTest.php | 17 ----------------- tests/StrictUrlSearchParamsSerializerTest.php | 2 -- tests/UrlSearchParamsSerializerTest.php | 8 -------- 9 files changed, 4 insertions(+), 44 deletions(-) diff --git a/src/Http/SerializingClient.php b/src/Http/SerializingClient.php index b9bd718..7e58742 100644 --- a/src/Http/SerializingClient.php +++ b/src/Http/SerializingClient.php @@ -102,8 +102,6 @@ private static function serialize_options(array $options): array ); if ($serialized === "") { - // Nothing serialized must mean no query at all, not a bare - // trailing `?`. unset($options["query"]); } else { $options["query"] = $serialized; diff --git a/src/NullValue.php b/src/NullValue.php index 56b11a2..75fbeed 100644 --- a/src/NullValue.php +++ b/src/NullValue.php @@ -32,9 +32,6 @@ enum NullValue { /** * Sentinel for a param explicitly set to null. - * - * An enum case cannot be instantiated or subclassed, so this is the only - * value of this type, and `$value instanceof NullValue` detects it. */ case NULL; diff --git a/src/StrictUrlSearchParamsSerializer.php b/src/StrictUrlSearchParamsSerializer.php index 15c10db..a01be99 100644 --- a/src/StrictUrlSearchParamsSerializer.php +++ b/src/StrictUrlSearchParamsSerializer.php @@ -45,8 +45,6 @@ public static function update( UrlSearchParamsSerializer::update($search_params, $params); if (count($search_params) > 0) { - // Replaced rather than repeated, and appended after the sort so - // it always sits last. $search_params->delete("_strict"); $search_params->append("_strict", "true"); } diff --git a/src/UnserializableParamError.php b/src/UnserializableParamError.php index c127ca3..7bc12e8 100644 --- a/src/UnserializableParamError.php +++ b/src/UnserializableParamError.php @@ -3,10 +3,8 @@ namespace Seam; /** - * Error thrown when a request param could not be serialized. - * - * It is thrown before any request is sent, so it never means the API - * rejected the param, only that this SDK could not represent it. + * Error thrown when a request param could not be serialized, before any + * request is sent. */ class UnserializableParamError extends \InvalidArgumentException { diff --git a/src/UrlSearchParamsSerializer.php b/src/UrlSearchParamsSerializer.php index 8ad082e..f0fad8d 100644 --- a/src/UrlSearchParamsSerializer.php +++ b/src/UrlSearchParamsSerializer.php @@ -286,9 +286,8 @@ private static function format_number(string $name, float $value): string private static function shortest_digits(float $value): array { // At -1, PHP's float-to-string conversion produces the shortest - // string that round-trips, which is exactly the digit string the - // algorithm needs. The ini setting is restored because it also - // affects the caller's own serialize() and json_encode() calls. + // string that round-trips. Restored because the setting also affects + // the caller's own serialize() and json_encode() calls. $precision = ini_set("serialize_precision", "-1"); try { @@ -306,8 +305,6 @@ private static function shortest_digits(float $value): array $matches, ) !== 1 ) { - // Unreachable for a finite positive float, but a silent - // mis-parse would serialize a wrong number. throw new \RuntimeException( "Could not parse the PHP float representation: {$repr}", ); diff --git a/tests/NullValueTest.php b/tests/NullValueTest.php index 3131ad5..4dacdf7 100644 --- a/tests/NullValueTest.php +++ b/tests/NullValueTest.php @@ -67,7 +67,6 @@ public function testReplaceCopiesStdClassObjectsWithoutMutatingThem(): void $this->assertNotSame($payload, $replaced); $this->assertNull($replaced->name); $this->assertNull($replaced->nested->code); - // The caller's payload is untouched. $this->assertSame(NullValue::NULL, $payload->name); $this->assertSame(NullValue::NULL, $payload->nested->code); } diff --git a/tests/SearchParamsTest.php b/tests/SearchParamsTest.php index 57d70d3..eda7adb 100644 --- a/tests/SearchParamsTest.php +++ b/tests/SearchParamsTest.php @@ -10,10 +10,6 @@ use Seam\UnserializableParamError; use Tests\Support\RecordingClient; -/** - * Asserts on the raw query string the client puts on the wire, not on a - * re-parsed version of it, which would hide encoding differences. - */ final class SearchParamsTest extends TestCase { private const DEVICES = ["devices" => []]; @@ -58,12 +54,6 @@ public function testClientSerializesSearchParams(): void ); } - /** - * The serializer and Guzzle disagree on exactly two characters: Guzzle - * escapes `*` and leaves `~` alone, so a query Guzzle encodes is not the - * one the serializer produced. Handing Guzzle the raw string keeps ours, - * including through resolution against the client's base URL. - */ public function testClientDoesNotReencodeTheSerializedSearchParams(): void { $recording = RecordingClient::repeating( @@ -90,8 +80,6 @@ public function testClientSerializesEmptyArraysToAnEmptyValue(): void "query" => ["device_ids" => []], ]); - // Not omitted and not a bare name: the parser reads `device_ids=` - // as the empty array, while no param at all means unfiltered. $this->assertSame( "device_ids=&_strict=true", $recording->request()->getUri()->getQuery(), @@ -175,10 +163,6 @@ public function testClientSerializesSearchParamsOfEveryVerb(): void } } - /** - * A query already given as a string is a representation the caller - * chose, so it is forwarded to Guzzle untouched. - */ public function testClientPassesSearchParamsItDidNotSerializeToGuzzle(): void { $recording = RecordingClient::repeating( @@ -211,7 +195,6 @@ public function testClientRejectsASearchParamItCannotSerialize(): void $this->assertSame("search", $error->getName()); } - // The error is raised before any request goes out. $this->assertSame(0, $recording->request_count()); } diff --git a/tests/StrictUrlSearchParamsSerializerTest.php b/tests/StrictUrlSearchParamsSerializerTest.php index 85dd2c4..e75affc 100644 --- a/tests/StrictUrlSearchParamsSerializerTest.php +++ b/tests/StrictUrlSearchParamsSerializerTest.php @@ -21,8 +21,6 @@ public function testAddsStrictToNonEmptyQueryStrings(): void public function testAppendsStrictAfterTheSortedParams(): void { - // `_strict` would sort between the uppercase and lowercase names; - // it is appended after the sort so it always sits last. $this->assertSame( "B=2&a=1&_strict=true", StrictUrlSearchParamsSerializer::serialize(["a" => 1, "B" => 2]), diff --git a/tests/UrlSearchParamsSerializerTest.php b/tests/UrlSearchParamsSerializerTest.php index 60778cb..96f9ace 100644 --- a/tests/UrlSearchParamsSerializerTest.php +++ b/tests/UrlSearchParamsSerializerTest.php @@ -36,7 +36,6 @@ public function testSerializesString(): void public function testRemovesTheEmptyString(): void { - // Serializing the empty string would conflict with NullValue::NULL. $this->assertSame("", self::serialize(["foo" => ""])); $this->assertSame( "foo=d", @@ -75,8 +74,6 @@ public function testSerializesFloat(): void public function testSerializesFloatUsingTheEcmascriptNumberFormat(): void { - // A float is serialized exactly as JavaScript would serialize the - // number, which is not always the same as the PHP string cast. $this->assertSame("foo=1", self::serialize(["foo" => 1.0])); $this->assertSame("foo=0", self::serialize(["foo" => -0.0])); $this->assertSame("foo=100", self::serialize(["foo" => 100.0])); @@ -200,7 +197,6 @@ public function testSerializesMutableDatetime(): void "now=2025-02-24T18%3A44%3A39.000Z", self::serialize(["now" => $now]), ); - // Converting to UTC must not mutate the caller's value. $this->assertSame("2025-02-24T18:44:39+00:00", $now->format("c")); } @@ -342,8 +338,6 @@ public function testSortsParamsByName(): void public function testSortsParamsByUtf16CodeUnit(): void { - // UTF-8 byte order and code-point order would both put U+FFFF first; - // only UTF-16 code-unit order puts the astral emoji first. $this->assertSame( "%F0%9F%98%80=2&%EF%BF%BF=1", self::serialize(["\u{FFFF}" => 1, "\u{1F600}" => 2]), @@ -407,8 +401,6 @@ public function testCannotSerializeNestedKeysContainingADot(): void public function testCannotSerializeNonStringKeys(): void { - // PHP casts a numeric-string key to an integer, so both spellings - // arrive here as the same unserializable key. $this->expectException(UnserializableParamError::class); self::serialize(["foo" => [1 => "a", "b" => "c"]]); From 07ad3469d5ba1a7b7e4d695fe1b2b3fd98a6171d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 05:59:05 +0000 Subject: [PATCH 5/6] 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 Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2 --- README.md | 160 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 96 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 69a5df2..4741a98 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,39 @@ $seam->locks->unlock_door( ); ``` +### Setting a param to null + +The Seam API distinguishes three states for an updatable param: +omitted (leave the stored value unchanged), null (unset the stored value), +and a value (set it). + +PHP's `null` means omitted. +The SDK removes `null` params from the request entirely, +so passing `null` never unsets a value. +To unset a value, pass the `Seam\NullValue::NULL` sentinel, +which the SDK sends as JSON `null` in request bodies +and as an empty value in query strings: + +```php +use Seam\NullValue; + +// Leaves the name unchanged. +$seam->devices->update(device_id: $device_id, name: null); + +// Unsets the name. +$seam->devices->update(device_id: $device_id, name: NullValue::NULL); +``` + +The other Seam SDKs spell the sentinel `NULL` and its type `Null`, +but those names are reserved in PHP, so both live on one enum: +`Seam\NullValue` is the type, and its single case `NullValue::NULL` +is the value to pass. + +Only pass `NullValue::NULL` for params the API documents as nullable. +Generated methods type nullable params as a union with the sentinel, +e.g. `string|NullValue|null`, so passing it anywhere else fails with a +`TypeError`. + ### Pagination Some Seam API endpoints that return lists of resources support pagination. @@ -358,70 +391,6 @@ $seam = new Seam\Seam(retries: 5); $seam = new Seam\Seam(retries: 0); ``` -#### Setting a param to null - -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. PHP has a single absence value, so -the SDK spells the two states differently: - -- `null`, or simply omitting the param, means **omit**: the param is not - sent at all. -- `Seam\NullValue::NULL` means **send null**: the value is unset. - -Since unsetting a value cannot be undone, it is never the default and is -always spelled explicitly. - -```php -use Seam\NullValue; - -// Unset the device name. -$seam->devices->update(device_id: $device_id, name: NullValue::NULL); - -// Leave the device name unchanged. -$seam->devices->update(device_id: $device_id, name: null); -``` - -Only params the API documents as nullable accept the sentinel: a nullable -param is typed `string|NullValue|null`, while a merely optional one is typed -`?string` and rejects it. The sentinel works on every route, whether the -request is sent as a query string (serialized as `name=`) or as a JSON body -(serialized as `null`). - -#### URL search params serialization - -Requests with query params follow the [Seam URL search params serialization -standard](https://github.com/seamapi/url-search-params-serializer): nested -objects join keys with dots (`{"page": {"size": 10}}` becomes -`page.size=10`), arrays repeat the name (`ids=a&ids=b`), an empty array -serializes to an empty value (`ids=`), and values are encoded and sorted -exactly as JavaScript's `URLSearchParams` would. Servers can read such -query strings with -[`@seamapi/url-search-params-parser`](https://github.com/seamapi/url-search-params-parser). - -The serializer is exported for callers building requests with their own -HTTP client. Use `StrictUrlSearchParamsSerializer` when calling the Seam -API: it adds `_strict=true` to any non-empty query, which tells the API to -use strict, schema-aware parsing, and is what the SDK's own requests use. A -query with no serializable params remains empty. -`UrlSearchParamsSerializer` is the same serialization without the flag — a -pure implementation of the standard. - -```php -use Seam\StrictUrlSearchParamsSerializer; - -$query = StrictUrlSearchParamsSerializer::serialize([ - "device_ids" => ["device1", "device2"], - "custom_metadata_has" => ["tag" => "front"], - "limit" => 20, -]); -// => 'custom_metadata_has.tag=front&device_ids=device1&device_ids=device2&limit=20&_strict=true' -``` - -A param that cannot be represented in the standard, such as `NAN` or a key -containing a dot, raises `Seam\UnserializableParamError` before any request -is sent. - #### Using the Guzzle client `$seam->client` is the [Guzzle] client, already carrying the endpoint and @@ -452,6 +421,69 @@ $client = new GuzzleHttp\Client([ $seam = Seam\Seam::from_client($client); ``` +#### Serializing URL search params + +The Seam API parses URL search params as complex types. +If you call it with your own HTTP client, +`Seam\StrictUrlSearchParamsSerializer` is exported for that purpose. +The `_strict=true` param is added to any non-empty query +so the Seam API uses strict, schema-aware parsing. +A query with no serializable params remains empty. + +```php +use Seam\StrictUrlSearchParamsSerializer; + +$query = StrictUrlSearchParamsSerializer::serialize([ + "device_ids" => ["device1", "device2"], +]); + +$response = file_get_contents( + "https://connect.getseam.com/devices/list?{$query}", + context: stream_context_create([ + "http" => ["header" => "Authorization: Bearer your-api-key"], + ]), +); +``` + +The serialization defines the name and value of each search param, +where every value is a string. +`Seam\UrlSearchParams` holds those pairs and renders the query string, +as [URLSearchParams] does for the [reference implementation]: + +```php +use Seam\StrictUrlSearchParamsSerializer; +use Seam\UrlSearchParams; + +$search_params = new UrlSearchParams(); + +StrictUrlSearchParamsSerializer::update($search_params, [ + "device_ids" => ["device1", "device2"], +]); + +iterator_to_array($search_params); +// => [["device_ids", "device1"], ["device_ids", "device2"], ["_strict", "true"]] + +(string) $search_params; +// => 'device_ids=device1&device_ids=device2&_strict=true' +``` + +Pass either the query string or the pairs to your HTTP client. +A client may percent-encode a few characters differently than +`URLSearchParams` does, e.g. Guzzle escapes `*` and leaves `~` unescaped, +which the Seam API reads as the same params either way. + +A param set to `null` is omitted, +while a param set to `NullValue::NULL` is serialized to an empty value, +which the Seam API reads as null, +as described in [Setting a param to null](#setting-a-param-to-null). +A param that cannot be represented raises a `Seam\UnserializableParamError`. + +The Seam API parses these params with the corresponding [parser]. + +[URLSearchParams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams +[reference implementation]: https://github.com/seamapi/url-search-params-serializer +[parser]: https://github.com/seamapi/url-search-params-parser + #### Errors Every exception the SDK raises implements `Seam\SeamException`, so it can be From 9d8d2e5138b66c5655c5b11e922f53071ba23aab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 06:01:46 +0000 Subject: [PATCH 6/6] 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 Claude-Session: https://claude.ai/code/session_01HMKbYgbsoFdKiiaxXB6jh2 --- src/UrlSearchParams.php | 70 ++----------------------- tests/UrlSearchParamsSerializerTest.php | 8 --- 2 files changed, 5 insertions(+), 73 deletions(-) diff --git a/src/UrlSearchParams.php b/src/UrlSearchParams.php index 6754a37..d16f67c 100644 --- a/src/UrlSearchParams.php +++ b/src/UrlSearchParams.php @@ -150,22 +150,16 @@ public function delete(string $name): void } /** - * Sorts all pairs by name. + * Sorts all pairs by name, comparing bytes. * * Sorting is stable, so the relative order of pairs with the same name - * is preserved. Names are compared by UTF-16 code units to match the - * URLSearchParams.sort() specification - * (https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/sort). + * is preserved, which is what keeps array element order. Byte order + * matches URLSearchParams.sort() for ASCII names; a name beyond the + * Basic Multilingual Plane may sort differently than in JavaScript. */ public function sort(): void { - usort( - $this->pairs, - fn(array $a, array $b) => strcmp( - self::utf16_sort_key($a[0]), - self::utf16_sort_key($b[0]), - ), - ); + usort($this->pairs, fn(array $a, array $b) => strcmp($a[0], $b[0])); } /** @@ -244,58 +238,4 @@ private static function encode_form_component(string $value): string return $encoded; } - - /** - * Converts a UTF-8 name to its UTF-16 big-endian encoding, so a byte - * comparison of the keys orders names by UTF-16 code unit. - * - * This is not code-point order and not UTF-8 byte order: both would sort - * anything above the Basic Multilingual Plane after U+E000 to U+FFFF, - * while surrogate pairs (0xD800 to 0xDFFF) sort below them. - */ - private static function utf16_sort_key(string $name): string - { - $key = ""; - $length = strlen($name); - $i = 0; - - while ($i < $length) { - $byte = ord($name[$i]); - - if ($byte < 0x80) { - $code_point = $byte; - $i += 1; - } elseif (($byte & 0xe0) === 0xc0) { - $code_point = - (($byte & 0x1f) << 6) | (ord($name[$i + 1]) & 0x3f); - $i += 2; - } elseif (($byte & 0xf0) === 0xe0) { - $code_point = - (($byte & 0x0f) << 12) | - ((ord($name[$i + 1]) & 0x3f) << 6) | - (ord($name[$i + 2]) & 0x3f); - $i += 3; - } else { - $code_point = - (($byte & 0x07) << 18) | - ((ord($name[$i + 1]) & 0x3f) << 12) | - ((ord($name[$i + 2]) & 0x3f) << 6) | - (ord($name[$i + 3]) & 0x3f); - $i += 4; - } - - if ($code_point >= 0x10000) { - $code_point -= 0x10000; - $key .= pack( - "n2", - 0xd800 | ($code_point >> 10), - 0xdc00 | ($code_point & 0x3ff), - ); - } else { - $key .= pack("n", $code_point); - } - } - - return $key; - } } diff --git a/tests/UrlSearchParamsSerializerTest.php b/tests/UrlSearchParamsSerializerTest.php index 96f9ace..764a4fc 100644 --- a/tests/UrlSearchParamsSerializerTest.php +++ b/tests/UrlSearchParamsSerializerTest.php @@ -336,14 +336,6 @@ public function testSortsParamsByName(): void ); } - public function testSortsParamsByUtf16CodeUnit(): void - { - $this->assertSame( - "%F0%9F%98%80=2&%EF%BF%BF=1", - self::serialize(["\u{FFFF}" => 1, "\u{1F600}" => 2]), - ); - } - public function testSortingPreservesArrayOrder(): void { $this->assertSame(