From 99dea31c09a29dbac8b3c9ec891616c7c60248c8 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 14 Aug 2026 06:30:16 +0000 Subject: [PATCH 1/3] feat: implement the URL search params serialization standard Port @seamapi/url-search-params-serializer to C# as Seam.Client.UrlSearchParamsSerializer over a Seam.Client.UrlSearchParams pair collection, byte-for-byte identical to the TypeScript reference implementation. The test suite mirrors the reference and the other SDK suites, covering every branch of the standard. The .NET primitives each diverge from the standard, so the port implements them directly: Uri.EscapeDataString is RFC 3986 flavored (escapes *, keeps ~) where the WHATWG form encoding does the opposite, and WebUtility.UrlEncode emits lowercase hex; the shortest round-tripping number format switches to exponent notation at the wrong thresholds and spells them E+16, so numbers follow the ECMAScript Number::toString algorithm; and List.Sort is unstable, which would lose array element order, so the sort is stable and ordinal, as URLSearchParams.sort() is. C# has a single absence value, so Seam.Client.Null adds the explicit null sentinel: null means the safe option of omitting a param, and sending null is always spelled Null.Value. The sentinel declares its own JsonConverter, so a param set to it is sent as JSON null in a request body under any serializer settings, while an omitted param is still dropped by EmitDefaultValue. Every SDK request sends a JSON body, so that is where the sentinel reaches the API; the serializers are exported for callers that build their own requests. StrictUrlSearchParamsSerializer wraps the base serializer and appends _strict=true to any non-empty query, telling the Seam API to use strict, schema-aware parsing. The flag is appended after the sort so it always sits last, a caller-supplied _strict param is replaced rather than repeated, and a query with no serializable params stays empty. The flag is Seam API behavior, not part of the serialization standard, so it is isolated in the wrapper and the base serializer stays a pure implementation of the standard. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BLJcDjH3YHxDgtPgfq82g4 --- README.md | 90 ++++ .../csharp/src/Seam.Test/Client/NullTests.cs | 53 +++ .../StrictUrlSearchParamsSerializerTests.cs | 63 +++ .../Client/UrlSearchParamsSerializerTests.cs | 392 +++++++++++++++++ .../Seam.Test/Client/UrlSearchParamsTests.cs | 131 ++++++ output/csharp/src/Seam/Client/Null.cs | 75 ++++ .../Client/StrictUrlSearchParamsSerializer.cs | 50 +++ .../Seam/Client/UnserializableParamError.cs | 25 ++ .../csharp/src/Seam/Client/UrlSearchParams.cs | 274 ++++++++++++ .../Seam/Client/UrlSearchParamsSerializer.cs | 412 ++++++++++++++++++ output/csharp/src/Seam/README.md | 90 ++++ 11 files changed, 1655 insertions(+) create mode 100644 output/csharp/src/Seam.Test/Client/NullTests.cs create mode 100644 output/csharp/src/Seam.Test/Client/StrictUrlSearchParamsSerializerTests.cs create mode 100644 output/csharp/src/Seam.Test/Client/UrlSearchParamsSerializerTests.cs create mode 100644 output/csharp/src/Seam.Test/Client/UrlSearchParamsTests.cs create mode 100644 output/csharp/src/Seam/Client/Null.cs create mode 100644 output/csharp/src/Seam/Client/StrictUrlSearchParamsSerializer.cs create mode 100644 output/csharp/src/Seam/Client/UnserializableParamError.cs create mode 100644 output/csharp/src/Seam/Client/UrlSearchParams.cs create mode 100644 output/csharp/src/Seam/Client/UrlSearchParamsSerializer.cs diff --git a/README.md b/README.md index 4ffaf6a..ab50d17 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,67 @@ 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. +If you call it 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/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/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..efffa1c --- /dev/null +++ b/output/csharp/src/Seam.Test/Client/UrlSearchParamsSerializerTests.cs @@ -0,0 +1,392 @@ +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" }))); + } + + [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/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/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..d748c63 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,64 @@ 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. +If you call it 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 From 3e63e997963eb517a15b38def0926137b2eabd67 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 14 Aug 2026 07:52:34 +0000 Subject: [PATCH 2/3] feat: send each request with the endpoint's preferred method Consume the blueprint's preferredMethod so a route calls the client method the Seam API prefers for it, rather than posting everything. Nine endpoints become GET and one becomes DELETE; the rest keep a body as POST, PUT or PATCH. Which transport carries the params follows from the method, so the generated route stays the same shape for every endpoint and the client decides: a GET or DELETE serializes its params into the query string with StrictUrlSearchParamsSerializer, and everything else sends a JSON body as before. This is what applies the serialization standard to the SDK's own requests, where until now it was only exported for callers building their own. The params are converted to search params through the request's JSON contract rather than by reflection, so a param carries the same name and the same value on either transport: DataMember names, string enum values, and EmitDefaultValue omission all behave as they do in a body. A param left unset is absent from that contract, so a null in it can only be the Null sentinel and is restored as one, which serializes to an empty value. One byte of the query is not the serializer's: 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. The new transport tests assert what reaches an HttpListener for each preferred method: the request line, the serialized query, and the body. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BLJcDjH3YHxDgtPgfq82g4 --- README.md | 4 +- codegen/layouts/partials/route-methods.hbs | 8 +- codegen/lib/build-model.ts | 3 + codegen/lib/class-model.ts | 7 +- .../Seam.Test/Client/RequestTransportTests.cs | 172 ++++++++++++++++++ output/csharp/src/Seam/Api/AccessCodes.cs | 12 +- output/csharp/src/Seam/Api/AccessGrants.cs | 8 +- output/csharp/src/Seam/Api/AccessGroupsAcs.cs | 4 +- output/csharp/src/Seam/Api/AccessMethods.cs | 4 +- output/csharp/src/Seam/Api/ClientSessions.cs | 10 +- .../csharp/src/Seam/Api/ConnectedAccounts.cs | 8 +- output/csharp/src/Seam/Api/CredentialsAcs.cs | 16 +- .../src/Seam/Api/DailyProgramsThermostats.cs | 4 +- output/csharp/src/Seam/Api/Devices.cs | 4 +- output/csharp/src/Seam/Api/EncodersAcs.cs | 4 +- .../Seam/Api/NoiseThresholdsNoiseSensors.cs | 4 +- .../src/Seam/Api/SchedulesThermostats.cs | 4 +- output/csharp/src/Seam/Api/Spaces.cs | 20 +- output/csharp/src/Seam/Api/Thermostats.cs | 11 +- .../src/Seam/Api/UnmanagedAccessCodes.cs | 8 +- .../src/Seam/Api/UnmanagedAccessGrants.cs | 4 +- .../csharp/src/Seam/Api/UnmanagedDevices.cs | 4 +- .../src/Seam/Api/UnmanagedUserIdentities.cs | 4 +- output/csharp/src/Seam/Api/UserIdentities.cs | 19 +- output/csharp/src/Seam/Api/UsersAcs.cs | 8 +- output/csharp/src/Seam/Api/Webhooks.cs | 8 +- output/csharp/src/Seam/Api/Workspaces.cs | 12 +- output/csharp/src/Seam/Client/Seam.cs | 69 ++++++- output/csharp/src/Seam/README.md | 4 +- 29 files changed, 348 insertions(+), 99 deletions(-) create mode 100644 output/csharp/src/Seam.Test/Client/RequestTransportTests.cs diff --git a/README.md b/README.md index ab50d17..470ffe7 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ GlobalSeamRequestConfiguration.Instance.Timeout = 60000; ### Serializing URL search params The Seam API parses URL search params as complex types. -If you call it with your own HTTP client, +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. 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/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/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/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/README.md b/output/csharp/src/Seam/README.md index d748c63..3afd545 100644 --- a/output/csharp/src/Seam/README.md +++ b/output/csharp/src/Seam/README.md @@ -63,7 +63,9 @@ GlobalSeamRequestConfiguration.Instance.Timeout = 60000; ### Serializing URL search params The Seam API parses URL search params as complex types. -If you call it with your own HTTP client, +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. From 83769a691538835400eefea8e03f04a42591fd16 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 14 Aug 2026 16:58:09 +0000 Subject: [PATCH 3/3] test: sort an array long enough to catch an unstable sort Three elements prove nothing about stability: below 17 elements .NET's introsort degrades to an insertion sort, which keeps a short array in order whether or not the sort preserves it. Sort 32 instead, and assert the same string the PHP, Python, Ruby and JavaScript SDKs produce for the same params. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BLJcDjH3YHxDgtPgfq82g4 --- .../Seam.Test/Client/UrlSearchParamsSerializerTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/output/csharp/src/Seam.Test/Client/UrlSearchParamsSerializerTests.cs b/output/csharp/src/Seam.Test/Client/UrlSearchParamsSerializerTests.cs index efffa1c..e2aecb2 100644 --- a/output/csharp/src/Seam.Test/Client/UrlSearchParamsSerializerTests.cs +++ b/output/csharp/src/Seam.Test/Client/UrlSearchParamsSerializerTests.cs @@ -242,6 +242,15 @@ public void SortsParamsByUtf16CodeUnit() 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]