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
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ Console.WriteLine("First Device Name: " + myDevices[0].Properties.Name);
var accessCode = seam.AccessCodes.Create(deviceId: myDevices[0].DeviceId, code: "1234");
```

### Setting a value to null

The Seam API distinguishes three states for an updatable parameter:
omitted (leave the stored value unchanged), null (unset the stored value),
and a value (set it).

C#'s `null` means omitted.
The SDK removes `null` parameters from the request entirely,
so passing `null` never unsets a value.
To unset a value, pass the `Null.Value` sentinel,
which the SDK sends as JSON `null` in request bodies
and as an empty value in query strings:

```csharp
// Omits custom_metadata, leaving the stored metadata unchanged.
seam.Devices.Update(deviceId: deviceId, customMetadata: null);

// Unsets the sync key of the stored metadata.
seam.Devices.Update(
deviceId: deviceId,
customMetadata: new Dictionary<string, object> { ["sync"] = Null.Value }
);
```

Only pass `Null.Value` where the Seam API documents a value as nullable.
A parameter typed as a specific C# type, e.g. `string?`, does not accept the
sentinel: pass it wherever a parameter is typed `object`, and to the URL search
params serializer below.

## Advanced Usage

### Setting the request timeout
Expand All @@ -39,6 +68,69 @@ The default may also be changed for every client at once:
GlobalSeamRequestConfiguration.Instance.Timeout = 60000;
```

### Serializing URL search params

The Seam API parses URL search params as complex types.
The SDK serializes the params of every endpoint
the Seam API prefers to receive as a GET or DELETE this way.
If you call the API with your own HTTP client,
`StrictUrlSearchParamsSerializer` is exported for that purpose.
The `_strict=true` parameter is added to any non-empty query
so the Seam API uses strict, schema-aware parsing.
A query with no serializable parameters remains empty.

```csharp
using Seam.Client;

var query = StrictUrlSearchParamsSerializer.Serialize(
new Dictionary<string, object> { ["device_ids"] = new[] { "device1", "device2" } }
);

using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", "Bearer your-api-key");

var devices = await client.GetStringAsync($"https://connect.getseam.com/devices/list?{query}");
```

The serialization defines the name and value of each search param,
where every value is a string.
`UrlSearchParams` holds those pairs and renders the query string,
as [URLSearchParams] does for the [reference implementation]:

```csharp
using Seam.Client;

var searchParams = new UrlSearchParams();

StrictUrlSearchParamsSerializer.Update(
searchParams,
new Dictionary<string, object> { ["device_ids"] = new[] { "device1", "device2" } }
);

searchParams.Select(pair => (pair.Key, pair.Value)).ToList();
// => [("device_ids", "device1"), ("device_ids", "device2"), ("_strict", "true")]

searchParams.ToString();
// => "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,
which the Seam API reads as the same params either way.

A parameter set to `null` is omitted,
while a parameter set to `Null.Value` is serialized to an empty value,
which the Seam API reads as null,
as described in [Setting a value to null](#setting-a-value-to-null).
A parameter that cannot be represented throws an `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

## Development and Testing

### Quickstart
Expand Down
8 changes: 4 additions & 4 deletions codegen/layouts/partials/route-methods.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ public {{#if isVoid}}void{{else}}{{returnType}}{{/if}} {{methodName}}({{methodNa
var requestOptions = new RequestOptions();
requestOptions.Data = request;
{{#if isVoid}}
_seam.Post<object>("{{path}}", requestOptions);
_seam.{{httpMethod}}<object>("{{path}}", requestOptions);
{{else}}
return _seam.Post<{{responseTypeArg}}>("{{path}}", requestOptions).EnsureData("{{path}}").{{returnProp}};
return _seam.{{httpMethod}}<{{responseTypeArg}}>("{{path}}", requestOptions).EnsureData("{{path}}").{{returnProp}};
{{/if}}
}

Expand All @@ -26,9 +26,9 @@ public async {{#if isVoid}}Task{{else}}Task<{{returnType}}>{{/if}} {{methodName}
var requestOptions = new RequestOptions();
requestOptions.Data = request;
{{#if isVoid}}
await _seam.PostAsync<object>("{{path}}", requestOptions);
await _seam.{{httpMethod}}Async<object>("{{path}}", requestOptions);
{{else}}
return (await _seam.PostAsync<{{responseTypeArg}}>("{{path}}", requestOptions)).EnsureData("{{path}}").{{returnProp}};
return (await _seam.{{httpMethod}}Async<{{responseTypeArg}}>("{{path}}", requestOptions)).EnsureData("{{path}}").{{returnProp}};
{{/if}}
}

Expand Down
3 changes: 3 additions & 0 deletions codegen/lib/build-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,7 @@ export const buildApiFile = (
): CsApiFile => {
const routes: CsRoute[] = endpoints.map((endpoint) => {
const methodName = pascalCase(endpoint.name)
const httpMethod = pascalCase(endpoint.request.preferredMethod)

const request = buildClass(
pascalCase(`${endpoint.name}_request`),
Expand Down Expand Up @@ -696,6 +697,7 @@ export const buildApiFile = (
return {
methodName,
path: endpoint.path,
httpMethod,
request: request.main,
requestSiblings: request.siblings,
responseSiblings: [],
Expand Down Expand Up @@ -727,6 +729,7 @@ export const buildApiFile = (
return {
methodName,
path: endpoint.path,
httpMethod,
request: request.main,
requestSiblings: request.siblings,
response: response.main,
Expand Down
7 changes: 6 additions & 1 deletion codegen/lib/class-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,18 @@ export interface CsModelFile {
export interface CsRoute {
methodName: string
path: string
// The client method for the endpoint's preferred HTTP method, e.g. `Get` for
// `_seam.Get<T>(...)`. The client decides from the method whether the request
// parameters travel as a query string or as a JSON body.
httpMethod: string
request: CsClass
// Sibling classes spawned by inline-object request/response properties,
// rendered (nested) inside the Api class after the request/response class.
requestSiblings: CsClass[]
responseSiblings: CsClass[]
response?: CsClass
// The type argument to _seam.Post<T> (the response class, or `object` for void).
// The type argument to the client method (the response class, or `object` for
// void).
responseTypeArg: string
// The `.Data.<returnProp>` accessor tail (absent for void).
returnProp?: string
Expand Down
53 changes: 53 additions & 0 deletions output/csharp/src/Seam.Test/Client/NullTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace Seam.Test;

using Newtonsoft.Json;
using Seam.Client;

public class NullTests
{
[Fact]
public void SerializesToJsonNull()
{
Assert.Equal("null", JsonConvert.SerializeObject(Null.Value));
}

[Fact]
public void SerializesToJsonNullInsideARequestBody()
{
var body = new Dictionary<string, object?>
{
["name"] = Null.Value,
["limit"] = 20,
["nested"] = new Dictionary<string, object?> { ["key"] = Null.Value },
["list"] = new object?[] { Null.Value },
};

Assert.Equal(
"{\"name\":null,\"limit\":20,\"nested\":{\"key\":null},\"list\":[null]}",
JsonConvert.SerializeObject(body)
);
}

[Fact]
public void SerializesToJsonNullUnderTheClientSerializerSettings()
{
var request = new Api.Devices.UpdateRequest(
deviceId: "device1",
customMetadata: new Dictionary<string, object?> { ["sync"] = Null.Value }
);

var json = JsonConvert.SerializeObject(
request,
new SeamClient(apiToken: "seam_apikey").SerializerSettings
);

Assert.Equal("{\"custom_metadata\":{\"sync\":null},\"device_id\":\"device1\"}", json);
}

[Fact]
public void IsASingleton()
{
Assert.Same(Null.Value, Null.Value);
Assert.Equal("null", Null.Value.ToString());
}
}
Loading
Loading