Skip to content

Commit d9b3d5f

Browse files
authored
feat: support Server-Sent Events as a streaming content format (#2232)
- Add StreamingContentFormat.ServerSentEvents and map text/event-stream to it in DetectStreamingFormat - Stream each SSE data event into IAsyncEnumerable<T> via SseParser, deserializing payloads through the existing source-generated/reflection JSON metadata path so it stays trim and Native AOT clean - Reference System.Net.ServerSentEvents on targets that lack it in-box (net4x/netstandard2.0/net8.0/net9.0); net10.0+ use the framework reference - Inherit both generated and reflection execution paths through the shared streaming seam (no generator change) - Cover generated/reflection paths and source-gen/reflection serializers; document in README and breaking-changes
1 parent 759a3c0 commit d9b3d5f

19 files changed

Lines changed: 213 additions & 8 deletions

README.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,14 +1035,35 @@ await foreach (var company in api.GetDocuments())
10351035
```
10361036

10371037
The frame format is auto-detected from the response content type: a content type of `application/jsonl`,
1038-
`application/x-ndjson`, or `application/x-jsonlines` is read as JSON Lines (one value per line); anything else is read
1039-
as a single streamed JSON array. To observe cancellation, add a `CancellationToken` parameter, or use
1040-
`WithCancellation(token)` on the enumerable.
1038+
`application/x-ndjson`, or `application/x-jsonlines` is read as JSON Lines (one value per line); `text/event-stream` is
1039+
read as Server-Sent Events (see below); anything else is read as a single streamed JSON array. To observe cancellation,
1040+
add a `CancellationToken` parameter, or use `WithCancellation(token)` on the enumerable.
10411041

10421042
Streaming requires the configured `ContentSerializer` to implement `IStreamingContentSerializer`. The default
10431043
`SystemTextJsonContentSerializer` does; a serializer that does not will throw `NotSupportedException` when the sequence
10441044
is enumerated.
10451045

1046+
##### Consuming Server-Sent Events
1047+
1048+
When the endpoint responds with `text/event-stream`, Refit parses each Server-Sent Events (SSE) `data` event and
1049+
deserializes its payload to `T`, yielding one item per event as it arrives (the event `type`/`id` envelope is dropped):
1050+
1051+
```csharp
1052+
public interface IChatApi
1053+
{
1054+
[Get("/chat/stream")]
1055+
IAsyncEnumerable<ChatToken> StreamAsync();
1056+
}
1057+
1058+
await foreach (var token in api.StreamAsync())
1059+
{
1060+
// one ChatToken per `data:` event, streamed live
1061+
}
1062+
```
1063+
1064+
If you need the raw event envelope (event type or id), return `Task<Stream>` and feed the unbuffered stream to
1065+
`System.Net.ServerSentEvents.SseParser` directly.
1066+
10461067
#### XML Content
10471068

10481069
XML requests and responses are serialized/deserialized using _System.Xml.Serialization.XmlSerializer_.

docs/breaking-changes.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@ visible in three places.
199199
generator discovers adapters declared in your project at compile time and emits a direct `Adapt` call — no reflection,
200200
so adapter-backed methods stay trim and Native AOT clean; the reflection request builder resolves adapters registered
201201
in `RefitSettings.ReturnTypeAdapters`. See [Custom return types](../README.md#custom-return-types-ireturntypeadapter).
202+
* **Server-Sent Events as a streaming format.** A `text/event-stream` response returned to an `IAsyncEnumerable<T>`
203+
method now streams one deserialized `T` per SSE `data` event, live and unbuffered, through the same seam as JSON
204+
arrays and JSON Lines — no generator or reflection change, so both generated and reflection clients inherit it. This
205+
adds the `StreamingContentFormat.ServerSentEvents` enum member and takes a new `System.Net.ServerSentEvents` package
206+
reference on the targets that lack it in-box (net4x/netstandard2.0/net8.0/net9.0; it is a framework reference on
207+
net10.0+). Each event's payload is deserialized through the configured `System.Text.Json` metadata path, so it stays
208+
trim and Native AOT clean. See [Consuming Server-Sent Events](../README.md#consuming-server-sent-events).
202209
* **Obtain the built request without sending it.** A method declared to return `Task<HttpRequestMessage>` builds the
203210
full request (method, URL, headers, and body/multipart content) and hands it back to the caller instead of dispatching
204211
it — the equivalent of Retrofit's `Call.request()`, useful for inspection, signing, logging, or manual dispatch. Both

src/Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
<ItemGroup Label="Microsoft Runtime And Extensions">
6565
<PackageVersion Include="Microsoft.Extensions.Http" Version="$(MicrosoftExtensionsVersion)"/>
6666
<PackageVersion Include="System.Net.Http.Json" Version="$(SystemTextJsonVersion)"/>
67+
<PackageVersion Include="System.Net.ServerSentEvents" Version="$(SystemTextJsonVersion)"/>
6768
<PackageVersion Include="System.Text.Json" Version="$(SystemTextJsonVersion)"/>
6869
</ItemGroup>
6970

src/Refit/PublicAPI/net10.0/PublicAPI.Shipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ Refit.StreamPart.Value.get -> System.IO.Stream!
365365
Refit.StreamingContentFormat
366366
Refit.StreamingContentFormat.JsonArray = 0 -> Refit.StreamingContentFormat
367367
Refit.StreamingContentFormat.JsonLines = 1 -> Refit.StreamingContentFormat
368+
Refit.StreamingContentFormat.ServerSentEvents = 2 -> Refit.StreamingContentFormat
368369
Refit.SystemTextJsonContentSerializer
369370
Refit.SystemTextJsonContentSerializer.DeserializeFromString<T>(string! content) -> T?
370371
Refit.SystemTextJsonContentSerializer.DeserializeStreamAsync<T>(System.IO.Stream! stream, Refit.StreamingContentFormat format, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable<T?>!

src/Refit/PublicAPI/net11.0/PublicAPI.Shipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ Refit.StreamPart.Value.get -> System.IO.Stream!
365365
Refit.StreamingContentFormat
366366
Refit.StreamingContentFormat.JsonArray = 0 -> Refit.StreamingContentFormat
367367
Refit.StreamingContentFormat.JsonLines = 1 -> Refit.StreamingContentFormat
368+
Refit.StreamingContentFormat.ServerSentEvents = 2 -> Refit.StreamingContentFormat
368369
Refit.SystemTextJsonContentSerializer
369370
Refit.SystemTextJsonContentSerializer.DeserializeFromString<T>(string! content) -> T?
370371
Refit.SystemTextJsonContentSerializer.DeserializeStreamAsync<T>(System.IO.Stream! stream, Refit.StreamingContentFormat format, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable<T?>!

src/Refit/PublicAPI/net462/PublicAPI.Shipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ Refit.StreamPart.Value.get -> System.IO.Stream!
361361
Refit.StreamingContentFormat
362362
Refit.StreamingContentFormat.JsonArray = 0 -> Refit.StreamingContentFormat
363363
Refit.StreamingContentFormat.JsonLines = 1 -> Refit.StreamingContentFormat
364+
Refit.StreamingContentFormat.ServerSentEvents = 2 -> Refit.StreamingContentFormat
364365
Refit.SystemTextJsonContentSerializer
365366
Refit.SystemTextJsonContentSerializer.DeserializeFromString<T>(string! content) -> T?
366367
Refit.SystemTextJsonContentSerializer.DeserializeStreamAsync<T>(System.IO.Stream! stream, Refit.StreamingContentFormat format, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable<T?>!

src/Refit/PublicAPI/net470/PublicAPI.Shipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ Refit.StreamPart.Value.get -> System.IO.Stream!
361361
Refit.StreamingContentFormat
362362
Refit.StreamingContentFormat.JsonArray = 0 -> Refit.StreamingContentFormat
363363
Refit.StreamingContentFormat.JsonLines = 1 -> Refit.StreamingContentFormat
364+
Refit.StreamingContentFormat.ServerSentEvents = 2 -> Refit.StreamingContentFormat
364365
Refit.SystemTextJsonContentSerializer
365366
Refit.SystemTextJsonContentSerializer.DeserializeFromString<T>(string! content) -> T?
366367
Refit.SystemTextJsonContentSerializer.DeserializeStreamAsync<T>(System.IO.Stream! stream, Refit.StreamingContentFormat format, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable<T?>!

src/Refit/PublicAPI/net471/PublicAPI.Shipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ Refit.StreamPart.Value.get -> System.IO.Stream!
361361
Refit.StreamingContentFormat
362362
Refit.StreamingContentFormat.JsonArray = 0 -> Refit.StreamingContentFormat
363363
Refit.StreamingContentFormat.JsonLines = 1 -> Refit.StreamingContentFormat
364+
Refit.StreamingContentFormat.ServerSentEvents = 2 -> Refit.StreamingContentFormat
364365
Refit.SystemTextJsonContentSerializer
365366
Refit.SystemTextJsonContentSerializer.DeserializeFromString<T>(string! content) -> T?
366367
Refit.SystemTextJsonContentSerializer.DeserializeStreamAsync<T>(System.IO.Stream! stream, Refit.StreamingContentFormat format, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable<T?>!

src/Refit/PublicAPI/net472/PublicAPI.Shipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ Refit.StreamPart.Value.get -> System.IO.Stream!
361361
Refit.StreamingContentFormat
362362
Refit.StreamingContentFormat.JsonArray = 0 -> Refit.StreamingContentFormat
363363
Refit.StreamingContentFormat.JsonLines = 1 -> Refit.StreamingContentFormat
364+
Refit.StreamingContentFormat.ServerSentEvents = 2 -> Refit.StreamingContentFormat
364365
Refit.SystemTextJsonContentSerializer
365366
Refit.SystemTextJsonContentSerializer.DeserializeFromString<T>(string! content) -> T?
366367
Refit.SystemTextJsonContentSerializer.DeserializeStreamAsync<T>(System.IO.Stream! stream, Refit.StreamingContentFormat format, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable<T?>!

src/Refit/PublicAPI/net48/PublicAPI.Shipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ Refit.StreamPart.Value.get -> System.IO.Stream!
361361
Refit.StreamingContentFormat
362362
Refit.StreamingContentFormat.JsonArray = 0 -> Refit.StreamingContentFormat
363363
Refit.StreamingContentFormat.JsonLines = 1 -> Refit.StreamingContentFormat
364+
Refit.StreamingContentFormat.ServerSentEvents = 2 -> Refit.StreamingContentFormat
364365
Refit.SystemTextJsonContentSerializer
365366
Refit.SystemTextJsonContentSerializer.DeserializeFromString<T>(string! content) -> T?
366367
Refit.SystemTextJsonContentSerializer.DeserializeStreamAsync<T>(System.IO.Stream! stream, Refit.StreamingContentFormat format, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable<T?>!

0 commit comments

Comments
 (0)