From 5bebb1d6d49c6655b6e39c5c70e3e55f2cf1d60d Mon Sep 17 00:00:00 2001 From: Mako88 Date: Sat, 30 May 2026 22:45:58 -0500 Subject: [PATCH 1/3] Add line and Server-Sent Events readers to ISimpleStreamResponse Add two IAsyncEnumerable members on ISimpleStreamResponse (implemented in SimpleStreamResponse), so callers can mock them: - ReadLinesAsync: reads the body as text lines (CR/LF/CRLF), encoding from the Content-Type charset (UTF-8 default), cancellation observed mid-read. - ReadServerSentEventsAsync: parses the text/event-stream wire format per the WHATWG SSE spec (blank-line dispatch, multi-data join, comment/keep-alive skipping, event/id/retry fields, id carries forward), returning ServerSentEvent frames. Both are generic to the SSE standard - application conventions (OpenAI's [DONE] sentinel, deserializing the data payload) are deliberately left to the caller, keeping SimpleHttpClient general-purpose. Made these interface methods (not extension methods) so they can be mocked; a test asserts mockability. IAsyncEnumerable works on netstandard2.0 via the Microsoft.Bcl.AsyncInterfaces dependency System.Text.Json already brings in; added latest so the netstandard2.0 target (C# 7.3 by default) can compile async streams. Adds ServerSentEvent model, 14 tests (parser units + WireMock end-to-end + mockability), and README docs. net10.0 and net48: 110 tests each. Co-Authored-By: Claude Opus 4.8 --- README.md | 47 +++- .../ServerSentEventsTests.cs | 251 ++++++++++++++++++ .../Models/ISimpleStreamResponse.cs | 25 ++ .../Models/ServerSentEvent.cs | 50 ++++ .../Models/SimpleStreamResponse.cs | 175 ++++++++++++ .../SimpleHttpDefaultJsonSerializer.cs | 3 +- .../SimpleHttpSystemTextJsonSerializer.cs | 13 +- src/SimpleHttpClient/SimpleHttpClient.csproj | 1 + 8 files changed, 552 insertions(+), 13 deletions(-) create mode 100644 src/SimpleHttpClient.Tests.Integration/ServerSentEventsTests.cs create mode 100644 src/SimpleHttpClient/Models/ServerSentEvent.cs diff --git a/README.md b/README.md index f32b54d..d434c05 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,46 @@ while ((line = await reader.ReadLineAsync()) != null) A few things to keep in mind: - **Dispose the response.** The underlying `HttpResponseMessage` and connection are held open until you dispose the returned `ISimpleStreamResponse`. A `using` block is the simplest way to guarantee this. - **`MakeStreamRequest` accepts a `CancellationToken`.** Pass one to cancel sending the request, waiting for the headers, and reading the stream (e.g. when a user aborts mid-stream). The token is observed by reads too, even through a `StreamReader` that gives you no place to pass it — so the `await reader.ReadLineAsync()` loop above stops promptly when the token fires. Async reads honor it even mid-read; synchronous reads observe it between reads, so to abort a synchronous read already blocked on the socket, dispose the response. A caller-requested cancellation surfaces as an `OperationCanceledException`; a timeout still surfaces as a `TimeoutException`. -- **The body is yours to frame.** `SimpleStreamResponse.Body` is a plain `Stream`, leaving any protocol-specific framing (such as SSE `event:`/`data:` parsing) to you. +- **The body is yours to frame.** `SimpleStreamResponse.Body` is a plain `Stream`. For common cases there are helpers (below) that read it as lines or Server-Sent Events; otherwise you can frame it however you like. + +### Reading lines and Server-Sent Events +`ISimpleStreamResponse` has two methods that cover the most common streaming formats. Both are `IAsyncEnumerable`, so you consume them with `await foreach`, and both honor a `CancellationToken`: + +```csharp +// Line-delimited streams (NDJSON, plain text, etc.) +await foreach (var line in response.ReadLinesAsync(cancellationToken)) +{ + Console.WriteLine(line); +} +``` + +```csharp +// Server-Sent Events (text/event-stream) +await foreach (var sse in response.ReadServerSentEventsAsync(cancellationToken)) +{ + // sse.Data, sse.EventType, sse.Id, sse.Retry + Console.WriteLine(sse.Data); +} +``` + +`ReadServerSentEventsAsync` parses the SSE wire format per the [WHATWG specification](https://html.spec.whatwg.org/multipage/server-sent-events.html): events are separated by blank lines, multiple `data:` lines are joined with newlines, and comment/keep-alive lines (starting with `:`) are skipped. It deliberately does **not** handle application-specific conventions — most notably it does not treat any sentinel value specially and does not deserialize the payload — so those stay in your hands: + +```csharp +using var response = await client.MakeStreamRequest(request, cancellationToken); + +await foreach (var sse in response.ReadServerSentEventsAsync(cancellationToken)) +{ + if (sse.Data == "end") // a sentinel some APIs send to mark the end - your convention, not the library's + { + break; + } + + var chunk = client.Serializer.Deserialize(sse.Data); + // ...handle chunk +} +``` + +This keeps SimpleHttpClient general-purpose: the SSE framing is a web standard (the same format `EventSource` consumes in browsers), while which sentinel terminates the stream and how each `data` payload is shaped are specific to the API you're calling. ## Configuration @@ -236,11 +275,11 @@ 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. 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. The defaults above cover the most common differences, but `System.Text.Json` is stricter in two ways it won't soften: +> **Note:** the defaults above cover the most common cases, but deserialization is strict in two ways they don'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. +> - **Wrong-shape values aren't coerced** — a field that's sometimes a string and sometimes an object (and similar) will throw. 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. +> If you need different behavior wholesale, implement `ISimpleHttpSerializer` with your own 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/ServerSentEventsTests.cs b/src/SimpleHttpClient.Tests.Integration/ServerSentEventsTests.cs new file mode 100644 index 0000000..7c7fa66 --- /dev/null +++ b/src/SimpleHttpClient.Tests.Integration/ServerSentEventsTests.cs @@ -0,0 +1,251 @@ +using Moq; +using SimpleHttpClient.Models; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace SimpleHttpClient.Tests +{ + public class ServerSentEventsTests + { + // ---- ReadLinesAsync ---- + + [Fact] + public async Task ReadLines_SplitsOnNewlines() + { + using var response = StreamFrom("a\nb\nc\n"); + + var lines = await Collect(response.ReadLinesAsync()); + + Assert.Equal(new[] { "a", "b", "c" }, lines); + } + + [Fact] + public async Task ReadLines_HandlesCrlf() + { + using var response = StreamFrom("a\r\nb\r\n"); + + var lines = await Collect(response.ReadLinesAsync()); + + Assert.Equal(new[] { "a", "b" }, lines); + } + + // ---- ReadServerSentEventsAsync ---- + + [Fact] + public async Task Sse_SingleEvent_ParsesData() + { + using var response = StreamFrom("data: hello\n\n"); + + var events = await Collect(response.ReadServerSentEventsAsync()); + + var sse = Assert.Single(events); + Assert.Equal("hello", sse.Data); + Assert.Null(sse.EventType); + Assert.Null(sse.Id); + Assert.Null(sse.Retry); + } + + [Fact] + public async Task Sse_MultipleDataLines_JoinedWithNewline() + { + using var response = StreamFrom("data: a\ndata: b\ndata: c\n\n"); + + var events = await Collect(response.ReadServerSentEventsAsync()); + + Assert.Equal("a\nb\nc", Assert.Single(events).Data); + } + + [Fact] + public async Task Sse_NoSpaceAfterColon_IsAccepted() + { + using var response = StreamFrom("data:hello\n\n"); + + Assert.Equal("hello", Assert.Single(await Collect(response.ReadServerSentEventsAsync())).Data); + } + + [Fact] + public async Task Sse_CommentLines_AreIgnored() + { + using var response = StreamFrom(": this is a keep-alive\ndata: real\n\n"); + + Assert.Equal("real", Assert.Single(await Collect(response.ReadServerSentEventsAsync())).Data); + } + + [Fact] + public async Task Sse_ParsesEventTypeIdAndRetry() + { + using var response = StreamFrom("event: update\nid: 42\nretry: 1500\ndata: payload\n\n"); + + var sse = Assert.Single(await Collect(response.ReadServerSentEventsAsync())); + Assert.Equal("update", sse.EventType); + Assert.Equal("42", sse.Id); + Assert.Equal(1500, sse.Retry); + Assert.Equal("payload", sse.Data); + } + + [Fact] + public async Task Sse_Id_CarriesForwardToLaterEvents() + { + using var response = StreamFrom("id: 1\ndata: a\n\ndata: b\n\n"); + + var events = await Collect(response.ReadServerSentEventsAsync()); + + Assert.Equal(2, events.Count); + Assert.Equal("1", events[0].Id); + Assert.Equal("1", events[1].Id); // id carries forward per the SSE spec + Assert.Equal("b", events[1].Data); + } + + [Fact] + public async Task Sse_EventTypeAndRetry_ResetBetweenEvents() + { + using var response = StreamFrom("event: first\nretry: 100\ndata: a\n\ndata: b\n\n"); + + var events = await Collect(response.ReadServerSentEventsAsync()); + + Assert.Equal("first", events[0].EventType); + Assert.Equal(100, events[0].Retry); + Assert.Null(events[1].EventType); // event type does not carry forward + Assert.Null(events[1].Retry); + } + + [Fact] + public async Task Sse_DoneSentinel_PassesThroughAsData() + { + // The library must NOT special-case [DONE]; the caller handles it. + using var response = StreamFrom("data: {\"x\":1}\n\ndata: [DONE]\n\n"); + + var events = await Collect(response.ReadServerSentEventsAsync()); + + Assert.Equal(2, events.Count); + Assert.Equal("{\"x\":1}", events[0].Data); + Assert.Equal("[DONE]", events[1].Data); + } + + [Fact] + public async Task Sse_EventWithoutData_IsNotDispatched() + { + using var response = StreamFrom("event: ping\n\ndata: real\n\n"); + + var events = await Collect(response.ReadServerSentEventsAsync()); + + // Per spec, an event with no data is not dispatched. + var sse = Assert.Single(events); + Assert.Equal("real", sse.Data); + } + + [Fact] + public async Task Sse_IncompleteTrailingEvent_IsNotDispatched() + { + using var response = StreamFrom("data: complete\n\ndata: incomplete"); + + var events = await Collect(response.ReadServerSentEventsAsync()); + + Assert.Equal("complete", Assert.Single(events).Data); + } + + [Fact] + public async Task Sse_PreCancelledToken_Throws() + { + using var response = StreamFrom("data: a\n\n"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await Collect(response.ReadServerSentEventsAsync(cts.Token))); + } + + [Fact] + public async Task Sse_EndToEnd_OverMakeStreamRequest() + { + var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath("/sse").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "text/event-stream") + .WithBody("data: one\n\ndata: two\n\ndata: [DONE]\n\n")); + + var client = new SimpleClient(server.Url); + + var datas = new List(); + using (var response = await client.MakeStreamRequest(new SimpleRequest("/sse"))) + { + await foreach (var sse in response.ReadServerSentEventsAsync()) + { + if (sse.Data == "[DONE]") + { + break; + } + + datas.Add(sse.Data); + } + } + + Assert.Equal(new[] { "one", "two" }, datas); + + server.Stop(); + } + + [Fact] + public async Task ReadServerSentEventsAsync_IsMockable() + { + // The readers are interface members (not extension methods) specifically so callers + // can mock them. This test fails to compile if they ever become extension methods. + var events = new[] { new ServerSentEvent("mocked") }; + var mock = new Mock(); + mock.Setup(x => x.ReadServerSentEventsAsync(It.IsAny())) + .Returns(ToAsync(events)); + + var result = await Collect(mock.Object.ReadServerSentEventsAsync()); + + Assert.Equal("mocked", Assert.Single(result).Data); + } + + // ---- helpers ---- + + private static async IAsyncEnumerable ToAsync(IEnumerable items) + { + foreach (var item in items) + { + yield return item; + } + + await Task.CompletedTask; + } + + private static ISimpleStreamResponse StreamFrom(string body, string contentType = "text/event-stream") + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); + var response = new SimpleStreamResponse(new HttpResponseMessage(HttpStatusCode.OK), stream) + { + StatusCode = HttpStatusCode.OK, + IsSuccessful = true, + }; + + if (contentType != null) + { + response.Headers["Content-Type"] = contentType; + } + + return response; + } + + private static async Task> Collect(IAsyncEnumerable sequence) + { + var list = new List(); + await foreach (var item in sequence) + { + list.Add(item); + } + + return list; + } + } +} diff --git a/src/SimpleHttpClient/Models/ISimpleStreamResponse.cs b/src/SimpleHttpClient/Models/ISimpleStreamResponse.cs index 0a65730..fb681a8 100644 --- a/src/SimpleHttpClient/Models/ISimpleStreamResponse.cs +++ b/src/SimpleHttpClient/Models/ISimpleStreamResponse.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Net; +using System.Threading; namespace SimpleHttpClient.Models { @@ -32,5 +33,29 @@ public interface ISimpleStreamResponse : IDisposable /// Read from this directly to consume the response as it arrives. /// Stream Body { get; } + + /// + /// Read the response body as a sequence of text lines as they arrive. Lines are split on + /// CR, LF, or CRLF. The encoding is taken from the response's Content-Type charset, falling + /// back to UTF-8. + /// + /// A token to cancel reading (observed mid-read, not just between lines). + /// An async sequence of lines (without their line terminators). + IAsyncEnumerable ReadLinesAsync(CancellationToken cancellationToken = default); + + /// + /// Read the response body as a sequence of Server-Sent Events (text/event-stream), + /// parsing the SSE wire format per the WHATWG/W3C specification: events are separated by + /// blank lines, multiple data: lines are joined with newlines, and lines beginning + /// with a colon (comments / keep-alives) are ignored. + /// + /// Application-specific conventions are intentionally NOT handled here - for example a + /// sentinel data value that marks the end of the stream, or deserializing + /// . Inspect each event and handle those in your own loop. + /// + /// + /// A token to cancel reading. + /// An async sequence of . + IAsyncEnumerable ReadServerSentEventsAsync(CancellationToken cancellationToken = default); } } diff --git a/src/SimpleHttpClient/Models/ServerSentEvent.cs b/src/SimpleHttpClient/Models/ServerSentEvent.cs new file mode 100644 index 0000000..40651a7 --- /dev/null +++ b/src/SimpleHttpClient/Models/ServerSentEvent.cs @@ -0,0 +1,50 @@ +namespace SimpleHttpClient.Models +{ + /// + /// A single event parsed from a text/event-stream (Server-Sent Events) response, + /// per the WHATWG/W3C SSE specification. This represents the transport-level frame only; + /// application-specific conventions (such as a sentinel data value that marks the end + /// of the stream, or deserializing into a type) are left to the caller. + /// + public sealed class ServerSentEvent + { + /// + /// Creates a Server-Sent Event. + /// + /// The event data (multiple data: lines joined with newlines, trailing newline removed). + /// The event type from the event: field, or null if none was specified. + /// The last event id (from an id: field); per the SSE spec this value carries forward to later events until changed. + /// The reconnection time in milliseconds from a retry: field, or null if none was specified. + public ServerSentEvent(string data, string eventType = null, string id = null, int? retry = null) + { + Data = data; + EventType = eventType; + Id = id; + Retry = retry; + } + + /// + /// The event data. When an event contains multiple data: lines they are joined + /// with newline characters, and the single trailing newline is removed. + /// + public string Data { get; } + + /// + /// The event type from the event: field, or null if the event didn't specify one. + /// + public string EventType { get; } + + /// + /// The last event id. Per the SSE specification the id "carries forward": once set by an + /// id: field it applies to subsequent events too, until a later id: changes it. + /// Null if no id has been seen yet. + /// + public string Id { get; } + + /// + /// The reconnection time in milliseconds from a retry: field, or null if the event + /// didn't specify one. + /// + public int? Retry { get; } + } +} diff --git a/src/SimpleHttpClient/Models/SimpleStreamResponse.cs b/src/SimpleHttpClient/Models/SimpleStreamResponse.cs index 1fe016d..4628680 100644 --- a/src/SimpleHttpClient/Models/SimpleStreamResponse.cs +++ b/src/SimpleHttpClient/Models/SimpleStreamResponse.cs @@ -3,6 +3,10 @@ using System.IO; using System.Net; using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; namespace SimpleHttpClient.Models { @@ -46,6 +50,177 @@ public SimpleStreamResponse(HttpResponseMessage httpResponse, Stream body) /// public Stream Body { get; private set; } + /// + /// Read the response body as a sequence of text lines as they arrive. Lines are split on + /// CR, LF, or CRLF. The encoding is taken from the response's Content-Type charset, falling + /// back to UTF-8. + /// + /// A token to cancel reading (observed mid-read, not just between lines). + /// An async sequence of lines (without their line terminators). + public async IAsyncEnumerable ReadLinesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Wrap the body so the token passed here also cancels an in-progress read (the body may + // already carry the request's token; linking is harmless and honors both). + var stream = new CancellationAwareStream(Body, cancellationToken); + + // leaveOpen: this response owns Body and disposes it. + using (var reader = new StreamReader(stream, GetEncoding(), detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true)) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + +#if NET8_0_OR_GREATER + var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); +#else + var line = await reader.ReadLineAsync().ConfigureAwait(false); +#endif + if (line == null) + { + yield break; + } + + yield return line; + } + } + } + + /// + /// Read the response body as a sequence of Server-Sent Events (text/event-stream), + /// parsing the SSE wire format per the WHATWG/W3C specification: events are separated by + /// blank lines, multiple data: lines are joined with newlines, and lines beginning + /// with a colon (comments / keep-alives) are ignored. + /// + /// Application-specific conventions are intentionally NOT handled here - for example a + /// sentinel data value that marks the end of the stream, or deserializing + /// . Inspect each event and handle those in your own loop. + /// + /// + /// A token to cancel reading. + /// An async sequence of . + public async IAsyncEnumerable ReadServerSentEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var data = new StringBuilder(); + string eventType = null; + string lastId = null; // the SSE "last event id" carries forward across events + int? retry = null; + var hasData = false; + + await foreach (var line in ReadLinesAsync(cancellationToken).ConfigureAwait(false)) + { + // Blank line: dispatch the event accumulated so far. + if (line.Length == 0) + { + if (hasData) + { + // Remove the single trailing newline added after the last data line. + yield return new ServerSentEvent(data.ToString(0, data.Length - 1), eventType, lastId, retry); + } + + data.Clear(); + eventType = null; + retry = null; + hasData = false; + continue; + } + + // Lines starting with a colon are comments (often keep-alive heartbeats); ignore. + if (line[0] == ':') + { + continue; + } + + string field; + string value; + var colon = line.IndexOf(':'); + if (colon < 0) + { + // A line with no colon is a field name with an empty value. + field = line; + value = string.Empty; + } + else + { + field = line.Substring(0, colon); + value = line.Substring(colon + 1); + // A single leading space after the colon is part of the format, not the value. + if (value.Length > 0 && value[0] == ' ') + { + value = value.Substring(1); + } + } + + switch (field) + { + case "event": + eventType = value; + break; + case "data": + data.Append(value).Append('\n'); + hasData = true; + break; + case "id": + // Per spec, ignore an id containing a NUL character. + if (value.IndexOf('\0') < 0) + { + lastId = value; + } + break; + case "retry": + if (int.TryParse(value, out var parsedRetry)) + { + retry = parsedRetry; + } + break; + // Unknown fields are ignored per spec. + } + } + + // Per the SSE spec, an incomplete event at end-of-stream (no terminating blank line) + // is not dispatched. + } + + /// + /// Determine the text encoding for the response from its Content-Type charset, defaulting + /// to UTF-8 (which is also what the SSE spec mandates). + /// + private Encoding GetEncoding() + { + if (Headers != null && + Headers.TryGetValue("Content-Type", out var contentType) && + !string.IsNullOrEmpty(contentType)) + { + var index = contentType.IndexOf("charset=", StringComparison.OrdinalIgnoreCase); + if (index >= 0) + { + var charset = contentType.Substring(index + "charset=".Length); + + // Trim any following parameters and surrounding quotes/whitespace. + var semicolon = charset.IndexOf(';'); + if (semicolon >= 0) + { + charset = charset.Substring(0, semicolon); + } + + charset = charset.Trim().Trim('"', '\''); + + if (charset.Length > 0) + { + try + { + return Encoding.GetEncoding(charset); + } + catch (ArgumentException) + { + // Unknown charset - fall through to the UTF-8 default. + } + } + } + } + + return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + } + /// /// Dispose. /// diff --git a/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs b/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs index 28f73b4..84de355 100644 --- a/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs +++ b/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs @@ -1,8 +1,7 @@ namespace SimpleHttpClient.Serialization { /// - /// 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 + /// The default JSON serializer, equivalent to /// . /// public class SimpleHttpDefaultJsonSerializer : SimpleHttpSystemTextJsonSerializer diff --git a/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs b/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs index 9417467..3334f8b 100644 --- a/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs +++ b/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs @@ -12,11 +12,11 @@ namespace SimpleHttpClient.Serialization /// and tolerates trailing commas and comments while reading. /// /// - /// 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. + /// Deserialization is strict in two ways these options don't soften. It cannot use a + /// non-public parameterless constructor (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 { @@ -29,8 +29,7 @@ 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. + // Read leniencies for real-world API payloads, without masking genuine type mismatches. NumberHandling = JsonNumberHandling.AllowReadingFromString, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, diff --git a/src/SimpleHttpClient/SimpleHttpClient.csproj b/src/SimpleHttpClient/SimpleHttpClient.csproj index 16aa162..2108e30 100644 --- a/src/SimpleHttpClient/SimpleHttpClient.csproj +++ b/src/SimpleHttpClient/SimpleHttpClient.csproj @@ -2,6 +2,7 @@ netstandard2.0;net8.0 + latest True Simple HTTP Client A_Future_Pilot From a4260d1950d62e41de769f828da9fb9d9e2b69fe Mon Sep 17 00:00:00 2001 From: Mako88 Date: Sat, 30 May 2026 23:25:04 -0500 Subject: [PATCH 2/3] Refresh package/README description to mention streaming The intro sentence and NuGet predated the streaming support, so they described only the buffered request/response model. Mention the line/SSE streaming helpers, add the SSE subsection to the README table of contents, and fix grammar in the package description. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 ++- src/SimpleHttpClient/SimpleHttpClient.csproj | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d434c05..88b4f8b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SimpleHttpClient [![CI](https://github.com/Mako88/SimpleHttpClient/actions/workflows/ci.yml/badge.svg)](https://github.com/Mako88/SimpleHttpClient/actions/workflows/ci.yml) -An easy-to-use .NET wrapper for `HttpClient`. No extension methods, included interfaces allow for easy unit test mocking, and straightforward properties allow for easier debugging (the response body is available as a string, byte array, and/or a typed object). +An easy-to-use .NET wrapper for `HttpClient`. No extension methods, included interfaces allow for easy unit test mocking, and straightforward properties allow for easier debugging (the response body is available as a string, byte array, and/or a typed object). It also supports streaming responses, with built-in helpers for reading line-delimited streams and Server-Sent Events. ## Contents - [Installation](#installation) @@ -15,6 +15,7 @@ An easy-to-use .NET wrapper for `HttpClient`. No extension methods, included int - [Headers](#headers) - [Request Bodies](#request-bodies) - [Streaming Responses](#streaming-responses) + - [Reading lines and Server-Sent Events](#reading-lines-and-server-sent-events) - [Configuration](#configuration) - [Timeouts](#timeouts) - [Additional Successful Status Codes](#additional-successful-status-codes) diff --git a/src/SimpleHttpClient/SimpleHttpClient.csproj b/src/SimpleHttpClient/SimpleHttpClient.csproj index 2108e30..91af137 100644 --- a/src/SimpleHttpClient/SimpleHttpClient.csproj +++ b/src/SimpleHttpClient/SimpleHttpClient.csproj @@ -6,7 +6,7 @@ True Simple HTTP Client 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. + An easy-to-use .NET wrapper for HttpClient. No extension methods, included interfaces allow for easy unit test mocking, and straightforward properties allow for easier debugging. It also supports streaming responses, with built-in helpers for reading line-delimited streams and Server-Sent Events. http;rest;httpclient;client 5.0.0 https://github.com/Mako88/SimpleHttpClient From 5970bbdaaf7418c07cb732978975f404b9664d06 Mon Sep 17 00:00:00 2001 From: Mako88 Date: Sat, 30 May 2026 23:29:39 -0500 Subject: [PATCH 3/3] Reword feature list to read as parallel phrases Make the three benefits parallel noun phrases joined by a single 'and' in both the README intro and the NuGet description, fixing the awkward 'No extension methods, included interfaces allow for...' reading. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- src/SimpleHttpClient/SimpleHttpClient.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 88b4f8b..b699bd7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SimpleHttpClient [![CI](https://github.com/Mako88/SimpleHttpClient/actions/workflows/ci.yml/badge.svg)](https://github.com/Mako88/SimpleHttpClient/actions/workflows/ci.yml) -An easy-to-use .NET wrapper for `HttpClient`. No extension methods, included interfaces allow for easy unit test mocking, and straightforward properties allow for easier debugging (the response body is available as a string, byte array, and/or a typed object). It also supports streaming responses, with built-in helpers for reading line-delimited streams and Server-Sent Events. +An easy-to-use .NET wrapper for `HttpClient`. No extension methods, included interfaces for easy unit test mocking, and straightforward properties for easier debugging (the response body is available as a string, byte array, and/or a typed object). It also supports streaming responses, with built-in helpers for reading line-delimited streams and Server-Sent Events. ## Contents - [Installation](#installation) diff --git a/src/SimpleHttpClient/SimpleHttpClient.csproj b/src/SimpleHttpClient/SimpleHttpClient.csproj index 91af137..aff686a 100644 --- a/src/SimpleHttpClient/SimpleHttpClient.csproj +++ b/src/SimpleHttpClient/SimpleHttpClient.csproj @@ -6,7 +6,7 @@ True Simple HTTP Client A_Future_Pilot - An easy-to-use .NET wrapper for HttpClient. No extension methods, included interfaces allow for easy unit test mocking, and straightforward properties allow for easier debugging. It also supports streaming responses, with built-in helpers for reading line-delimited streams and Server-Sent Events. + An easy-to-use .NET wrapper for HttpClient. No extension methods, included interfaces for easy unit test mocking, and straightforward properties for easier debugging. It also supports streaming responses, with built-in helpers for reading line-delimited streams and Server-Sent Events. http;rest;httpclient;client 5.0.0 https://github.com/Mako88/SimpleHttpClient