From 04290cbca543a4ad15e803eba0c285dbe12c9323 Mon Sep 17 00:00:00 2001 From: Mako88 Date: Sat, 30 May 2026 03:55:58 -0500 Subject: [PATCH 1/4] v5.0.0: make System.Text.Json the default serializer and remove Newtonsoft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SimpleHttpDefaultJsonSerializer now derives from SimpleHttpSystemTextJsonSerializer (System.Text.Json), so the client's default serialization moves off Newtonsoft.Json, which is dropped as a dependency. Breaking: System.Text.Json is stricter — types using a non-public parameterless constructor, or fields whose JSON shape varies (string vs object), deserialize differently. README documents the migration. Convert the test suite off Newtonsoft (JToken/JObject -> System.Text.Json.Nodes, JsonConvert -> JsonSerializer). The postman-echo "data" field is an empty string on form posts but an object on JSON posts; typed as JsonNode so STJ doesn't reject it. Switch the release workflow to NuGet trusted publishing (OIDC) instead of a long-lived API key: add id-token: write, exchange the token via NuGet/login@v1 right before push. Requires a NUGET_USER secret and a trusted-publishing policy on nuget.org. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 23 ++++++++++-- README.md | 10 ++--- .../SimpleHttpDefaultJsonSerializerTests.cs | 4 +- ...SimpleHttpSystemTextJsonSerializerTests.cs | 3 +- .../SimpleClientTests.cs | 37 ++++++++++--------- .../SimpleRequestTests.cs | 12 +++--- .../SimpleResponseTests.cs | 6 +-- .../SimpleHttpDefaultJsonSerializer.cs | 26 ++----------- .../SimpleHttpSystemTextJsonSerializer.cs | 12 +++--- src/SimpleHttpClient/SimpleHttpClient.csproj | 3 +- 10 files changed, 66 insertions(+), 70 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcf23b5..7aaa970 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,15 @@ name: Release # when no tag exists yet), so never has to be edited by hand. Each # successful publish creates the matching v tag and a GitHub Release. # -# Requires a repository secret NUGET_API_KEY (Settings > Secrets and variables > Actions) -# with push rights for the SimpleHttpClient package. +# Publishing uses NuGet trusted publishing (OIDC) — no long-lived API key. The job +# requests a short-lived key at runtime via the NuGet/login action, so it needs +# `id-token: write` permission and a repository secret NUGET_USER holding the +# nuget.org username (profile name, NOT an email). +# +# Trusted publishing setup (one-time, on nuget.org > your account > Trusted Publishing): +# Repository Owner: Mako88 Repository: SimpleHttpClient +# Workflow File: release.yml Environment: (leave empty) +# Docs: https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing # # First-time setup: seed a tag matching the last published version so the first bump is # correct, e.g. git tag v4.1.0 && git push origin v4.1.0 @@ -33,6 +40,7 @@ concurrency: permissions: contents: write + id-token: write # request the OIDC token used for NuGet trusted publishing jobs: release: @@ -118,9 +126,18 @@ jobs: if: steps.bump.outputs.proceed == 'true' run: dotnet build src/SimpleHttpClient/SimpleHttpClient.csproj -c Release -p:Version=${{ steps.version.outputs.version }} + # Exchange the GitHub OIDC token for a short-lived nuget.org API key (valid ~1h). + # Done right before push so it doesn't expire mid-build. + - name: NuGet login (OIDC -> short-lived API key) + if: steps.bump.outputs.proceed == 'true' + uses: NuGet/login@v1 + id: nuget-login + with: + user: ${{ secrets.NUGET_USER }} + - name: Push to NuGet if: steps.bump.outputs.proceed == 'true' - run: dotnet nuget push "src/SimpleHttpClient/bin/Release/*.nupkg" -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate + run: dotnet nuget push "src/SimpleHttpClient/bin/Release/*.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate - name: Tag and create GitHub Release if: steps.bump.outputs.proceed == 'true' diff --git a/README.md b/README.md index 8b01b11..32ddd09 100644 --- a/README.md +++ b/README.md @@ -233,14 +233,10 @@ request.SerializerOverride = new SimpleHttpDefaultJsonSerializer(); ``` You can supply your own serializer by implementing `ISimpleHttpSerializer`. -#### System.Text.Json -The default JSON serializer uses `Newtonsoft.Json`. A `System.Text.Json`-based serializer is also included and can be opted into the same way: -```csharp -client.Serializer = new SimpleHttpSystemTextJsonSerializer(); -``` -Its settings mirror the default (camelCase names, null values omitted, indented output, case-insensitive deserialization), so it's a drop-in for most payloads. Note that `System.Text.Json` is stricter than `Newtonsoft.Json` — most notably it can't use a non-public parameterless constructor when deserializing, so such types need a public constructor or a `[JsonConstructor]`. +#### JSON serialization +The default JSON serializer (`SimpleHttpDefaultJsonSerializer`) is backed by `System.Text.Json`. It serializes with camelCase names, omits null values, writes indented output, and deserializes case-insensitively. The equivalent `SimpleHttpSystemTextJsonSerializer` is also available for callers who reference it explicitly. -> **Heads up:** `SimpleHttpSystemTextJsonSerializer` is slated to become the default in the next major version (v5), at which point the `Newtonsoft.Json` dependency will be removed. +> **Upgrading from v4?** As of **v5.0.0** the default serializer moved from `Newtonsoft.Json` to `System.Text.Json` and the `Newtonsoft.Json` dependency was removed. `System.Text.Json` is stricter, so watch for: types deserialized via a non-public parameterless constructor (add a public constructor or a `[JsonConstructor]`), and fields whose JSON shape varies (e.g. sometimes a string, sometimes an object) — these threw nothing under Newtonsoft but will under `System.Text.Json`. If you need the old behavior, implement `ISimpleHttpSerializer` with your own `Newtonsoft.Json` serializer and set it on the client. ### Logging You can log requests and responses by setting the `LogRequest` and `LogResponse` delegates (called immediately before a request is sent and immediately after a response is received), or by providing an `ISimpleHttpLogger`: diff --git a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs index 44a3249..163f3af 100644 --- a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs @@ -37,7 +37,9 @@ public void Serialization_Succeeds() var serialized = testObject.Serialize(objectToSerialize); - Assert.Equal(TestSerializationString, serialized); + // Normalize line endings so the comparison doesn't depend on the + // platform's newline or the checkout's git autocrlf setting. + Assert.Equal(TestSerializationString.Replace("\r\n", "\n"), serialized.Replace("\r\n", "\n")); } [Fact] diff --git a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs index 6085450..68a6a56 100644 --- a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs @@ -4,8 +4,7 @@ namespace SimpleHttpClient.Tests.Serialization { public class SimpleHttpSystemTextJsonSerializerTests { - // Same shape and formatting the Newtonsoft-based default produces, so the - // System.Text.Json serializer is verified to be a drop-in for typical payloads. + // The camelCase, indented shape the serializer is expected to produce. private const string TestSerializationString = @"{ ""property1"": ""property1 value"", diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs index 05eba09..aab80f2 100644 --- a/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs @@ -1,5 +1,5 @@ using Moq; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using SimpleHttpClient.Logging; using SimpleHttpClient.Models; using SimpleHttpClient.Serialization; @@ -113,7 +113,7 @@ public async Task UntypedResponse_Succeeds() var response = await client.MakeRequest(request); - var responseJson = JObject.Parse(response.StringBody); + var responseJson = JsonNode.Parse(response.StringBody); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("value1", responseJson?["args"]?["param1"]?.ToString()); @@ -201,8 +201,8 @@ public async Task Request_WithBody_Succeeds() var response = await client.MakeRequest(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("value1", response.Body?.Data?.Param1); - Assert.Equal("value2", response.Body?.Data?.Param2); + Assert.Equal("value1", (response.Body?.Data as JsonObject)?["param1"]?.ToString()); + Assert.Equal("value2", (response.Body?.Data as JsonObject)?["param2"]?.ToString()); } #if NETFRAMEWORK @@ -270,8 +270,8 @@ public async Task UrlFormEncodedParamaters_OverwritesStringBody() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("value1", response.Body?.Form?.Param1); Assert.Equal("value2", response.Body?.Form?.Param2); - Assert.NotEqual("willbeoverwritten", response.Body?.Data?.Param1); - Assert.NotEqual("alsooverwritten", response.Body?.Data?.Param2); + Assert.NotEqual("willbeoverwritten", (response.Body?.Data as JsonObject)?["param1"]?.ToString()); + Assert.NotEqual("alsooverwritten", (response.Body?.Data as JsonObject)?["param2"]?.ToString()); } [Fact] @@ -291,8 +291,8 @@ public async Task UrlFormEncodedParamaters_OverwritesBody() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("value1", response.Body?.Form?.Param1); Assert.Equal("value2", response.Body?.Form?.Param2); - Assert.NotEqual("willbeoverwritten", response.Body?.Data?.Param1); - Assert.NotEqual("alsooverwritten", response.Body?.Data?.Param2); + Assert.NotEqual("willbeoverwritten", (response.Body?.Data as JsonObject)?["param1"]?.ToString()); + Assert.NotEqual("alsooverwritten", (response.Body?.Data as JsonObject)?["param2"]?.ToString()); } [Fact] @@ -310,8 +310,8 @@ public async Task ObjectBody_TakesPrecedenceOver_StringBody() var response = await client.MakeRequest(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("value1", response.Body?.Data?.Param1); - Assert.Equal("value2", response.Body?.Data?.Param2); + Assert.Equal("value1", (response.Body?.Data as JsonObject)?["param1"]?.ToString()); + Assert.Equal("value2", (response.Body?.Data as JsonObject)?["param2"]?.ToString()); } [Fact] @@ -349,7 +349,7 @@ public async Task ContentTypeHeader_IsSetOnBodyContent() var response = await client.MakeRequest(request); - var body = JToken.Parse(response.StringBody); + var body = JsonNode.Parse(response.StringBody)!; Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/json", request.ContentType); @@ -368,7 +368,7 @@ public async Task ContentTypeHeader_DoesNotOverride_RequestContentType() var response = await client.MakeRequest(request); - var body = JToken.Parse(response.StringBody); + var body = JsonNode.Parse(response.StringBody)!; Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/json", request.ContentType); @@ -416,7 +416,7 @@ public async Task StringBody_IsSet_ToSerializedBody() await client.MakeRequest(request); - var body = JToken.Parse(request.StringBody); + var body = JsonNode.Parse(request.StringBody)!; Assert.Equal("value1", body["param1"]?.ToString()); Assert.Equal("value2", body["param2"]?.ToString()); @@ -428,13 +428,13 @@ public async Task ResendingRequest_WithChangedBody_SendsTheNewBody() var request = new SimpleRequest("/post", HttpMethod.Post, new { param1 = "first" }); var first = await client.MakeRequest(request); - Assert.Equal("first", first.Body?.Data?.Param1); + Assert.Equal("first", (first.Body?.Data as JsonObject)?["param1"]?.ToString()); // Changing Body and re-sending the same request object must send the new body. request.Body = new { param1 = "second" }; var second = await client.MakeRequest(request); - Assert.Equal("second", second.Body?.Data?.Param1); + Assert.Equal("second", (second.Body?.Data as JsonObject)?["param1"]?.ToString()); } [Fact] @@ -623,9 +623,12 @@ public class PostmanEchoResponse public Args? Form { get; set; } - public Args? Data { get; set; } + // postman-echo returns "data" as the posted object for JSON bodies, but as an + // empty string for form posts. System.Text.Json (unlike Newtonsoft) won't coerce + // a string into a complex type, so this is typed as a JsonNode to accept either. + public JsonNode? Data { get; set; } - public JToken? Headers { get; set; } + public JsonNode? Headers { get; set; } } public class Args diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs index ff87de7..35876c0 100644 --- a/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs @@ -1,6 +1,6 @@ using Moq; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using SimpleHttpClient.Models; using SimpleHttpClient.Serialization; using System.Net; @@ -57,7 +57,7 @@ public async Task UrlOverride_OverridesHostAndPath(string host, string path) var response = await client.MakeRequest(request); - var responseJson = JObject.Parse(response.StringBody); + var responseJson = JsonNode.Parse(response.StringBody); Assert.Equal("value1", responseJson?["args"]?["param1"]?.ToString()); Assert.Equal("value2", responseJson?["args"]?["param2"]?.ToString()); @@ -67,8 +67,8 @@ public async Task UrlOverride_OverridesHostAndPath(string host, string path) public async Task SerializerOverride_OverridesClientSerializer() { var serializer = new Mock(MockBehavior.Loose); - serializer.Setup(x => x.Serialize(It.IsAny())).Returns((object obj) => JsonConvert.SerializeObject(obj)); - serializer.Setup(x => x.Deserialize(It.IsAny())).Returns((string str) => JsonConvert.DeserializeObject(str)!); + serializer.Setup(x => x.Serialize(It.IsAny())).Returns((object obj) => JsonSerializer.Serialize(obj)); + serializer.Setup(x => x.Deserialize(It.IsAny())).Returns((string str) => JsonSerializer.Deserialize(str)!); var serializer2 = new Mock(MockBehavior.Loose); @@ -160,7 +160,7 @@ public async Task ContentEncoding_IsSet() var response = await client.MakeRequest(request); - var body = JToken.Parse(response.StringBody); + var body = JsonNode.Parse(response.StringBody)!; Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains("ascii", body["headers"]?["content-type"]?.ToString()); diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleResponseTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleResponseTests.cs index d1cebbe..dd681b0 100644 --- a/src/SimpleHttpClient.Tests.Integration/SimpleResponseTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/SimpleResponseTests.cs @@ -1,5 +1,5 @@ using Moq; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using SimpleHttpClient.Models; using SimpleHttpClient.Serialization; using System.Net; @@ -52,7 +52,7 @@ public async Task StringBody_IsSet() var response = await client.MakeRequest(request); - var body = JToken.Parse(response.StringBody); + var body = JsonNode.Parse(response.StringBody)!; Assert.Equal("value1", body["data"]?["param1"]?.ToString()); Assert.Equal("value2", body["data"]?["param2"]?.ToString()); @@ -70,7 +70,7 @@ public async Task ByteBody_IsSet() var response = await client.MakeRequest(request); - var body = JToken.Parse(Encoding.UTF8.GetString(response.ByteBody)); + var body = JsonNode.Parse(Encoding.UTF8.GetString(response.ByteBody))!; Assert.NotNull(response.ByteBody); Assert.NotEmpty(response.ByteBody); diff --git a/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs b/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs index bddf419..28f73b4 100644 --- a/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs +++ b/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs @@ -1,29 +1,11 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; - namespace SimpleHttpClient.Serialization { /// - /// The default Json serializer - uses Newtonsoft.Json. + /// The default JSON serializer. As of v5.0.0 it is backed by System.Text.Json + /// (it used Newtonsoft.Json in earlier versions) and is equivalent to + /// . /// - public class SimpleHttpDefaultJsonSerializer : ISimpleHttpSerializer + public class SimpleHttpDefaultJsonSerializer : SimpleHttpSystemTextJsonSerializer { - /// - /// Serialize the given object into a string. - /// - public string Serialize(object obj) => JsonConvert.SerializeObject(obj, new JsonSerializerSettings - { - ContractResolver = new CamelCasePropertyNamesContractResolver(), - DefaultValueHandling = DefaultValueHandling.Include, - TypeNameHandling = TypeNameHandling.None, - NullValueHandling = NullValueHandling.Ignore, - Formatting = Formatting.Indented, - ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor - }); - - /// - /// Deserialize the given string into an object of type T. - /// - public T Deserialize(string data) => JsonConvert.DeserializeObject(data); } } diff --git a/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs b/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs index b85158f..89a41b3 100644 --- a/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs +++ b/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs @@ -4,17 +4,15 @@ namespace SimpleHttpClient.Serialization { /// - /// A JSON serializer backed by System.Text.Json. Opt in by setting it on the - /// client (or per-request) instead of the Newtonsoft-based default. Its settings - /// mirror the default serializer's behavior (camelCase names, null values omitted, - /// indented output, case-insensitive deserialization) so it's a drop-in for most - /// payloads. + /// A JSON serializer backed by System.Text.Json. This is the default serializer + /// ( derives from it); the type is kept + /// for callers who reference it explicitly. It serializes with camelCase names, omits + /// null values, writes indented output, and deserializes case-insensitively. /// /// /// System.Text.Json is stricter than Newtonsoft.Json. Notably, it cannot use a /// non-public parameterless constructor when deserializing; such types need a public - /// constructor or a . This serializer is slated - /// to become the default in a future major version. + /// constructor or a . /// public class SimpleHttpSystemTextJsonSerializer : ISimpleHttpSerializer { diff --git a/src/SimpleHttpClient/SimpleHttpClient.csproj b/src/SimpleHttpClient/SimpleHttpClient.csproj index bb3dd5e..0e05daa 100644 --- a/src/SimpleHttpClient/SimpleHttpClient.csproj +++ b/src/SimpleHttpClient/SimpleHttpClient.csproj @@ -7,7 +7,7 @@ A_Future_Pilot An easy-to-use .NET wrapper for HttpClient. No extension methods, and included interfaces allow for easy unit test mocking, and straightforward properties allows for easier debugging. http;rest;httpclient;client - 4.2.0 + 5.0.0 https://github.com/Mako88/SimpleHttpClient https://github.com/Mako88/SimpleHttpClient MIT @@ -24,7 +24,6 @@ - From 9e5dc77804d7d413672f91c06da870f26fc854f6 Mon Sep 17 00:00:00 2001 From: Mako88 Date: Sat, 30 May 2026 04:04:53 -0500 Subject: [PATCH 2/4] Restore typed Data deserialization in tests; fill serializer coverage gaps The earlier fix typed PostmanEchoResponse.Data as JsonNode and read it via a dynamic indexer, which defeated the test's purpose: verifying the serializer maps JSON onto strongly-typed properties. Restore Data to the typed Args? and instead attach an EmptyStringTolerantConverter that maps postman-echo's empty-string "data" (returned for form posts) to null, keeping the typed assertions intact. Fill noticeable serializer gaps surfaced by the v5 switch: - nested object + collection + enum round-trip - enums serialize as numbers (Newtonsoft parity) - deserializing into a type with only a non-public constructor throws (the documented System.Text.Json strictness difference) - the default serializer is System.Text.Json-backed Co-Authored-By: Claude Opus 4.8 --- .../SimpleHttpDefaultJsonSerializerTests.cs | 8 ++ ...SimpleHttpSystemTextJsonSerializerTests.cs | 73 +++++++++++++++++++ .../SimpleClientTests.cs | 51 ++++++++++--- 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs index 163f3af..f07c55b 100644 --- a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs @@ -11,6 +11,14 @@ public class SimpleHttpDefaultJsonSerializerTests ""property3"": true }"; + [Fact] + public void DefaultSerializer_IsBackedBy_SystemTextJson() + { + // As of v5.0.0 the default JSON serializer is System.Text.Json-based + // (it derives from SimpleHttpSystemTextJsonSerializer). + Assert.IsAssignableFrom(new SimpleHttpDefaultJsonSerializer()); + } + [Fact] public void RoundTrip_Succeeds() { diff --git a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs index 68a6a56..599053e 100644 --- a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs @@ -77,6 +77,53 @@ public void Serialization_OmitsNullValues() Assert.Contains("\"value\": 1", serialized); } + [Fact] + public void RoundTrip_WithNestedObjectCollectionAndEnum_PreservesValues() + { + var serializer = new SimpleHttpSystemTextJsonSerializer(); + + var original = new ComplexObject + { + Name = "outer", + Status = SampleStatus.Active, + Tags = new List { "a", "b" }, + Child = new TestSerializationObject(), + }; + + var roundTripped = serializer.Deserialize(serializer.Serialize(original)); + + Assert.NotNull(roundTripped); + Assert.Equal("outer", roundTripped.Name); + Assert.Equal(SampleStatus.Active, roundTripped.Status); + Assert.Equal(new[] { "a", "b" }, roundTripped.Tags); + Assert.Equal("property1 value", roundTripped.Child?.Property1); + } + + [Fact] + public void Serialization_WritesEnumsAsNumbers() + { + // Matches Newtonsoft's default (numeric enums), so migrating to System.Text.Json + // doesn't silently change the enum wire format. + var serializer = new SimpleHttpSystemTextJsonSerializer(); + + var serialized = serializer.Serialize(new ComplexObject { Status = SampleStatus.Active }); + + Assert.Contains("\"status\": 1", serialized); + Assert.DoesNotContain("Active", serialized); + } + + [Fact] + public void Deserialization_IntoTypeWithOnlyNonPublicConstructor_Throws() + { + // The headline strictness difference from Newtonsoft: System.Text.Json will not + // invoke a non-public parameterless constructor. Documented in the README migration + // notes; pinned here so the behavior (and the docs) stay honest. + var serializer = new SimpleHttpSystemTextJsonSerializer(); + + Assert.ThrowsAny( + () => serializer.Deserialize("{\"value\":1}")); + } + private static string Normalize(string value) => value.Replace("\r\n", "\n"); private class NullableSerializationObject @@ -85,5 +132,31 @@ private class NullableSerializationObject public int Value { get; set; } = 1; } + + private enum SampleStatus + { + None = 0, + Active = 1, + } + + private class ComplexObject + { + public string? Name { get; set; } + + public SampleStatus Status { get; set; } + + public List? Tags { get; set; } + + public TestSerializationObject? Child { get; set; } + } + + private class NonPublicCtorObject + { + private NonPublicCtorObject() + { + } + + public int Value { get; set; } + } } } diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs index aab80f2..f5c0116 100644 --- a/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs @@ -1,5 +1,7 @@ using Moq; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using SimpleHttpClient.Logging; using SimpleHttpClient.Models; using SimpleHttpClient.Serialization; @@ -201,8 +203,8 @@ public async Task Request_WithBody_Succeeds() var response = await client.MakeRequest(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("value1", (response.Body?.Data as JsonObject)?["param1"]?.ToString()); - Assert.Equal("value2", (response.Body?.Data as JsonObject)?["param2"]?.ToString()); + Assert.Equal("value1", response.Body?.Data?.Param1); + Assert.Equal("value2", response.Body?.Data?.Param2); } #if NETFRAMEWORK @@ -270,8 +272,8 @@ public async Task UrlFormEncodedParamaters_OverwritesStringBody() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("value1", response.Body?.Form?.Param1); Assert.Equal("value2", response.Body?.Form?.Param2); - Assert.NotEqual("willbeoverwritten", (response.Body?.Data as JsonObject)?["param1"]?.ToString()); - Assert.NotEqual("alsooverwritten", (response.Body?.Data as JsonObject)?["param2"]?.ToString()); + Assert.NotEqual("willbeoverwritten", response.Body?.Data?.Param1); + Assert.NotEqual("alsooverwritten", response.Body?.Data?.Param2); } [Fact] @@ -291,8 +293,8 @@ public async Task UrlFormEncodedParamaters_OverwritesBody() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("value1", response.Body?.Form?.Param1); Assert.Equal("value2", response.Body?.Form?.Param2); - Assert.NotEqual("willbeoverwritten", (response.Body?.Data as JsonObject)?["param1"]?.ToString()); - Assert.NotEqual("alsooverwritten", (response.Body?.Data as JsonObject)?["param2"]?.ToString()); + Assert.NotEqual("willbeoverwritten", response.Body?.Data?.Param1); + Assert.NotEqual("alsooverwritten", response.Body?.Data?.Param2); } [Fact] @@ -310,8 +312,8 @@ public async Task ObjectBody_TakesPrecedenceOver_StringBody() var response = await client.MakeRequest(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("value1", (response.Body?.Data as JsonObject)?["param1"]?.ToString()); - Assert.Equal("value2", (response.Body?.Data as JsonObject)?["param2"]?.ToString()); + Assert.Equal("value1", response.Body?.Data?.Param1); + Assert.Equal("value2", response.Body?.Data?.Param2); } [Fact] @@ -428,13 +430,13 @@ public async Task ResendingRequest_WithChangedBody_SendsTheNewBody() var request = new SimpleRequest("/post", HttpMethod.Post, new { param1 = "first" }); var first = await client.MakeRequest(request); - Assert.Equal("first", (first.Body?.Data as JsonObject)?["param1"]?.ToString()); + Assert.Equal("first", first.Body?.Data?.Param1); // Changing Body and re-sending the same request object must send the new body. request.Body = new { param1 = "second" }; var second = await client.MakeRequest(request); - Assert.Equal("second", (second.Body?.Data as JsonObject)?["param1"]?.ToString()); + Assert.Equal("second", second.Body?.Data?.Param1); } [Fact] @@ -625,8 +627,11 @@ public class PostmanEchoResponse // postman-echo returns "data" as the posted object for JSON bodies, but as an // empty string for form posts. System.Text.Json (unlike Newtonsoft) won't coerce - // a string into a complex type, so this is typed as a JsonNode to accept either. - public JsonNode? Data { get; set; } + // a string into a complex type, so the converter maps the non-object case to null + // while keeping Data strongly typed (so the JSON-body tests still verify that + // properties deserialize onto the typed object). + [JsonConverter(typeof(EmptyStringTolerantConverter))] + public Args? Data { get; set; } public JsonNode? Headers { get; set; } } @@ -636,4 +641,26 @@ public class Args public string? Param1 { get; set; } public string? Param2 { get; set; } } + + // Deserializes an object normally, but yields null for any non-object JSON value + // (e.g. the empty string postman-echo returns for the "data" field on form posts), + // rather than letting System.Text.Json throw and fail the whole response. + public class EmptyStringTolerantConverter : JsonConverter where T : class + { + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + reader.Skip(); + return null; + } + + // No converter is registered for T in options (this one is applied per-property), + // so this deserializes with the default object handling rather than recursing. + return JsonSerializer.Deserialize(ref reader, options); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, value, options); + } } \ No newline at end of file From 21d8e77e1d3fb9c628e64af827acda279be0af43 Mon Sep 17 00:00:00 2001 From: Mako88 Date: Sat, 30 May 2026 04:10:03 -0500 Subject: [PATCH 3/4] Add Newtonsoft-leaning read leniencies to the default JSON serializer Soften the default System.Text.Json options to ease the v5 migration without masking genuine type errors: - NumberHandling = AllowReadingFromString (quoted numbers bind to numeric props) - AllowTrailingCommas = true - ReadCommentHandling = Skip Deliberately not softened: non-public constructors and wrong-shape coercion stay strict (documented, with a custom-converter escape hatch in the README). Add tests for the new leniencies. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 +++-- ...SimpleHttpSystemTextJsonSerializerTests.cs | 32 +++++++++++++++++++ .../SimpleHttpSystemTextJsonSerializer.cs | 17 +++++++--- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 32ddd09..f32b54d 100644 --- a/README.md +++ b/README.md @@ -234,9 +234,13 @@ request.SerializerOverride = new SimpleHttpDefaultJsonSerializer(); You can supply your own serializer by implementing `ISimpleHttpSerializer`. #### JSON serialization -The default JSON serializer (`SimpleHttpDefaultJsonSerializer`) is backed by `System.Text.Json`. It serializes with camelCase names, omits null values, writes indented output, and deserializes case-insensitively. The equivalent `SimpleHttpSystemTextJsonSerializer` is also available for callers who reference it explicitly. +The default JSON serializer (`SimpleHttpDefaultJsonSerializer`) is backed by `System.Text.Json`. It serializes with camelCase names, omits null values, writes indented output, and deserializes case-insensitively. For smoother interop it also reads numbers from JSON strings (e.g. `"123"`) and tolerates trailing commas and comments while reading. The equivalent `SimpleHttpSystemTextJsonSerializer` is also available for callers who reference it explicitly. -> **Upgrading from v4?** As of **v5.0.0** the default serializer moved from `Newtonsoft.Json` to `System.Text.Json` and the `Newtonsoft.Json` dependency was removed. `System.Text.Json` is stricter, so watch for: types deserialized via a non-public parameterless constructor (add a public constructor or a `[JsonConstructor]`), and fields whose JSON shape varies (e.g. sometimes a string, sometimes an object) — these threw nothing under Newtonsoft but will under `System.Text.Json`. If you need the old behavior, implement `ISimpleHttpSerializer` with your own `Newtonsoft.Json` serializer and set it on the client. +> **Upgrading from v4?** As of **v5.0.0** the default serializer moved from `Newtonsoft.Json` to `System.Text.Json` and the `Newtonsoft.Json` dependency was removed. The defaults above cover the most common differences, but `System.Text.Json` is stricter in two ways it won't soften: +> - **Non-public parameterless constructors** aren't used — add a public constructor or a `[JsonConstructor]`. +> - **Wrong-shape values aren't coerced** — a field that's sometimes a string and sometimes an object (and similar) threw nothing under Newtonsoft but throws here. For such fields, attach a custom `JsonConverter` to the property. +> +> If you'd rather keep the old behavior wholesale, implement `ISimpleHttpSerializer` with your own `Newtonsoft.Json` serializer and set it on the client. ### Logging You can log requests and responses by setting the `LogRequest` and `LogResponse` delegates (called immediately before a request is sent and immediately after a response is received), or by providing an `ISimpleHttpLogger`: diff --git a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs index 599053e..1b37095 100644 --- a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs @@ -124,6 +124,38 @@ public void Deserialization_IntoTypeWithOnlyNonPublicConstructor_Throws() () => serializer.Deserialize("{\"value\":1}")); } + [Fact] + public void Deserialization_ReadsNumbersFromStrings() + { + // A quoted number must bind to a numeric property (common API interop case, + // and Newtonsoft's default behavior). + var serializer = new SimpleHttpSystemTextJsonSerializer(); + + var result = serializer.Deserialize("{\"property2\": \"42\"}"); + + Assert.NotNull(result); + Assert.Equal(42, result.Property2); + } + + [Fact] + public void Deserialization_ToleratesTrailingCommasAndComments() + { + var serializer = new SimpleHttpSystemTextJsonSerializer(); + + const string json = +@"{ + // leading comment + ""property1"": ""value"", + ""property2"": 7, +}"; + + var result = serializer.Deserialize(json); + + Assert.NotNull(result); + Assert.Equal("value", result.Property1); + Assert.Equal(7, result.Property2); + } + private static string Normalize(string value) => value.Replace("\r\n", "\n"); private class NullableSerializationObject diff --git a/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs b/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs index 89a41b3..9417467 100644 --- a/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs +++ b/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs @@ -7,12 +7,16 @@ namespace SimpleHttpClient.Serialization /// A JSON serializer backed by System.Text.Json. This is the default serializer /// ( derives from it); the type is kept /// for callers who reference it explicitly. It serializes with camelCase names, omits - /// null values, writes indented output, and deserializes case-insensitively. + /// null values, writes indented output, and deserializes case-insensitively. To ease + /// interop with real-world APIs it also reads numbers from JSON strings (e.g. "123") + /// and tolerates trailing commas and comments while reading. /// /// - /// System.Text.Json is stricter than Newtonsoft.Json. Notably, it cannot use a - /// non-public parameterless constructor when deserializing; such types need a public - /// constructor or a . + /// System.Text.Json is stricter than Newtonsoft.Json in ways these options don't soften. + /// Notably, it cannot use a non-public parameterless constructor when deserializing (such + /// types need a public constructor or a ), and it + /// won't coerce a JSON value of the wrong shape (e.g. a string where an object is expected). + /// For fields whose shape varies, attach a custom to the property. /// public class SimpleHttpSystemTextJsonSerializer : ISimpleHttpSerializer { @@ -25,6 +29,11 @@ public class SimpleHttpSystemTextJsonSerializer : ISimpleHttpSerializer DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNameCaseInsensitive = true, WriteIndented = true, + // Read leniencies that bring the defaults closer to Newtonsoft's, easing the + // v5 migration without masking genuine type mismatches. + NumberHandling = JsonNumberHandling.AllowReadingFromString, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, }; /// From a23cf1911a648e6eb951d3e3f08e4c503c0e45a0 Mon Sep 17 00:00:00 2001 From: Mako88 Date: Sat, 30 May 2026 04:14:20 -0500 Subject: [PATCH 4/4] Add HttpClientProvider tests Cover the previously-untested provider stack: the factory returns the right provider per target framework, GetClient returns a configured client cached across calls (no factory) or delegates to the IHttpClientFactory (with one), and Dispose disposes the owned client and is idempotent. Add InternalsVisibleTo for the test assembly so the internal providers/factory can be exercised directly. The net10.0 run exercises PooledHttpClientProvider; net48 exercises RotatingHttpClientProvider. Timer-driven rotation (5-min interval) is left untested as it has no injectable seam. Co-Authored-By: Claude Opus 4.8 --- .../HttpClientProviderTests.cs | 67 +++++++++++++++++++ src/SimpleHttpClient/SimpleHttpClient.csproj | 4 ++ 2 files changed, 71 insertions(+) create mode 100644 src/SimpleHttpClient.Tests.Integration/HttpClientProviderTests.cs diff --git a/src/SimpleHttpClient.Tests.Integration/HttpClientProviderTests.cs b/src/SimpleHttpClient.Tests.Integration/HttpClientProviderTests.cs new file mode 100644 index 0000000..9126e79 --- /dev/null +++ b/src/SimpleHttpClient.Tests.Integration/HttpClientProviderTests.cs @@ -0,0 +1,67 @@ +using Moq; +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace SimpleHttpClient.Tests +{ + public class HttpClientProviderTests + { + [Fact] + public void Factory_Create_ReturnsProviderForTargetFramework() + { + using var provider = HttpClientProviderFactory.Create(null); + +#if NETFRAMEWORK + // The netstandard2.0 asset (used by .NET Framework) rotates a cached client. + Assert.IsType(provider); +#else + // The net8.0 asset relies on SocketsHttpHandler's pooled connection lifetime. + Assert.IsType(provider); +#endif + } + + [Fact] + public void GetClient_WithoutFactory_ReturnsConfiguredClient_CachedAcrossCalls() + { + using var provider = HttpClientProviderFactory.Create(null); + + var first = provider.GetClient(); + var second = provider.GetClient(); + + Assert.NotNull(first); + // With no factory the provider owns and caches a single client. + Assert.Same(first, second); + // Configured with an infinite timeout - SimpleClient applies its own per-request timeout. + Assert.Equal(System.Threading.Timeout.InfiniteTimeSpan, first.Timeout); + } + + [Fact] + public void GetClient_WithFactory_DelegatesToFactory() + { + using var factoryClient = new HttpClient(); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(Constants.HttpClientNameString)).Returns(factoryClient); + + using var provider = HttpClientProviderFactory.Create(factory.Object); + + var client = provider.GetClient(); + + Assert.Same(factoryClient, client); + factory.Verify(f => f.CreateClient(Constants.HttpClientNameString), Times.AtLeastOnce); + } + + [Fact] + public async Task Dispose_DisposesOwnedClient_AndIsIdempotent() + { + var provider = HttpClientProviderFactory.Create(null); + var client = provider.GetClient(); + + provider.Dispose(); + provider.Dispose(); // idempotent: a second dispose must not throw + + // The client the provider owns is disposed along with it. + await Assert.ThrowsAsync(() => client.GetAsync("http://localhost")); + } + } +} diff --git a/src/SimpleHttpClient/SimpleHttpClient.csproj b/src/SimpleHttpClient/SimpleHttpClient.csproj index 0e05daa..16aa162 100644 --- a/src/SimpleHttpClient/SimpleHttpClient.csproj +++ b/src/SimpleHttpClient/SimpleHttpClient.csproj @@ -15,6 +15,10 @@ True + + + + True