diff --git a/README.md b/README.md index 5366cad..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. @@ -388,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 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..7e58742 --- /dev/null +++ b/src/Http/SerializingClient.php @@ -0,0 +1,117 @@ +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 = StrictUrlSearchParamsSerializer::serialize( + $options["query"], + ); + + if ($serialized === "") { + 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/NullValue.php b/src/NullValue.php new file mode 100644 index 0000000..75fbeed --- /dev/null +++ b/src/NullValue.php @@ -0,0 +1,67 @@ + 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. + */ + 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/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/src/StrictUrlSearchParamsSerializer.php b/src/StrictUrlSearchParamsSerializer.php new file mode 100644 index 0000000..a01be99 --- /dev/null +++ b/src/StrictUrlSearchParamsSerializer.php @@ -0,0 +1,52 @@ +|\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) { + $search_params->delete("_strict"); + $search_params->append("_strict", "true"); + } + } +} diff --git a/src/UnserializableParamError.php b/src/UnserializableParamError.php new file mode 100644 index 0000000..7bc12e8 --- /dev/null +++ b/src/UnserializableParamError.php @@ -0,0 +1,26 @@ +name; + } +} diff --git a/src/UrlSearchParams.php b/src/UrlSearchParams.php new file mode 100644 index 0000000..d16f67c --- /dev/null +++ b/src/UrlSearchParams.php @@ -0,0 +1,241 @@ + + */ +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, comparing bytes. + * + * Sorting is stable, so the relative order of pairs with the same name + * 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($a[0], $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; + } +} diff --git a/src/UrlSearchParamsSerializer.php b/src/UrlSearchParamsSerializer.php new file mode 100644 index 0000000..f0fad8d --- /dev/null +++ b/src/UrlSearchParamsSerializer.php @@ -0,0 +1,354 @@ +|\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. Restored because the setting 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 + ) { + 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/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/NullValueTest.php b/tests/NullValueTest.php new file mode 100644 index 0000000..4dacdf7 --- /dev/null +++ b/tests/NullValueTest.php @@ -0,0 +1,78 @@ +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); + $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/SearchParamsTest.php b/tests/SearchParamsTest.php new file mode 100644 index 0000000..eda7adb --- /dev/null +++ b/tests/SearchParamsTest.php @@ -0,0 +1,267 @@ + []]; + 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" . + "&_strict=true", + $recording->request()->getUri()->getQuery(), + ); + $this->assertSame("GET", $recording->request()->getMethod()); + $this->assertSame( + "/devices/list", + $recording->request()->getUri()->getPath(), + ); + } + + 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&_strict=true", + $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" => []], + ]); + + $this->assertSame( + "device_ids=&_strict=true", + $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&_strict=true", + $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=&_strict=true", + $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&_strict=true", + $recording->request($index)->getUri()->getQuery(), + ); + } + } + + 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()); + } + + $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&_strict=true", + $recording->request()->getUri()->getQuery(), + ); + } +} diff --git a/tests/StrictUrlSearchParamsSerializerTest.php b/tests/StrictUrlSearchParamsSerializerTest.php new file mode 100644 index 0000000..e75affc --- /dev/null +++ b/tests/StrictUrlSearchParamsSerializerTest.php @@ -0,0 +1,45 @@ +assertSame("", StrictUrlSearchParamsSerializer::serialize([])); + $this->assertSame( + "foo=d&_strict=true", + StrictUrlSearchParamsSerializer::serialize(["foo" => "d"]), + ); + } + + public function testAppendsStrictAfterTheSortedParams(): void + { + $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()); + } +} diff --git a/tests/UrlSearchParamsSerializerTest.php b/tests/UrlSearchParamsSerializerTest.php new file mode 100644 index 0000000..764a4fc --- /dev/null +++ b/tests/UrlSearchParamsSerializerTest.php @@ -0,0 +1,645 @@ +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 + { + $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 + { + $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]), + ); + $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 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 + { + $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"]]), + ); + } +}