diff --git a/README.md b/README.md index 4ffaf6a..470ffe7 100644 --- a/README.md +++ b/README.md @@ -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 { ["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 @@ -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 { ["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 { ["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 diff --git a/codegen/layouts/partials/route-methods.hbs b/codegen/layouts/partials/route-methods.hbs index fc39be9..068de21 100644 --- a/codegen/layouts/partials/route-methods.hbs +++ b/codegen/layouts/partials/route-methods.hbs @@ -4,9 +4,9 @@ public {{#if isVoid}}void{{else}}{{returnType}}{{/if}} {{methodName}}({{methodNa var requestOptions = new RequestOptions(); requestOptions.Data = request; {{#if isVoid}} -_seam.Post("{{path}}", requestOptions); +_seam.{{httpMethod}}("{{path}}", requestOptions); {{else}} -return _seam.Post<{{responseTypeArg}}>("{{path}}", requestOptions).EnsureData("{{path}}").{{returnProp}}; +return _seam.{{httpMethod}}<{{responseTypeArg}}>("{{path}}", requestOptions).EnsureData("{{path}}").{{returnProp}}; {{/if}} } @@ -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("{{path}}", requestOptions); +await _seam.{{httpMethod}}Async("{{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}} } diff --git a/codegen/lib/build-model.ts b/codegen/lib/build-model.ts index 6146067..d28d9fb 100644 --- a/codegen/lib/build-model.ts +++ b/codegen/lib/build-model.ts @@ -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`), @@ -696,6 +697,7 @@ export const buildApiFile = ( return { methodName, path: endpoint.path, + httpMethod, request: request.main, requestSiblings: request.siblings, responseSiblings: [], @@ -727,6 +729,7 @@ export const buildApiFile = ( return { methodName, path: endpoint.path, + httpMethod, request: request.main, requestSiblings: request.siblings, response: response.main, diff --git a/codegen/lib/class-model.ts b/codegen/lib/class-model.ts index 90d9c70..3fcb59c 100644 --- a/codegen/lib/class-model.ts +++ b/codegen/lib/class-model.ts @@ -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(...)`. 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 (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.` accessor tail (absent for void). returnProp?: string diff --git a/output/csharp/src/Seam.Test/Client/NullTests.cs b/output/csharp/src/Seam.Test/Client/NullTests.cs new file mode 100644 index 0000000..50c5f7c --- /dev/null +++ b/output/csharp/src/Seam.Test/Client/NullTests.cs @@ -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 + { + ["name"] = Null.Value, + ["limit"] = 20, + ["nested"] = new Dictionary { ["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 { ["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()); + } +} diff --git a/output/csharp/src/Seam.Test/Client/RequestTransportTests.cs b/output/csharp/src/Seam.Test/Client/RequestTransportTests.cs new file mode 100644 index 0000000..3811d60 --- /dev/null +++ b/output/csharp/src/Seam.Test/Client/RequestTransportTests.cs @@ -0,0 +1,172 @@ +namespace Seam.Test; + +using System.Net; +using System.Text; +using Seam.Client; + +/// +/// Exercises what the client puts on the wire for each preferred HTTP method. +/// +public class RequestTransportTests : IDisposable +{ + private readonly HttpListener _listener; + private readonly string _basePath; + + private string _method = ""; + private string _url = ""; + private string _body = ""; + + public RequestTransportTests() + { + var port = GetAvailablePort(); + _basePath = $"http://127.0.0.1:{port}"; + _listener = new HttpListener(); + _listener.Prefixes.Add($"{_basePath}/"); + _listener.Start(); + } + + public void Dispose() + { + _listener.Close(); + GC.SuppressFinalize(this); + } + + private static int GetAvailablePort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + return port; + } + + private SeamClient CreateClient(string responseBody) + { + _ = Task.Run(() => + { + var context = _listener.GetContext(); + _method = context.Request.HttpMethod; + _url = context.Request.RawUrl ?? ""; + + using (var reader = new StreamReader(context.Request.InputStream)) + { + _body = reader.ReadToEnd(); + } + + var bytes = Encoding.UTF8.GetBytes(responseBody); + context.Response.ContentType = "application/json"; + context.Response.ContentLength64 = bytes.Length; + context.Response.OutputStream.Write(bytes, 0, bytes.Length); + context.Response.Close(); + }); + + return new SeamClient(basePath: _basePath, apiToken: "seam_apikey_token"); + } + + [Fact] + public void SendsGetParamsAsSortedSearchParams() + { + var seam = CreateClient("{\"acs_encoders\":[]}"); + + seam.EncodersAcs.List(acsSystemIds: new List { "system1", "system2" }, limit: 20); + + Assert.Equal("GET", _method); + Assert.Equal( + "/acs/encoders/list?acs_system_ids=system1&acs_system_ids=system2&limit=20&_strict=true", + _url + ); + Assert.Equal("", _body); + } + + [Fact] + public void SendsGetParamsOfEveryPrimitiveType() + { + var seam = CreateClient("{\"acs_credentials\":[]}"); + + seam.CredentialsAcs.List( + acsUserId: "user1", + isMultiPhoneSyncCredential: true, + limit: 20, + search: "a b*~" + ); + + Assert.Equal("GET", _method); + + // `~` reaches the wire unescaped rather than as `%7E`, because Uri normalizes a + // percent-encoded unreserved character back to its literal form. Both decode to the + // same param. + Assert.Equal( + "/acs/credentials/list?acs_user_id=user1&is_multi_phone_sync_credential=true" + + "&limit=20&search=a+b*~&_strict=true", + _url + ); + } + + [Fact] + public void SendsAGetWithNoParamsWithoutAQuery() + { + var seam = CreateClient("{\"workspaces\":[]}"); + + seam.Workspaces.List(); + + Assert.Equal("GET", _method); + Assert.Equal("/workspaces/list", _url); + } + + [Fact] + public void SendsTheNullSentinelAsAnEmptySearchParamValue() + { + var seam = CreateClient("{\"workspace\":{}}"); + + seam.Get( + "/workspaces/get", + new RequestOptions + { + Data = new Dictionary { ["workspace_id"] = Null.Value }, + } + ); + + Assert.Equal("GET", _method); + Assert.Equal("/workspaces/get?workspace_id=&_strict=true", _url); + } + + [Fact] + public void SendsDeleteParamsAsSearchParams() + { + var seam = CreateClient("{}"); + + seam.AccessMethods.Delete(accessMethodId: "method1"); + + Assert.Equal("DELETE", _method); + Assert.Equal("/access_methods/delete?access_method_id=method1&_strict=true", _url); + Assert.Equal("", _body); + } + + [Fact] + public void SendsPostParamsAsAJsonBody() + { + var seam = CreateClient("{\"device\":{}}"); + + seam.Devices.Get(deviceId: "device1"); + + Assert.Equal("POST", _method); + Assert.Equal("/devices/get", _url); + Assert.Equal("{\"device_id\":\"device1\"}", _body); + } + + [Fact] + public void SendsPatchParamsAsAJsonBody() + { + var seam = CreateClient("{}"); + + seam.AccessGrants.Update(accessGrantId: "grant1", startsAt: "2025-02-24T18:44:39.000Z"); + + Assert.Equal("PATCH", _method); + Assert.Equal("/access_grants/update", _url); + Assert.Equal( + "{\"access_grant_id\":\"grant1\",\"starts_at\":\"2025-02-24T18:44:39.000Z\"}", + _body + ); + } +} diff --git a/output/csharp/src/Seam.Test/Client/StrictUrlSearchParamsSerializerTests.cs b/output/csharp/src/Seam.Test/Client/StrictUrlSearchParamsSerializerTests.cs new file mode 100644 index 0000000..bc14933 --- /dev/null +++ b/output/csharp/src/Seam.Test/Client/StrictUrlSearchParamsSerializerTests.cs @@ -0,0 +1,63 @@ +namespace Seam.Test; + +using Seam.Client; + +public class StrictUrlSearchParamsSerializerTests +{ + private static string Serialize(params (string Name, object? Value)[] parameters) + { + return StrictUrlSearchParamsSerializer.Serialize( + parameters.ToDictionary(parameter => parameter.Name, parameter => parameter.Value) + ); + } + + [Fact] + public void AddsTheStrictFlagToANonEmptyQuery() + { + Assert.Equal("foo=d&_strict=true", Serialize(("foo", "d"))); + } + + [Fact] + public void AddsTheStrictFlagAfterTheSortedParams() + { + Assert.Equal("bar=2&foo=d&_strict=true", Serialize(("foo", "d"), ("bar", 2))); + } + + [Fact] + public void LeavesAnEmptyQueryEmpty() + { + Assert.Equal("", Serialize()); + Assert.Equal("", Serialize(("foo", null))); + Assert.Equal("", Serialize(("foo", ""))); + } + + [Fact] + public void ReplacesACallerSuppliedStrictParam() + { + Assert.Equal("foo=d&_strict=true", Serialize(("foo", "d"), ("_strict", "false"))); + Assert.Equal("_strict=true", Serialize(("_strict", "false"))); + } + + [Fact] + public void UpdatesExistingSearchParams() + { + var searchParams = new UrlSearchParams(); + searchParams.Set("foo", "bar"); + + StrictUrlSearchParamsSerializer.Update( + searchParams, + new Dictionary { ["name"] = "Dax" } + ); + + Assert.Equal("foo=bar&name=Dax&_strict=true", searchParams.ToString()); + } + + [Fact] + public void LeavesTheBaseSerializerWithoutTheStrictFlag() + { + Assert.Equal( + "foo=d", + UrlSearchParamsSerializer.Serialize(new Dictionary { ["foo"] = "d" }) + ); + } +} diff --git a/output/csharp/src/Seam.Test/Client/UrlSearchParamsSerializerTests.cs b/output/csharp/src/Seam.Test/Client/UrlSearchParamsSerializerTests.cs new file mode 100644 index 0000000..e2aecb2 --- /dev/null +++ b/output/csharp/src/Seam.Test/Client/UrlSearchParamsSerializerTests.cs @@ -0,0 +1,401 @@ +namespace Seam.Test; + +using System.Collections; +using Seam.Client; + +public class UrlSearchParamsSerializerTests +{ + private static Dictionary Params( + params (string Name, object? Value)[] parameters + ) + { + return parameters.ToDictionary(parameter => parameter.Name, parameter => parameter.Value); + } + + private static string Serialize(params (string Name, object? Value)[] parameters) + { + return UrlSearchParamsSerializer.Serialize(Params(parameters)); + } + + [Fact] + public void SerializesEmptyParams() + { + Assert.Equal("", Serialize()); + } + + [Fact] + public void SerializesString() + { + Assert.Equal("foo=d", Serialize(("foo", "d"))); + Assert.Equal("foo=null", Serialize(("foo", "null"))); + Assert.Equal("foo=undefined", Serialize(("foo", "undefined"))); + Assert.Equal("foo=0", Serialize(("foo", "0"))); + } + + [Fact] + public void RemovesTheEmptyString() + { + Assert.Equal("", Serialize(("foo", ""))); + Assert.Equal("foo=d", Serialize(("foo", "d"), ("bar", ""))); + } + + [Fact] + public void SerializesInteger() + { + Assert.Equal("foo=1", Serialize(("foo", 1))); + Assert.Equal("foo=0", Serialize(("foo", 0))); + Assert.Equal("foo=-42", Serialize(("foo", -42))); + } + + [Fact] + public void SerializesLargeIntegerWithFullPrecision() + { + Assert.Equal("foo=9007199254740993", Serialize(("foo", 9007199254740993L))); + Assert.Equal("foo=9223372036854775807", Serialize(("foo", long.MaxValue))); + Assert.Equal("foo=18446744073709551615", Serialize(("foo", ulong.MaxValue))); + } + + [Fact] + public void SerializesDouble() + { + Assert.Equal("foo=23.8", Serialize(("foo", 23.8))); + Assert.Equal("foo=-23.8", Serialize(("foo", -23.8))); + Assert.Equal("foo=0.30000000000000004", Serialize(("foo", 0.1 + 0.2))); + } + + [Fact] + public void SerializesDoubleUsingTheEcmascriptNumberFormat() + { + Assert.Equal("foo=1", Serialize(("foo", 1.0))); + Assert.Equal("foo=0", Serialize(("foo", -0.0))); + Assert.Equal("foo=100", Serialize(("foo", 100.0))); + Assert.Equal("foo=10000000000000000", Serialize(("foo", 1e16))); + Assert.Equal("foo=100000000000000000000", Serialize(("foo", 1e20))); + Assert.Equal("foo=1e%2B21", Serialize(("foo", 1e21))); + Assert.Equal("foo=0.0001", Serialize(("foo", 0.0001))); + Assert.Equal("foo=0.000001", Serialize(("foo", 1e-6))); + Assert.Equal("foo=1e-7", Serialize(("foo", 1e-7))); + Assert.Equal("foo=5e-324", Serialize(("foo", double.Epsilon))); + Assert.Equal("foo=1.7976931348623157e%2B308", Serialize(("foo", double.MaxValue))); + } + + [Fact] + public void SerializesFloatFromItsOwnShortestRepresentation() + { + Assert.Equal("foo=23.8", Serialize(("foo", 23.8f))); + Assert.Equal("foo=0.1", Serialize(("foo", 0.1f))); + Assert.Equal("foo=-1.5", Serialize(("foo", -1.5f))); + Assert.Equal("foo=1", Serialize(("foo", 1f))); + } + + [Fact] + public void SerializesDecimalFromItsOwnExactValue() + { + Assert.Equal("foo=1.1", Serialize(("foo", 1.10m))); + Assert.Equal("foo=0", Serialize(("foo", 0.0m))); + Assert.Equal("foo=-0.5", Serialize(("foo", -0.5m))); + } + + [Fact] + public void SerializesBool() + { + Assert.Equal("foo=true", Serialize(("foo", true))); + Assert.Equal("foo=false", Serialize(("foo", false))); + Assert.Equal("bar=false&foo=true", Serialize(("foo", true), ("bar", false))); + } + + [Fact] + public void RemovesNullParams() + { + Assert.Equal("", Serialize(("bar", null))); + Assert.Equal("foo=1", Serialize(("foo", 1), ("bar", null))); + } + + [Fact] + public void SerializesTheNullSentinel() + { + Assert.Equal("bar=", Serialize(("bar", Null.Value))); + Assert.Equal("bar=&foo=1", Serialize(("foo", 1), ("bar", Null.Value))); + } + + [Fact] + public void SerializesEmptyArray() + { + Assert.Equal("bar=", Serialize(("bar", new string[0]))); + Assert.Equal("bar=&foo=1", Serialize(("foo", 1), ("bar", new List()))); + } + + [Fact] + public void SerializesArrayWithOneValue() + { + Assert.Equal("bar=a", Serialize(("bar", new[] { "a" }))); + Assert.Equal("bar=a&foo=1", Serialize(("foo", 1), ("bar", new[] { "a" }))); + } + + [Fact] + public void SerializesArrayWithManyValues() + { + Assert.Equal("bar=a&bar=2&foo=1", Serialize(("foo", 1), ("bar", new[] { "a", "2" }))); + Assert.Equal( + "bar=null&bar=2&bar=undefined&foo=1", + Serialize(("foo", 1), ("bar", new[] { "null", "2", "undefined" })) + ); + } + + [Fact] + public void SerializesArrayOfMixedValues() + { + Assert.Equal("bar=1&bar=a&bar=true", Serialize(("bar", new object[] { 1, "a", true }))); + } + + [Fact] + public void SerializesDateTime() + { + Assert.Equal( + "foo=1&now=2025-02-24T18%3A44%3A39.000Z", + Serialize(("foo", 1), ("now", new DateTime(2025, 2, 24, 18, 44, 39, DateTimeKind.Utc))) + ); + } + + [Fact] + public void SerializesDateTimeOffsetInUtc() + { + Assert.Equal( + "now=2025-02-24T18%3A44%3A39.000Z", + Serialize(("now", new DateTimeOffset(2025, 2, 24, 13, 44, 39, TimeSpan.FromHours(-5)))) + ); + } + + [Fact] + public void ReadsAnUnspecifiedDateTimeAsUtc() + { + Assert.Equal( + "now=2025-02-24T18%3A44%3A39.000Z", + Serialize(("now", new DateTime(2025, 2, 24, 18, 44, 39))) + ); + } + + [Fact] + public void TruncatesSubMillisecondPrecision() + { + var now = new DateTime(2025, 2, 24, 18, 44, 39, DateTimeKind.Utc).AddTicks(12_345); + + Assert.Equal("now=2025-02-24T18%3A44%3A39.001Z", Serialize(("now", now))); + } + + [Fact] + public void SerializesNestedObjectsToDotPaths() + { + Assert.Equal("bar.baz=a&foo=1", Serialize(("foo", 1), ("bar", Params(("baz", "a"))))); + + Assert.Equal( + "bar.baz.x.z=1&foo=1", + Serialize(("foo", 1), ("bar", Params(("baz", Params(("x", Params(("z", 1)))))))) + ); + + Assert.Equal( + "bar.baz.x.z=&foo=1", + Serialize( + ("foo", 1), + ("bar", Params(("baz", Params(("x", Params(("z", Null.Value))))))) + ) + ); + + Assert.Equal( + "bar.baz=1&bar.baz=a&foo=1", + Serialize(("foo", 1), ("bar", Params(("baz", new object[] { 1, "a" })))) + ); + } + + [Fact] + public void SerializesEmptyNestedObjectsToNothing() + { + Assert.Equal("bar=2", Serialize(("foo", Params()), ("bar", 2))); + Assert.Equal("bar=2", Serialize(("foo", Params(("x", Params()))), ("bar", 2))); + Assert.Equal( + "bar.baz.x.z=", + Serialize( + ("foo", Params()), + ( + "bar", + Params( + ( + "baz", + Params( + ("x", Params(("z", Null.Value), ("t", Params()))), + ("q", Params()) + ) + ) + ) + ) + ) + ); + } + + [Fact] + public void SortsParamsByUtf16CodeUnit() + { + Assert.Equal("A=1&_x=2&a=3&b=4", Serialize(("b", 4), ("a", 3), ("_x", 2), ("A", 1))); + } + + [Fact] + public void KeepsArrayElementOrderWhenSorting() + { + Assert.Equal("a=1&a=2&a=3&b=4", Serialize(("b", 4), ("a", new[] { "1", "2", "3" }))); + + // Beyond 16 elements an unstable sort no longer degrades to an insertion sort, which + // would keep a short array in order whether or not the sort preserves it. + var values = Enumerable.Range(0, 32).Select(index => index.ToString()).ToList(); + + Assert.Equal( + string.Join("&", values.Select(value => $"a={value}")) + "&b=2&z=1", + Serialize(("z", 1), ("a", values), ("b", 2)) + ); + } + + [Fact] + public void EncodesWithTheFormUrlencodedSerializer() + { + Assert.Equal("foo=a+b*%7E%21%C3%A9%E4%B8%AD", Serialize(("foo", "a b*~!\u00E9\u4E2D"))); + Assert.Equal("a+name=x%2Fy%3Fz%3D1%262", Serialize(("a name", "x/y?z=1&2"))); + Assert.Equal("emoji=%F0%9F%98%80", Serialize(("emoji", "\U0001F600"))); + } + + [Fact] + public void CannotSerializeKeysContainingADot() + { + var error = Assert.Throws(() => Serialize(("foo.bar", 1))); + + Assert.Equal("foo.bar", error.ParamName); + Assert.Equal( + "Could not serialize parameter: 'foo.bar' contains one or more dots \".\" in its name which is unsupported", + error.Message + ); + + Assert.Throws(() => Serialize(("foo", Params(("bar.baz", 1))))); + } + + [Fact] + public void CannotSerializeKeysThatAreNotStrings() + { + var parameters = new Dictionary { [1] = "a" }; + + Assert.Throws( + () => UrlSearchParamsSerializer.Serialize(parameters) + ); + } + + [Fact] + public void CannotSerializeNumberPointers() + { + Assert.Equal( + "Could not serialize parameter: 'foo' is Infinity", + Assert + .Throws(() => Serialize(("foo", double.PositiveInfinity))) + .Message + ); + Assert.Equal( + "Could not serialize parameter: 'foo' is -Infinity", + Assert + .Throws(() => Serialize(("foo", double.NegativeInfinity))) + .Message + ); + Assert.Equal( + "Could not serialize parameter: 'foo' is NaN", + Assert.Throws(() => Serialize(("foo", double.NaN))).Message + ); + Assert.Throws(() => Serialize(("foo", float.NaN))); + Assert.Throws(() => Serialize(("foo", float.PositiveInfinity))); + } + + [Fact] + public void CannotSerializeOtherObjects() + { + Assert.Equal( + "Could not serialize parameter: 'foo' is a Uri", + Assert + .Throws( + () => Serialize(("foo", new Uri("https://example.com"))) + ) + .Message + ); + } + + [Fact] + public void CannotSerializeArraysWithUnserializableValues() + { + Assert.Equal( + "Could not serialize parameter: 'foo' is a single element array containing the empty string which is unsupported", + Assert.Throws(() => Serialize(("foo", new[] { "" }))).Message + ); + + Assert.Equal( + "Could not serialize parameter: 'bar' is an array containing the empty string which is unsupported", + Assert + .Throws(() => Serialize(("bar", new[] { "a", "" }))) + .Message + ); + + Assert.Equal( + "Could not serialize parameter: 'bar' is an array containing null or undefined values which is unsupported", + Assert + .Throws( + () => Serialize(("bar", new object?[] { "a", null })) + ) + .Message + ); + + Assert.Throws( + () => Serialize(("bar", new object?[] { "a", Null.Value })) + ); + Assert.Throws( + () => Serialize(("bar", new object[] { "a", new[] { "s" } })) + ); + Assert.Throws( + () => Serialize(("bar", new object[] { "a", new string[0] })) + ); + Assert.Throws( + () => Serialize(("bar", new object[] { "a", Params() })) + ); + Assert.Throws( + () => Serialize(("bar", new object[] { "a", Params(("x", 2)) })) + ); + Assert.Throws( + () => Serialize(("foo", 1), ("bar", new[] { "", "a", "" })) + ); + Assert.Throws( + () => Serialize(("foo", 1), ("bar", new[] { "", "", "" })) + ); + } + + [Fact] + public void UpdatesExistingSearchParams() + { + var searchParams = new UrlSearchParams(); + searchParams.Set("foo", "bar"); + + UrlSearchParamsSerializer.Update( + searchParams, + Params(("name", "Dax"), ("age", 27), ("tags", new[] { "cars", "planes" })) + ); + + Assert.Equal("age=27&foo=bar&name=Dax&tags=cars&tags=planes", searchParams.ToString()); + } + + [Fact] + public void SerializesTheReadmeExample() + { + IDictionary parameters = new Dictionary + { + ["name"] = "Dax", + ["age"] = 27, + ["isAdmin"] = true, + ["tags"] = new[] { "cars", "planes" }, + }; + + Assert.Equal( + "age=27&isAdmin=true&name=Dax&tags=cars&tags=planes", + UrlSearchParamsSerializer.Serialize(parameters) + ); + } +} diff --git a/output/csharp/src/Seam.Test/Client/UrlSearchParamsTests.cs b/output/csharp/src/Seam.Test/Client/UrlSearchParamsTests.cs new file mode 100644 index 0000000..4841173 --- /dev/null +++ b/output/csharp/src/Seam.Test/Client/UrlSearchParamsTests.cs @@ -0,0 +1,131 @@ +namespace Seam.Test; + +using Seam.Client; + +public class UrlSearchParamsTests +{ + [Fact] + public void StartsEmpty() + { + var searchParams = new UrlSearchParams(); + + Assert.Equal(0, searchParams.Count); + Assert.Equal("", searchParams.ToString()); + Assert.Null(searchParams.Get("foo")); + Assert.False(searchParams.Has("foo")); + } + + [Fact] + public void AppendsPairsWithTheSameName() + { + var searchParams = new UrlSearchParams(); + searchParams.Append("foo", "a"); + searchParams.Append("foo", "b"); + + Assert.Equal(2, searchParams.Count); + Assert.Equal("a", searchParams.Get("foo")); + Assert.Equal(new[] { "a", "b" }, searchParams.GetAll("foo")); + Assert.Equal("foo=a&foo=b", searchParams.ToString()); + } + + [Fact] + public void SetKeepsThePositionOfTheFirstPairAndRemovesTheRest() + { + var searchParams = new UrlSearchParams(); + searchParams.Append("foo", "a"); + searchParams.Append("bar", "b"); + searchParams.Append("foo", "c"); + + searchParams.Set("foo", "d"); + + Assert.Equal("foo=d&bar=b", searchParams.ToString()); + } + + [Fact] + public void SetAppendsWhenNoPairWithTheNameExists() + { + var searchParams = new UrlSearchParams(); + searchParams.Append("foo", "a"); + + searchParams.Set("bar", "b"); + + Assert.Equal("foo=a&bar=b", searchParams.ToString()); + } + + [Fact] + public void DeletesEveryPairWithTheName() + { + var searchParams = new UrlSearchParams(); + searchParams.Append("foo", "a"); + searchParams.Append("bar", "b"); + searchParams.Append("foo", "c"); + + searchParams.Delete("foo"); + + Assert.Equal("bar=b", searchParams.ToString()); + Assert.False(searchParams.Has("foo")); + } + + [Fact] + public void SortsByNameKeepingTheOrderOfPairsWithTheSameName() + { + var searchParams = new UrlSearchParams(); + searchParams.Append("foo", "1"); + searchParams.Append("bar", "a"); + searchParams.Append("foo", "2"); + searchParams.Append("Baz", "c"); + + searchParams.Sort(); + + Assert.Equal("Baz=c&bar=a&foo=1&foo=2", searchParams.ToString()); + } + + [Fact] + public void EncodesEveryPairIncludingEmptyValues() + { + var searchParams = new UrlSearchParams(); + searchParams.Append("a name", ""); + searchParams.Append("foo", "a b*~"); + + Assert.Equal("a+name=&foo=a+b*%7E", searchParams.ToString()); + } + + [Fact] + public void ParsesAQueryString() + { + var searchParams = new UrlSearchParams("?foo=a+b*%7E&foo=2&bar=&baz"); + + Assert.Equal(new[] { "a b*~", "2" }, searchParams.GetAll("foo")); + Assert.Equal("", searchParams.Get("bar")); + Assert.Equal("", searchParams.Get("baz")); + Assert.Equal("foo=a+b*%7E&foo=2&bar=&baz=", searchParams.ToString()); + } + + [Fact] + public void RoundTripsNonAsciiValues() + { + var searchParams = new UrlSearchParams(); + searchParams.Append("emoji", "\U0001F600"); + searchParams.Append("kanji", "\u4E2D"); + + var parsed = new UrlSearchParams(searchParams.ToString()); + + Assert.Equal("\U0001F600", parsed.Get("emoji")); + Assert.Equal("\u4E2D", parsed.Get("kanji")); + } + + [Fact] + public void EnumeratesPairsInOrder() + { + var searchParams = new UrlSearchParams( + new[] + { + new KeyValuePair("foo", "a"), + new KeyValuePair("bar", "b"), + } + ); + + Assert.Equal(new[] { "foo", "bar" }, searchParams.Select(pair => pair.Key)); + Assert.Equal(new[] { "a", "b" }, searchParams.Select(pair => pair.Value)); + } +} diff --git a/output/csharp/src/Seam/Api/AccessCodes.cs b/output/csharp/src/Seam/Api/AccessCodes.cs index 5c8c0c0..54b8804 100644 --- a/output/csharp/src/Seam/Api/AccessCodes.cs +++ b/output/csharp/src/Seam/Api/AccessCodes.cs @@ -620,7 +620,7 @@ public List CreateMultiple(CreateMultipleRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/access_codes/create_multiple", requestOptions) + .Put("/access_codes/create_multiple", requestOptions) .EnsureData("/access_codes/create_multiple") .AccessCodes; } @@ -690,7 +690,7 @@ public async Task> CreateMultipleAsync(CreateMultipleRequest re var requestOptions = new RequestOptions(); requestOptions.Data = request; return ( - await _seam.PostAsync( + await _seam.PutAsync( "/access_codes/create_multiple", requestOptions ) @@ -1923,7 +1923,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/access_codes/update", requestOptions); + _seam.Put("/access_codes/update", requestOptions); } /// @@ -1985,7 +1985,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/access_codes/update", requestOptions); + await _seam.PutAsync("/access_codes/update", requestOptions); } /// @@ -2121,7 +2121,7 @@ public void UpdateMultiple(UpdateMultipleRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/access_codes/update_multiple", requestOptions); + _seam.Patch("/access_codes/update_multiple", requestOptions); } /// @@ -2159,7 +2159,7 @@ public async Task UpdateMultipleAsync(UpdateMultipleRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/access_codes/update_multiple", requestOptions); + await _seam.PatchAsync("/access_codes/update_multiple", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/AccessGrants.cs b/output/csharp/src/Seam/Api/AccessGrants.cs index 42922fc..574f055 100644 --- a/output/csharp/src/Seam/Api/AccessGrants.cs +++ b/output/csharp/src/Seam/Api/AccessGrants.cs @@ -684,7 +684,7 @@ public AccessGrant Get(GetRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/access_grants/get", requestOptions) + .Get("/access_grants/get", requestOptions) .EnsureData("/access_grants/get") .AccessGrant; } @@ -706,7 +706,7 @@ public async Task GetAsync(GetRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return (await _seam.PostAsync("/access_grants/get", requestOptions)) + return (await _seam.GetAsync("/access_grants/get", requestOptions)) .EnsureData("/access_grants/get") .AccessGrant; } @@ -1556,7 +1556,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/access_grants/update", requestOptions); + _seam.Patch("/access_grants/update", requestOptions); } /// @@ -1588,7 +1588,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/access_grants/update", requestOptions); + await _seam.PatchAsync("/access_grants/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/AccessGroupsAcs.cs b/output/csharp/src/Seam/Api/AccessGroupsAcs.cs index 1487631..1e6c63a 100644 --- a/output/csharp/src/Seam/Api/AccessGroupsAcs.cs +++ b/output/csharp/src/Seam/Api/AccessGroupsAcs.cs @@ -83,7 +83,7 @@ public void AddUser(AddUserRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/acs/access_groups/add_user", requestOptions); + _seam.Put("/acs/access_groups/add_user", requestOptions); } /// @@ -111,7 +111,7 @@ public async Task AddUserAsync(AddUserRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/acs/access_groups/add_user", requestOptions); + await _seam.PutAsync("/acs/access_groups/add_user", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/AccessMethods.cs b/output/csharp/src/Seam/Api/AccessMethods.cs index 1466f56..73d7cf1 100644 --- a/output/csharp/src/Seam/Api/AccessMethods.cs +++ b/output/csharp/src/Seam/Api/AccessMethods.cs @@ -225,7 +225,7 @@ public void Delete(DeleteRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/access_methods/delete", requestOptions); + _seam.Delete("/access_methods/delete", requestOptions); } /// @@ -253,7 +253,7 @@ public async Task DeleteAsync(DeleteRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/access_methods/delete", requestOptions); + await _seam.DeleteAsync("/access_methods/delete", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/ClientSessions.cs b/output/csharp/src/Seam/Api/ClientSessions.cs index 0716b36..a275fe2 100644 --- a/output/csharp/src/Seam/Api/ClientSessions.cs +++ b/output/csharp/src/Seam/Api/ClientSessions.cs @@ -166,7 +166,7 @@ public ClientSession Create(CreateRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/client_sessions/create", requestOptions) + .Put("/client_sessions/create", requestOptions) .EnsureData("/client_sessions/create") .ClientSession; } @@ -206,9 +206,7 @@ public async Task CreateAsync(CreateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return ( - await _seam.PostAsync("/client_sessions/create", requestOptions) - ) + return (await _seam.PutAsync("/client_sessions/create", requestOptions)) .EnsureData("/client_sessions/create") .ClientSession; } @@ -763,7 +761,7 @@ public void GrantAccess(GrantAccessRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/client_sessions/grant_access", requestOptions); + _seam.Patch("/client_sessions/grant_access", requestOptions); } /// @@ -797,7 +795,7 @@ public async Task GrantAccessAsync(GrantAccessRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/client_sessions/grant_access", requestOptions); + await _seam.PatchAsync("/client_sessions/grant_access", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/ConnectedAccounts.cs b/output/csharp/src/Seam/Api/ConnectedAccounts.cs index 487b9b6..9d1badd 100644 --- a/output/csharp/src/Seam/Api/ConnectedAccounts.cs +++ b/output/csharp/src/Seam/Api/ConnectedAccounts.cs @@ -206,7 +206,7 @@ public ConnectedAccount Get(GetRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/connected_accounts/get", requestOptions) + .Get("/connected_accounts/get", requestOptions) .EnsureData("/connected_accounts/get") .ConnectedAccount; } @@ -226,7 +226,7 @@ public async Task GetAsync(GetRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return (await _seam.PostAsync("/connected_accounts/get", requestOptions)) + return (await _seam.GetAsync("/connected_accounts/get", requestOptions)) .EnsureData("/connected_accounts/get") .ConnectedAccount; } @@ -648,7 +648,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/connected_accounts/update", requestOptions); + _seam.Patch("/connected_accounts/update", requestOptions); } /// @@ -682,7 +682,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/connected_accounts/update", requestOptions); + await _seam.PatchAsync("/connected_accounts/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/CredentialsAcs.cs b/output/csharp/src/Seam/Api/CredentialsAcs.cs index acfc308..27fe9cd 100644 --- a/output/csharp/src/Seam/Api/CredentialsAcs.cs +++ b/output/csharp/src/Seam/Api/CredentialsAcs.cs @@ -83,7 +83,7 @@ public void Assign(AssignRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/acs/credentials/assign", requestOptions); + _seam.Patch("/acs/credentials/assign", requestOptions); } /// @@ -111,7 +111,7 @@ public async Task AssignAsync(AssignRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/acs/credentials/assign", requestOptions); + await _seam.PatchAsync("/acs/credentials/assign", requestOptions); } /// @@ -981,7 +981,7 @@ public List List(ListRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/acs/credentials/list", requestOptions) + .Get("/acs/credentials/list", requestOptions) .EnsureData("/acs/credentials/list") .AcsCredentials; } @@ -1021,7 +1021,7 @@ public async Task> ListAsync(ListRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return (await _seam.PostAsync("/acs/credentials/list", requestOptions)) + return (await _seam.GetAsync("/acs/credentials/list", requestOptions)) .EnsureData("/acs/credentials/list") .AcsCredentials; } @@ -1257,7 +1257,7 @@ public void Unassign(UnassignRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/acs/credentials/unassign", requestOptions); + _seam.Patch("/acs/credentials/unassign", requestOptions); } /// @@ -1285,7 +1285,7 @@ public async Task UnassignAsync(UnassignRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/acs/credentials/unassign", requestOptions); + await _seam.PatchAsync("/acs/credentials/unassign", requestOptions); } /// @@ -1371,7 +1371,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/acs/credentials/update", requestOptions); + _seam.Patch("/acs/credentials/update", requestOptions); } /// @@ -1393,7 +1393,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/acs/credentials/update", requestOptions); + await _seam.PatchAsync("/acs/credentials/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/DailyProgramsThermostats.cs b/output/csharp/src/Seam/Api/DailyProgramsThermostats.cs index 35bca74..a9fc294 100644 --- a/output/csharp/src/Seam/Api/DailyProgramsThermostats.cs +++ b/output/csharp/src/Seam/Api/DailyProgramsThermostats.cs @@ -458,7 +458,7 @@ public ActionAttempt Update(UpdateRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/thermostats/daily_programs/update", requestOptions) + .Patch("/thermostats/daily_programs/update", requestOptions) .EnsureData("/thermostats/daily_programs/update") .ActionAttempt; } @@ -489,7 +489,7 @@ public async Task UpdateAsync(UpdateRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return ( - await _seam.PostAsync( + await _seam.PatchAsync( "/thermostats/daily_programs/update", requestOptions ) diff --git a/output/csharp/src/Seam/Api/Devices.cs b/output/csharp/src/Seam/Api/Devices.cs index b239168..65fb35c 100644 --- a/output/csharp/src/Seam/Api/Devices.cs +++ b/output/csharp/src/Seam/Api/Devices.cs @@ -5371,7 +5371,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/devices/update", requestOptions); + _seam.Patch("/devices/update", requestOptions); } /// @@ -5409,7 +5409,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/devices/update", requestOptions); + await _seam.PatchAsync("/devices/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/EncodersAcs.cs b/output/csharp/src/Seam/Api/EncodersAcs.cs index 17754d7..73a71e0 100644 --- a/output/csharp/src/Seam/Api/EncodersAcs.cs +++ b/output/csharp/src/Seam/Api/EncodersAcs.cs @@ -418,7 +418,7 @@ public List List(ListRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/acs/encoders/list", requestOptions) + .Get("/acs/encoders/list", requestOptions) .EnsureData("/acs/encoders/list") .AcsEncoders; } @@ -452,7 +452,7 @@ public async Task> ListAsync(ListRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return (await _seam.PostAsync("/acs/encoders/list", requestOptions)) + return (await _seam.GetAsync("/acs/encoders/list", requestOptions)) .EnsureData("/acs/encoders/list") .AcsEncoders; } diff --git a/output/csharp/src/Seam/Api/NoiseThresholdsNoiseSensors.cs b/output/csharp/src/Seam/Api/NoiseThresholdsNoiseSensors.cs index df53fc5..d6ef41b 100644 --- a/output/csharp/src/Seam/Api/NoiseThresholdsNoiseSensors.cs +++ b/output/csharp/src/Seam/Api/NoiseThresholdsNoiseSensors.cs @@ -653,7 +653,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/noise_sensors/noise_thresholds/update", requestOptions); + _seam.Put("/noise_sensors/noise_thresholds/update", requestOptions); } /// @@ -689,7 +689,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/noise_sensors/noise_thresholds/update", requestOptions); + await _seam.PutAsync("/noise_sensors/noise_thresholds/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/SchedulesThermostats.cs b/output/csharp/src/Seam/Api/SchedulesThermostats.cs index 4ece425..d355567 100644 --- a/output/csharp/src/Seam/Api/SchedulesThermostats.cs +++ b/output/csharp/src/Seam/Api/SchedulesThermostats.cs @@ -683,7 +683,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/thermostats/schedules/update", requestOptions); + _seam.Patch("/thermostats/schedules/update", requestOptions); } /// @@ -719,7 +719,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/thermostats/schedules/update", requestOptions); + await _seam.PatchAsync("/thermostats/schedules/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/Spaces.cs b/output/csharp/src/Seam/Api/Spaces.cs index 6f986d8..1e37aa0 100644 --- a/output/csharp/src/Seam/Api/Spaces.cs +++ b/output/csharp/src/Seam/Api/Spaces.cs @@ -75,7 +75,7 @@ public void AddAcsEntrances(AddAcsEntrancesRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/spaces/add_acs_entrances", requestOptions); + _seam.Put("/spaces/add_acs_entrances", requestOptions); } /// @@ -95,7 +95,7 @@ public async Task AddAcsEntrancesAsync(AddAcsEntrancesRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/spaces/add_acs_entrances", requestOptions); + await _seam.PutAsync("/spaces/add_acs_entrances", requestOptions); } /// @@ -168,7 +168,7 @@ public void AddConnectedAccount(AddConnectedAccountRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/spaces/add_connected_account", requestOptions); + _seam.Put("/spaces/add_connected_account", requestOptions); } /// @@ -194,7 +194,7 @@ public async Task AddConnectedAccountAsync(AddConnectedAccountRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/spaces/add_connected_account", requestOptions); + await _seam.PutAsync("/spaces/add_connected_account", requestOptions); } /// @@ -267,7 +267,7 @@ public void AddDevices(AddDevicesRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/spaces/add_devices", requestOptions); + _seam.Put("/spaces/add_devices", requestOptions); } /// @@ -285,7 +285,7 @@ public async Task AddDevicesAsync(AddDevicesRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/spaces/add_devices", requestOptions); + await _seam.PutAsync("/spaces/add_devices", requestOptions); } /// @@ -748,7 +748,7 @@ public Space Get(GetRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/spaces/get", requestOptions) + .Get("/spaces/get", requestOptions) .EnsureData("/spaces/get") .Space; } @@ -768,7 +768,7 @@ public async Task GetAsync(GetRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return (await _seam.PostAsync("/spaces/get", requestOptions)) + return (await _seam.GetAsync("/spaces/get", requestOptions)) .EnsureData("/spaces/get") .Space; } @@ -1663,7 +1663,7 @@ public Space Update(UpdateRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/spaces/update", requestOptions) + .Patch("/spaces/update", requestOptions) .EnsureData("/spaces/update") .Space; } @@ -1699,7 +1699,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return (await _seam.PostAsync("/spaces/update", requestOptions)) + return (await _seam.PatchAsync("/spaces/update", requestOptions)) .EnsureData("/spaces/update") .Space; } diff --git a/output/csharp/src/Seam/Api/Thermostats.cs b/output/csharp/src/Seam/Api/Thermostats.cs index 848637a..4e1d989 100644 --- a/output/csharp/src/Seam/Api/Thermostats.cs +++ b/output/csharp/src/Seam/Api/Thermostats.cs @@ -2350,7 +2350,7 @@ public void SetTemperatureThreshold(SetTemperatureThresholdRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/thermostats/set_temperature_threshold", requestOptions); + _seam.Patch("/thermostats/set_temperature_threshold", requestOptions); } /// @@ -2382,7 +2382,10 @@ public async Task SetTemperatureThresholdAsync(SetTemperatureThresholdRequest re { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/thermostats/set_temperature_threshold", requestOptions); + await _seam.PatchAsync( + "/thermostats/set_temperature_threshold", + requestOptions + ); } /// @@ -2708,7 +2711,7 @@ public void UpdateClimatePreset(UpdateClimatePresetRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/thermostats/update_climate_preset", requestOptions); + _seam.Patch("/thermostats/update_climate_preset", requestOptions); } /// @@ -2754,7 +2757,7 @@ public async Task UpdateClimatePresetAsync(UpdateClimatePresetRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/thermostats/update_climate_preset", requestOptions); + await _seam.PatchAsync("/thermostats/update_climate_preset", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/UnmanagedAccessCodes.cs b/output/csharp/src/Seam/Api/UnmanagedAccessCodes.cs index ba19f7f..2fb2a82 100644 --- a/output/csharp/src/Seam/Api/UnmanagedAccessCodes.cs +++ b/output/csharp/src/Seam/Api/UnmanagedAccessCodes.cs @@ -103,7 +103,7 @@ public void ConvertToManaged(ConvertToManagedRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/access_codes/unmanaged/convert_to_managed", requestOptions); + _seam.Patch("/access_codes/unmanaged/convert_to_managed", requestOptions); } /// @@ -141,7 +141,7 @@ public async Task ConvertToManagedAsync(ConvertToManagedRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync( + await _seam.PatchAsync( "/access_codes/unmanaged/convert_to_managed", requestOptions ); @@ -675,7 +675,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/access_codes/unmanaged/update", requestOptions); + _seam.Patch("/access_codes/unmanaged/update", requestOptions); } /// @@ -707,7 +707,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/access_codes/unmanaged/update", requestOptions); + await _seam.PatchAsync("/access_codes/unmanaged/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/UnmanagedAccessGrants.cs b/output/csharp/src/Seam/Api/UnmanagedAccessGrants.cs index a75d560..226213d 100644 --- a/output/csharp/src/Seam/Api/UnmanagedAccessGrants.cs +++ b/output/csharp/src/Seam/Api/UnmanagedAccessGrants.cs @@ -403,7 +403,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/access_grants/unmanaged/update", requestOptions); + _seam.Patch("/access_grants/unmanaged/update", requestOptions); } /// @@ -439,7 +439,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/access_grants/unmanaged/update", requestOptions); + await _seam.PatchAsync("/access_grants/unmanaged/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/UnmanagedDevices.cs b/output/csharp/src/Seam/Api/UnmanagedDevices.cs index ffbd1e3..18bead2 100644 --- a/output/csharp/src/Seam/Api/UnmanagedDevices.cs +++ b/output/csharp/src/Seam/Api/UnmanagedDevices.cs @@ -987,7 +987,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/devices/unmanaged/update", requestOptions); + _seam.Patch("/devices/unmanaged/update", requestOptions); } /// @@ -1019,7 +1019,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/devices/unmanaged/update", requestOptions); + await _seam.PatchAsync("/devices/unmanaged/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/UnmanagedUserIdentities.cs b/output/csharp/src/Seam/Api/UnmanagedUserIdentities.cs index 5ce049a..50705a6 100644 --- a/output/csharp/src/Seam/Api/UnmanagedUserIdentities.cs +++ b/output/csharp/src/Seam/Api/UnmanagedUserIdentities.cs @@ -380,7 +380,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/user_identities/unmanaged/update", requestOptions); + _seam.Patch("/user_identities/unmanaged/update", requestOptions); } /// @@ -412,7 +412,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/user_identities/unmanaged/update", requestOptions); + await _seam.PatchAsync("/user_identities/unmanaged/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/UserIdentities.cs b/output/csharp/src/Seam/Api/UserIdentities.cs index 6a10a1f..ce4c84e 100644 --- a/output/csharp/src/Seam/Api/UserIdentities.cs +++ b/output/csharp/src/Seam/Api/UserIdentities.cs @@ -87,7 +87,7 @@ public void AddAcsUser(AddAcsUserRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/user_identities/add_acs_user", requestOptions); + _seam.Put("/user_identities/add_acs_user", requestOptions); } /// @@ -123,7 +123,7 @@ public async Task AddAcsUserAsync(AddAcsUserRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/user_identities/add_acs_user", requestOptions); + await _seam.PutAsync("/user_identities/add_acs_user", requestOptions); } /// @@ -664,7 +664,7 @@ public UserIdentity Get(GetRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/user_identities/get", requestOptions) + .Get("/user_identities/get", requestOptions) .EnsureData("/user_identities/get") .UserIdentity; } @@ -686,7 +686,7 @@ public async Task GetAsync(GetRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return (await _seam.PostAsync("/user_identities/get", requestOptions)) + return (await _seam.GetAsync("/user_identities/get", requestOptions)) .EnsureData("/user_identities/get") .UserIdentity; } @@ -763,7 +763,7 @@ public void GrantAccessToDevice(GrantAccessToDeviceRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/user_identities/grant_access_to_device", requestOptions); + _seam.Put("/user_identities/grant_access_to_device", requestOptions); } /// @@ -783,10 +783,7 @@ public async Task GrantAccessToDeviceAsync(GrantAccessToDeviceRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync( - "/user_identities/grant_access_to_device", - requestOptions - ); + await _seam.PutAsync("/user_identities/grant_access_to_device", requestOptions); } /// @@ -1787,7 +1784,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/user_identities/update", requestOptions); + _seam.Patch("/user_identities/update", requestOptions); } /// @@ -1819,7 +1816,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/user_identities/update", requestOptions); + await _seam.PatchAsync("/user_identities/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/UsersAcs.cs b/output/csharp/src/Seam/Api/UsersAcs.cs index 65c054e..5d410fb 100644 --- a/output/csharp/src/Seam/Api/UsersAcs.cs +++ b/output/csharp/src/Seam/Api/UsersAcs.cs @@ -75,7 +75,7 @@ public void AddToAccessGroup(AddToAccessGroupRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/acs/users/add_to_access_group", requestOptions); + _seam.Put("/acs/users/add_to_access_group", requestOptions); } /// @@ -98,7 +98,7 @@ public async Task AddToAccessGroupAsync(AddToAccessGroupRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/acs/users/add_to_access_group", requestOptions); + await _seam.PutAsync("/acs/users/add_to_access_group", requestOptions); } /// @@ -1661,7 +1661,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/acs/users/update", requestOptions); + _seam.Patch("/acs/users/update", requestOptions); } /// @@ -1701,7 +1701,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/acs/users/update", requestOptions); + await _seam.PatchAsync("/acs/users/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/Webhooks.cs b/output/csharp/src/Seam/Api/Webhooks.cs index aeb7a78..917c973 100644 --- a/output/csharp/src/Seam/Api/Webhooks.cs +++ b/output/csharp/src/Seam/Api/Webhooks.cs @@ -414,7 +414,7 @@ public List List(ListRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/webhooks/list", requestOptions) + .Get("/webhooks/list", requestOptions) .EnsureData("/webhooks/list") .Webhooks; } @@ -434,7 +434,7 @@ public async Task> ListAsync(ListRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return (await _seam.PostAsync("/webhooks/list", requestOptions)) + return (await _seam.GetAsync("/webhooks/list", requestOptions)) .EnsureData("/webhooks/list") .Webhooks; } @@ -501,7 +501,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/webhooks/update", requestOptions); + _seam.Put("/webhooks/update", requestOptions); } /// @@ -519,7 +519,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/webhooks/update", requestOptions); + await _seam.PutAsync("/webhooks/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Api/Workspaces.cs b/output/csharp/src/Seam/Api/Workspaces.cs index 3043042..91f5847 100644 --- a/output/csharp/src/Seam/Api/Workspaces.cs +++ b/output/csharp/src/Seam/Api/Workspaces.cs @@ -449,7 +449,7 @@ public Workspace Get(GetRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/workspaces/get", requestOptions) + .Get("/workspaces/get", requestOptions) .EnsureData("/workspaces/get") .Workspace; } @@ -469,7 +469,7 @@ public async Task GetAsync(GetRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return (await _seam.PostAsync("/workspaces/get", requestOptions)) + return (await _seam.GetAsync("/workspaces/get", requestOptions)) .EnsureData("/workspaces/get") .Workspace; } @@ -556,7 +556,7 @@ public List List(ListRequest request) var requestOptions = new RequestOptions(); requestOptions.Data = request; return _seam - .Post("/workspaces/list", requestOptions) + .Get("/workspaces/list", requestOptions) .EnsureData("/workspaces/list") .Workspaces; } @@ -576,7 +576,7 @@ public async Task> ListAsync(ListRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - return (await _seam.PostAsync("/workspaces/list", requestOptions)) + return (await _seam.GetAsync("/workspaces/list", requestOptions)) .EnsureData("/workspaces/list") .Workspaces; } @@ -889,7 +889,7 @@ public void Update(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - _seam.Post("/workspaces/update", requestOptions); + _seam.Patch("/workspaces/update", requestOptions); } /// @@ -923,7 +923,7 @@ public async Task UpdateAsync(UpdateRequest request) { var requestOptions = new RequestOptions(); requestOptions.Data = request; - await _seam.PostAsync("/workspaces/update", requestOptions); + await _seam.PatchAsync("/workspaces/update", requestOptions); } /// diff --git a/output/csharp/src/Seam/Client/Null.cs b/output/csharp/src/Seam/Client/Null.cs new file mode 100644 index 0000000..c9e5b85 --- /dev/null +++ b/output/csharp/src/Seam/Client/Null.cs @@ -0,0 +1,75 @@ +using System; +using Newtonsoft.Json; + +namespace Seam.Client +{ + /// + /// The explicit null sentinel used by request parameters. + /// + /// + /// + /// C# has a single absence value, null, but the Seam API distinguishes an omitted + /// parameter from a parameter explicitly set to null. In an update request, an omitted + /// parameter leaves the current value unchanged, while a null parameter unsets it. + /// + /// + /// Since sending null is rarely intended and unsetting a value cannot be undone, null + /// means the safe option of omitting the parameter. Sending null is explicit and always + /// spelled Null.Value, which serializes to JSON null in a request body and to + /// an empty value in a query string. + /// + /// + /// UrlSearchParamsSerializer.Serialize( + /// new Dictionary<string, object> { ["name"] = Null.Value, ["limit"] = 20 } + /// ); + /// // => "limit=20&name=" + /// + /// + [JsonConverter(typeof(NullJsonConverter))] + public sealed class Null + { + /// + /// The sentinel for a parameter explicitly set to null. + /// + public static readonly Null Value = new Null(); + + private Null() { } + + public override string ToString() + { + return "null"; + } + } + + /// + /// Writes the sentinel as JSON null. + /// + /// + /// Declared on itself so the sentinel serializes to null under any + /// serializer settings, including ones a caller supplies. + /// + internal class NullJsonConverter : JsonConverter + { + public override bool CanRead => false; + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(Null); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteNull(); + } + + public override object ReadJson( + JsonReader reader, + Type objectType, + object existingValue, + JsonSerializer serializer + ) + { + throw new NotSupportedException("The Null sentinel cannot be deserialized."); + } + } +} diff --git a/output/csharp/src/Seam/Client/Seam.cs b/output/csharp/src/Seam/Client/Seam.cs index f4ebe10..cbd3005 100644 --- a/output/csharp/src/Seam/Client/Seam.cs +++ b/output/csharp/src/Seam/Client/Seam.cs @@ -24,6 +24,7 @@ using System.Threading.Tasks; using System.Web; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; using Polly; using RestSharp; @@ -362,7 +363,21 @@ private RestRequest NewRequest( if (options.Data != null) { - if (options.Data is Stream stream) + if (CarriesDataInQuery(method)) + { + // Uri normalizes a percent-encoded unreserved character back to its literal + // form, so `~` reaches the wire as `~` rather than as `%7E`. Both decode to + // the same param. + var query = StrictUrlSearchParamsSerializer.Serialize( + ToSearchParams(options.Data) + ); + + if (query.Length > 0) + { + request.Resource = $"{path}?{query}"; + } + } + else if (options.Data is Stream stream) { var contentType = "application/octet-stream"; if (options.HeaderParameters != null) @@ -430,6 +445,58 @@ private RestRequest NewRequest( return request; } + /// + /// Whether the request data travels as URL search params rather than as a body. + /// + private static bool CarriesDataInQuery(HttpMethod method) + { + return method == HttpMethod.Get || method == HttpMethod.Delete; + } + + /// + /// Converts request data to search params through its JSON contract, so a parameter + /// carries the same name and the same value whether it travels in the query or the body. + /// + private IDictionary ToSearchParams(object data) + { + var token = JToken.FromObject(data, JsonSerializer.CreateDefault(SerializerSettings)); + + if (!(ToSearchParamValue(token) is IDictionary parameters)) + { + throw new ArgumentException( + $"Request data must serialize to an object, got {token.Type}", + nameof(data) + ); + } + + return parameters; + } + + /// + /// An unset parameter is absent from the JSON contract, so a null can only be the + /// sentinel and is restored as one. + /// + private static object ToSearchParamValue(JToken token) + { + switch (token.Type) + { + case JTokenType.Object: + var parameters = new Dictionary(); + foreach (var property in ((JObject)token).Properties()) + { + parameters[property.Name] = ToSearchParamValue(property.Value); + } + return parameters; + case JTokenType.Array: + return ((JArray)token).Select(ToSearchParamValue).ToList(); + case JTokenType.Null: + case JTokenType.Undefined: + return Null.Value; + default: + return ((JValue)token).Value; + } + } + private ApiResponse ToApiResponse(RestResponse response) { T result = response.Data; diff --git a/output/csharp/src/Seam/Client/StrictUrlSearchParamsSerializer.cs b/output/csharp/src/Seam/Client/StrictUrlSearchParamsSerializer.cs new file mode 100644 index 0000000..36f63e2 --- /dev/null +++ b/output/csharp/src/Seam/Client/StrictUrlSearchParamsSerializer.cs @@ -0,0 +1,50 @@ +using System.Collections; + +namespace Seam.Client +{ + /// + /// Serializes parameters for the Seam API: the URL search parameters standard plus + /// _strict=true appended to any non-empty query, which tells the API to use strict, + /// schema-aware parsing. A query with no serializable parameters remains empty. + /// + /// + /// The strict flag is Seam API behavior, not part of the serialization standard, so it lives + /// here rather than in , which stays a pure + /// implementation of the standard. + /// + public static class StrictUrlSearchParamsSerializer + { + /// + /// Serializes parameters to a URL search parameter query string with strict API + /// validation enabled, without a leading ?. + /// + /// + /// If any parameter could not be serialized. + /// + public static string Serialize(IDictionary parameters) + { + var searchParams = new UrlSearchParams(); + Update(searchParams, parameters); + + return searchParams.ToString(); + } + + /// + /// Updates existing URL search parameters with serialized parameters and strict API + /// validation enabled. + /// + /// + /// If any parameter could not be serialized. + /// + public static void Update(UrlSearchParams searchParams, IDictionary parameters) + { + UrlSearchParamsSerializer.Update(searchParams, parameters); + + if (searchParams.Count > 0) + { + searchParams.Delete("_strict"); + searchParams.Append("_strict", "true"); + } + } + } +} diff --git a/output/csharp/src/Seam/Client/UnserializableParamError.cs b/output/csharp/src/Seam/Client/UnserializableParamError.cs new file mode 100644 index 0000000..eefe175 --- /dev/null +++ b/output/csharp/src/Seam/Client/UnserializableParamError.cs @@ -0,0 +1,25 @@ +using System; + +namespace Seam.Client +{ + /// + /// Thrown when a request parameter could not be serialized, before any request is sent. + /// + public class UnserializableParamError : ArgumentException + { + private readonly string _paramName; + + /// + /// The name of the parameter that could not be serialized, e.g. foo.bar for a + /// nested parameter. + /// + /// Why the parameter could not be serialized. + public UnserializableParamError(string paramName, string reason) + : base($"Could not serialize parameter: '{paramName}' {reason}") + { + _paramName = paramName; + } + + public override string ParamName => _paramName; + } +} diff --git a/output/csharp/src/Seam/Client/UrlSearchParams.cs b/output/csharp/src/Seam/Client/UrlSearchParams.cs new file mode 100644 index 0000000..4b96697 --- /dev/null +++ b/output/csharp/src/Seam/Client/UrlSearchParams.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace Seam.Client +{ + /// + /// A mutable collection of URL search parameters. + /// + /// + /// Implements the parts of the + /// URLSearchParams + /// interface needed to serialize parameters to a query string. Unlike a dictionary, a name may + /// appear more than once, which is how arrays are serialized. + /// + public class UrlSearchParams : IEnumerable> + { + private List> _pairs = + new List>(); + + /// + /// Creates an empty collection. + /// + public UrlSearchParams() { } + + /// + /// Creates a collection from a query string, with or without a leading ?. + /// + public UrlSearchParams(string query) + { + if (string.IsNullOrEmpty(query)) + { + return; + } + + var pairs = query.StartsWith("?", StringComparison.Ordinal) + ? query.Substring(1) + : query; + + foreach (var pair in pairs.Split('&')) + { + if (pair.Length == 0) + { + continue; + } + + var separator = pair.IndexOf('='); + var name = separator < 0 ? pair : pair.Substring(0, separator); + var value = separator < 0 ? string.Empty : pair.Substring(separator + 1); + + Append(DecodeFormComponent(name), DecodeFormComponent(value)); + } + } + + /// + /// Creates a collection from name-value pairs, in order. + /// + public UrlSearchParams(IEnumerable> pairs) + { + _pairs = pairs.ToList(); + } + + /// + /// The number of pairs in the collection. + /// + public int Count => _pairs.Count; + + /// + /// Appends a name-value pair, keeping any existing pairs with this name. + /// + public void Append(string name, string value) + { + _pairs.Add(new KeyValuePair(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 void Set(string name, string value) + { + var pairs = new List>(_pairs.Count); + var isSet = false; + + foreach (var pair in _pairs) + { + if (pair.Key != name) + { + pairs.Add(pair); + continue; + } + + if (isSet) + { + continue; + } + + pairs.Add(new KeyValuePair(name, value)); + isSet = true; + } + + if (!isSet) + { + pairs.Add(new KeyValuePair(name, value)); + } + + _pairs = pairs; + } + + /// + /// Returns the value of the first pair with this name, or null if no pair with this name + /// exists. + /// + public string Get(string name) + { + foreach (var pair in _pairs) + { + if (pair.Key == name) + { + return pair.Value; + } + } + + return null; + } + + /// + /// Returns the values of all pairs with this name, in insertion order. + /// + public IList GetAll(string name) + { + return _pairs.Where(pair => pair.Key == name).Select(pair => pair.Value).ToList(); + } + + /// + /// Returns whether a pair with this name exists. + /// + public bool Has(string name) + { + return _pairs.Any(pair => pair.Key == name); + } + + /// + /// Removes all pairs with this name. + /// + public void Delete(string name) + { + _pairs = _pairs.Where(pair => pair.Key != name).ToList(); + } + + /// + /// Sorts all pairs by name, comparing UTF-16 code units. + /// + /// + /// Sorting is stable, so the relative order of pairs with the same name is preserved, + /// which is what keeps array element order. + /// + public void Sort() + { + _pairs = _pairs.OrderBy(pair => pair.Key, StringComparer.Ordinal).ToList(); + } + + /// + /// Serializes all pairs to a query string, without a leading ?. + /// + /// + /// Every pair gets an =, including empty values, e.g. name=. + /// + public override string ToString() + { + return string.Join( + "&", + _pairs.Select(pair => + EncodeFormComponent(pair.Key) + "=" + EncodeFormComponent(pair.Value) + ) + ); + } + + public IEnumerator> GetEnumerator() + { + return _pairs.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// 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 Uri.EscapeDataString + /// nor WebUtility.UrlEncode produces it: * is emitted literally and ~ + /// is escaped, the exact opposite of the former, and the latter emits lowercase hex. + /// + private static string EncodeFormComponent(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var encoded = new StringBuilder(bytes.Length); + + foreach (var b in bytes) + { + if (IsFormSafe(b)) + { + encoded.Append((char)b); + } + else if (b == 0x20) + { + encoded.Append('+'); + } + else + { + encoded.Append('%').Append(b.ToString("X2", CultureInfo.InvariantCulture)); + } + } + + return encoded.ToString(); + } + + private static bool IsFormSafe(byte b) + { + return (b >= 0x30 && b <= 0x39) + || (b >= 0x41 && b <= 0x5a) + || (b >= 0x61 && b <= 0x7a) + || b == (byte)'*' + || b == (byte)'-' + || b == (byte)'.' + || b == (byte)'_'; + } + + private static string DecodeFormComponent(string value) + { + var bytes = new List(value.Length); + var literal = new StringBuilder(); + + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + + if ( + character == '%' + && index + 2 < value.Length + && byte.TryParse( + value.Substring(index + 1, 2), + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out var decoded + ) + ) + { + bytes.AddRange(Encoding.UTF8.GetBytes(literal.ToString())); + literal.Clear(); + bytes.Add(decoded); + index += 2; + continue; + } + + literal.Append(character == '+' ? ' ' : character); + } + + bytes.AddRange(Encoding.UTF8.GetBytes(literal.ToString())); + + return Encoding.UTF8.GetString(bytes.ToArray()); + } + } +} diff --git a/output/csharp/src/Seam/Client/UrlSearchParamsSerializer.cs b/output/csharp/src/Seam/Client/UrlSearchParamsSerializer.cs new file mode 100644 index 0000000..08a6aaa --- /dev/null +++ b/output/csharp/src/Seam/Client/UrlSearchParamsSerializer.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Seam.Client +{ + /// + /// Serializes values to URL search parameters. + /// + /// + /// + /// This is a C# port of the + /// @seamapi/url-search-params-serializer + /// reference implementation, which defines the standard for how the Seam SDKs and other Seam + /// API consumers serialize objects to URL search parameters in HTTP GET requests. The Seam API + /// parses them with the corresponding + /// parser. + /// + /// + /// Output is byte-for-byte identical to the reference implementation: values are encoded with + /// the application/x-www-form-urlencoded serializer, parameters are sorted by name, and + /// numbers are formatted using the ECMAScript Number::toString algorithm. + /// + /// + /// Type mapping between the reference implementation and this port: + /// + /// + /// JavaScript undefined is null, or simply an absent key. + /// + /// JavaScript null is . C# has a single absence value, so + /// null means the safe option of omitting the parameter and sending null is always + /// explicit. + /// + /// JavaScript string is string and boolean is bool. + /// + /// JavaScript number is float or double, each formatted from its own + /// shortest round-tripping representation, and bigint is any integral type, which is + /// always serialized in full without exponent notation. A decimal is serialized from + /// its own exact value rather than the nearest double. + /// + /// + /// JavaScript Date and Temporal.Instant are and + /// . Since Date has millisecond precision, sub-millisecond + /// precision is truncated. A with an unspecified kind is read as UTC, + /// so serialization never depends on the local time zone. + /// + /// A JavaScript plain object is an with string keys. + /// + /// A JavaScript Array is any other , e.g. an array or a + /// List. + /// + /// + /// + public static class UrlSearchParamsSerializer + { + /// + /// Serializes parameters to a URL search parameter query string, without a leading + /// ?. + /// + /// + /// If any parameter could not be serialized. + /// + public static string Serialize(IDictionary parameters) + { + var searchParams = new UrlSearchParams(); + Update(searchParams, parameters); + + return searchParams.ToString(); + } + + /// + /// Updates existing URL search parameters with serialized parameters. + /// + /// + /// Existing parameters are preserved unless overwritten by a serialized parameter. All + /// parameters are sorted by name. + /// + /// + /// If any parameter could not be serialized. + /// + public static void Update(UrlSearchParams searchParams, IDictionary parameters) + { + NestedUpdate(searchParams, parameters, new List()); + searchParams.Sort(); + } + + private static void NestedUpdate( + UrlSearchParams searchParams, + IDictionary parameters, + IList path + ) + { + foreach (DictionaryEntry entry in parameters) + { + if (!(entry.Key is string key)) + { + throw new UnserializableParamError( + Convert.ToString(entry.Key, CultureInfo.InvariantCulture), + "has a name that is not a string which is unsupported" + ); + } + + if (key.Contains('.')) + { + throw new UnserializableParamError( + key, + "contains one or more dots \".\" in its name which is unsupported" + ); + } + + var currentPath = new List(path) { key }; + var value = entry.Value; + + if (value is IDictionary nested) + { + NestedUpdate(searchParams, nested, currentPath); + continue; + } + + var name = string.Join(".", currentPath); + + if (value == null) + { + continue; + } + + if (value is string text && text.Length == 0) + { + continue; + } + + if (!(value is string) && value is IEnumerable values) + { + UpdateFromEnumerable(searchParams, name, values); + continue; + } + + searchParams.Set(name, SerializeValue(name, value)); + } + } + + private static void UpdateFromEnumerable( + UrlSearchParams searchParams, + string name, + IEnumerable values + ) + { + var items = values.Cast().ToList(); + + if (items.Count == 0) + { + // The one case where an empty value is meaningful: the parser reads `name=` as + // the empty array. + searchParams.Set(name, ""); + return; + } + + if (items.Count == 1 && IsEmptyString(items[0])) + { + throw new UnserializableParamError( + name, + "is a single element array containing the empty string which is unsupported" + ); + } + + if (items.Any(IsEmptyString)) + { + throw new UnserializableParamError( + name, + "is an array containing the empty string which is unsupported" + ); + } + + if (items.Any(item => item == null || item is Null)) + { + throw new UnserializableParamError( + name, + "is an array containing null or undefined values which is unsupported" + ); + } + + foreach (var item in items) + { + searchParams.Append(name, SerializeValue(name, item)); + } + } + + private static bool IsEmptyString(object value) + { + return value is string text && text.Length == 0; + } + + private static string SerializeValue(string name, object value) + { + if (value is Null) + { + return ""; + } + + if (value is string text) + { + return text; + } + + if (value is bool flag) + { + return flag ? "true" : "false"; + } + + if ( + value is sbyte + || value is byte + || value is short + || value is ushort + || value is int + || value is uint + || value is long + || value is ulong + ) + { + return Convert.ToString(value, CultureInfo.InvariantCulture); + } + + if (value is float single) + { + return SerializeSingle(name, single); + } + + if (value is double number) + { + return SerializeDouble(name, number); + } + + if (value is decimal fixedPoint) + { + return SerializeDecimal(fixedPoint); + } + + if (value is DateTimeOffset dateTimeOffset) + { + return SerializeDateTime(dateTimeOffset.UtcDateTime); + } + + if (value is DateTime dateTime) + { + return SerializeDateTime( + dateTime.Kind == DateTimeKind.Local ? dateTime.ToUniversalTime() : dateTime + ); + } + + throw new UnserializableParamError(name, $"is a {value.GetType().Name}"); + } + + private static string SerializeSingle(string name, float value) + { + if (float.IsNaN(value)) + { + throw new UnserializableParamError(name, "is NaN"); + } + + if (float.IsInfinity(value)) + { + throw new UnserializableParamError( + name, + value > 0 ? "is Infinity" : "is -Infinity" + ); + } + + if (value == 0f) + { + return "0"; + } + + var formatted = FormatShortestDigits( + Math.Abs(value).ToString("R", CultureInfo.InvariantCulture) + ); + + return value < 0 ? "-" + formatted : formatted; + } + + private static string SerializeDouble(string name, double value) + { + if (double.IsNaN(value)) + { + throw new UnserializableParamError(name, "is NaN"); + } + + if (double.IsInfinity(value)) + { + throw new UnserializableParamError( + name, + value > 0 ? "is Infinity" : "is -Infinity" + ); + } + + if (value == 0d) + { + return "0"; + } + + var formatted = FormatShortestDigits( + Math.Abs(value).ToString("R", CultureInfo.InvariantCulture) + ); + + return value < 0 ? "-" + formatted : formatted; + } + + private static string SerializeDecimal(decimal value) + { + if (value == 0m) + { + return "0"; + } + + var formatted = FormatShortestDigits( + Math.Abs(value).ToString(CultureInfo.InvariantCulture) + ); + + return value < 0 ? "-" + formatted : formatted; + } + + // The shortest round-tripping representation of a positive number, as .NET writes it: + // significant digits, an optional fraction, and an optional exponent. + private static readonly Regex NumberPattern = new Regex( + @"^(\d+)(?:\.(\d+))?(?:E([+-]?\d+))?$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + ); + + /// + /// Reformats the shortest round-tripping representation of a positive number as the + /// ECMAScript Number::toString algorithm renders it. + /// + /// + /// .NET writes the same digits but renders them differently: the exponent thresholds are + /// not at 1e21 and 1e-7, and an exponent is spelled E+16 or E-07 rather than + /// e+16 or e-7. + /// + private static string FormatShortestDigits(string representation) + { + var match = NumberPattern.Match(representation); + + if (!match.Success) + { + throw new InvalidOperationException( + $"Could not parse the number representation: {representation}" + ); + } + + var digits = match.Groups[1].Value + match.Groups[2].Value; + var point = + match.Groups[1].Value.Length + + ( + match.Groups[3].Success + ? int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture) + : 0 + ); + + var stripped = digits.TrimStart('0'); + point -= digits.Length - stripped.Length; + + return FormatDigits(stripped.TrimEnd('0'), point); + } + + /// + /// Formats digits and a decimal point position per ECMAScript Number::toString. + /// + /// Significant digits, without leading or trailing zeros. + /// Position of the decimal point relative to the digits. + /// + /// The four branches and the constants 21 and -6 are the specification. + /// + private static string FormatDigits(string digits, int point) + { + var count = digits.Length; + + if (count <= point && point <= 21) + { + return digits + new string('0', point - count); + } + + if (0 < point && point <= 21) + { + return digits.Substring(0, point) + "." + digits.Substring(point); + } + + if (-6 < point && point <= 0) + { + return "0." + new string('0', -point) + digits; + } + + var exponent = point - 1; + var mantissa = count == 1 ? digits : digits.Substring(0, 1) + "." + digits.Substring(1); + + return mantissa + + "e" + + (exponent >= 0 ? "+" : "-") + + Math.Abs(exponent).ToString(CultureInfo.InvariantCulture); + } + + /// + /// Formats an instant as JavaScript's Date.prototype.toISOString does: always UTC, always + /// exactly three fractional digits, always a literal Z. + /// + private static string SerializeDateTime(DateTime value) + { + return value.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + } + } +} diff --git a/output/csharp/src/Seam/README.md b/output/csharp/src/Seam/README.md index 8c59e38..3afd545 100644 --- a/output/csharp/src/Seam/README.md +++ b/output/csharp/src/Seam/README.md @@ -14,6 +14,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 { ["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 @@ -30,3 +59,66 @@ The default may also be changed for every client at once: ```csharp 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 { ["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 { ["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