Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions codegen/layouts/seam-client.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
27 changes: 25 additions & 2 deletions codegen/lib/layouts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 => {
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
117 changes: 117 additions & 0 deletions src/Http/SerializingClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

namespace Seam\Http;

use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Seam\NullValue;
use Seam\StrictUrlSearchParamsSerializer;

/**
* Applies the Seam serialization standard to every request the wrapped
* client sends.
*
* Query params given as a map are serialized with
* StrictUrlSearchParamsSerializer and handed to Guzzle as a raw query
* string, because Guzzle's own encoder follows different rules: it escapes
* `*`, leaves `~` alone, and drops an empty array instead of sending
* `name=`. A query already given as a string is a representation the
* caller chose, so it passes through untouched.
*
* NullValue::NULL sentinels in a JSON body become JSON null, so the same
* sentinel works whether a route sends a query string or a body.
*/
final class SerializingClient implements ClientInterface
{
private function __construct(private ClientInterface $client) {}

/**
* Wraps a client, or returns it unchanged when it is already wrapped.
*/
public static function wrap(ClientInterface $client): self
{
return $client instanceof self ? $client : new self($client);
}

#[\Override]
public function send(
RequestInterface $request,
array $options = [],
): ResponseInterface {
return $this->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<string, mixed> $options
* @return array<string, mixed>
*/
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;
}
}
67 changes: 67 additions & 0 deletions src/NullValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace Seam;

/**
* The explicit null sentinel used by request params.
*
* PHP has a single absence value, null, but the Seam API distinguishes an
* omitted param from a param explicitly set to null. For example, in an
* update request, an omitted param leaves the current value unchanged,
* while a null param unsets the current value.
*
* Since sending null is rarely intended and unsetting a value cannot be
* undone, null means the safe option of omitting the param. Sending null is
* explicit and always spelled NullValue::NULL:
*
* ```php
* use Seam\NullValue;
* use Seam\UrlSearchParamsSerializer;
*
* UrlSearchParamsSerializer::serialize(["name" => 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;
}
}
Loading
Loading