Skip to content

Commit bd1ba76

Browse files
authored
Add support for ServerSentEventsResult and extension methods (#60616)
* Add support for ServerSentEventsResult and extension methods * Address feedback * Use TaskCompletionSource to observe cancellation in tests * Disable buffering and add byte[] handling * Rely on SSE in shared framework
1 parent a2fd165 commit bd1ba76

File tree

6 files changed

+570
-2
lines changed

6 files changed

+570
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,10 @@
1-
#nullable enable
1+
Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<T>
2+
Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<T>.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task!
3+
Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<T>.StatusCode.get -> int?
24
static Microsoft.AspNetCore.Http.HttpResults.RedirectHttpResult.IsLocalUrl(string? url) -> bool
5+
static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable<string!>! values, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult!
6+
static Microsoft.AspNetCore.Http.Results.ServerSentEvents<T>(System.Collections.Generic.IAsyncEnumerable<System.Net.ServerSentEvents.SseItem<T>>! values) -> Microsoft.AspNetCore.Http.IResult!
7+
static Microsoft.AspNetCore.Http.Results.ServerSentEvents<T>(System.Collections.Generic.IAsyncEnumerable<T>! values, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult!
8+
static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable<string!>! values, string? eventType = null) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<string!>!
9+
static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents<T>(System.Collections.Generic.IAsyncEnumerable<System.Net.ServerSentEvents.SseItem<T>>! values) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<T>!
10+
static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents<T>(System.Collections.Generic.IAsyncEnumerable<T>! values, string? eventType = null) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<T>!

src/Http/Http.Results/src/Results.cs

+44
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Diagnostics.CodeAnalysis;
55
using System.IO.Pipelines;
6+
using System.Net.ServerSentEvents;
67
using System.Security.Claims;
78
using System.Text;
89
using System.Text.Json;
@@ -978,6 +979,49 @@ public static IResult AcceptedAtRoute<TValue>(string? routeName, RouteValueDicti
978979
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
979980
=> value is null ? TypedResults.AcceptedAtRoute(routeName, routeValues) : TypedResults.AcceptedAtRoute(value, routeName, routeValues);
980981

982+
/// <summary>
983+
/// Produces a <see cref="ServerSentEventsResult{TValue}"/> response.
984+
/// </summary>
985+
/// <param name="values">The values to be included in the HTTP response body.</param>
986+
/// <param name="eventType">The event type to be included in the HTTP response body.</param>
987+
/// <returns>The created <see cref="ServerSentEventsResult{TValue}"/> for the response.</returns>
988+
/// <remarks>
989+
/// Strings serialized by this result type are serialized as raw strings without any additional formatting.
990+
/// </remarks>
991+
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
992+
public static IResult ServerSentEvents(IAsyncEnumerable<string> values, string? eventType = null)
993+
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
994+
=> new ServerSentEventsResult<string>(values, eventType);
995+
996+
/// <summary>
997+
/// Produces a <see cref="ServerSentEventsResult{T}"/> response.
998+
/// </summary>
999+
/// <typeparam name="T">The type of object that will be serialized to the response body.</typeparam>
1000+
/// <param name="values">The values to be included in the HTTP response body.</param>
1001+
/// <param name="eventType">The event type to be included in the HTTP response body.</param>
1002+
/// <returns>The created <see cref="ServerSentEventsResult{T}"/> for the response.</returns>
1003+
/// <remarks>
1004+
/// Strings serialized by this result type are serialized as raw strings without any additional formatting.
1005+
/// Other types are serialized using the configured JSON serializer options.
1006+
/// </remarks>
1007+
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
1008+
public static IResult ServerSentEvents<T>(IAsyncEnumerable<T> values, string? eventType = null)
1009+
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
1010+
=> new ServerSentEventsResult<T>(values, eventType);
1011+
1012+
/// <summary>
1013+
/// Produces a <see cref="ServerSentEventsResult{T}"/> response.
1014+
/// </summary>
1015+
/// <typeparam name="T">The type of object that will be serialized to the response body.</typeparam>
1016+
/// <param name="values">The values to be included in the HTTP response body.</param>
1017+
/// <returns>The created <see cref="ServerSentEventsResult{T}"/> for the response.</returns>
1018+
/// <remarks>
1019+
/// Strings serialized by this result type are serialized as raw strings without any additional formatting.
1020+
/// Other types are serialized using the configured JSON serializer options.
1021+
/// </remarks>
1022+
public static IResult ServerSentEvents<T>(IAsyncEnumerable<SseItem<T>> values)
1023+
=> new ServerSentEventsResult<T>(values);
1024+
9811025
/// <summary>
9821026
/// Produces an empty result response, that when executed will do nothing.
9831027
/// </summary>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Buffers;
5+
using System.Net.ServerSentEvents;
6+
using Microsoft.AspNetCore.Http.Metadata;
7+
using System.Reflection;
8+
using Microsoft.AspNetCore.Builder;
9+
using System.Text.Json;
10+
using Microsoft.Extensions.Options;
11+
using Microsoft.AspNetCore.Http.Json;
12+
using Microsoft.Extensions.DependencyInjection;
13+
using Microsoft.AspNetCore.Http.Features;
14+
15+
namespace Microsoft.AspNetCore.Http.HttpResults;
16+
17+
/// <summary>
18+
/// Represents a result that writes a stream of server-sent events to the response.
19+
/// </summary>
20+
/// <typeparam name="T">The underlying type of the events emitted.</typeparam>
21+
public sealed class ServerSentEventsResult<T> : IResult, IEndpointMetadataProvider, IStatusCodeHttpResult
22+
{
23+
private readonly IAsyncEnumerable<SseItem<T>> _events;
24+
25+
/// <inheritdoc/>
26+
public int? StatusCode => StatusCodes.Status200OK;
27+
28+
internal ServerSentEventsResult(IAsyncEnumerable<T> events, string? eventType)
29+
{
30+
_events = WrapEvents(events, eventType);
31+
}
32+
33+
internal ServerSentEventsResult(IAsyncEnumerable<SseItem<T>> events)
34+
{
35+
_events = events;
36+
}
37+
38+
/// <inheritdoc />
39+
public async Task ExecuteAsync(HttpContext httpContext)
40+
{
41+
ArgumentNullException.ThrowIfNull(httpContext);
42+
43+
httpContext.Response.ContentType = "text/event-stream";
44+
httpContext.Response.Headers.CacheControl = "no-cache,no-store";
45+
httpContext.Response.Headers.Pragma = "no-cache";
46+
httpContext.Response.Headers.ContentEncoding = "identity";
47+
48+
var bufferingFeature = httpContext.Features.GetRequiredFeature<IHttpResponseBodyFeature>();
49+
bufferingFeature.DisableBuffering();
50+
51+
var jsonOptions = httpContext.RequestServices.GetService<IOptions<JsonOptions>>()?.Value ?? new JsonOptions();
52+
53+
// If the event type is string, we can skip JSON serialization
54+
// and directly use the SseFormatter's WriteAsync overload for strings.
55+
if (_events is IAsyncEnumerable<SseItem<string>> stringEvents)
56+
{
57+
await SseFormatter.WriteAsync(stringEvents, httpContext.Response.Body, httpContext.RequestAborted);
58+
return;
59+
}
60+
61+
await SseFormatter.WriteAsync(_events, httpContext.Response.Body,
62+
(item, writer) => FormatSseItem(item, writer, jsonOptions),
63+
httpContext.RequestAborted);
64+
}
65+
66+
private static void FormatSseItem(SseItem<T> item, IBufferWriter<byte> writer, JsonOptions jsonOptions)
67+
{
68+
if (item.Data is null)
69+
{
70+
writer.Write([]);
71+
return;
72+
}
73+
74+
// Handle byte arrays byt writing them directly as strings.
75+
if (item.Data is byte[] byteArray)
76+
{
77+
writer.Write(byteArray);
78+
return;
79+
}
80+
81+
// For non-string types, use JSON serialization with options from DI
82+
var runtimeType = item.Data.GetType();
83+
var jsonTypeInfo = jsonOptions.SerializerOptions.GetTypeInfo(typeof(T));
84+
85+
// Use the appropriate JsonTypeInfo based on whether we need polymorphic serialization
86+
var typeInfo = jsonTypeInfo.ShouldUseWith(runtimeType)
87+
? jsonTypeInfo
88+
: jsonOptions.SerializerOptions.GetTypeInfo(typeof(object));
89+
90+
var json = JsonSerializer.SerializeToUtf8Bytes(item.Data, typeInfo);
91+
writer.Write(json);
92+
}
93+
94+
private static async IAsyncEnumerable<SseItem<T>> WrapEvents(IAsyncEnumerable<T> events, string? eventType = null)
95+
{
96+
await foreach (var item in events)
97+
{
98+
yield return new SseItem<T>(item, eventType);
99+
}
100+
}
101+
102+
static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder)
103+
{
104+
ArgumentNullException.ThrowIfNull(method);
105+
ArgumentNullException.ThrowIfNull(builder);
106+
107+
builder.Metadata.Add(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(SseItem<T>), contentTypes: ["text/event-stream"]));
108+
}
109+
}

src/Http/Http.Results/src/TypedResults.cs

+44
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Diagnostics.CodeAnalysis;
55
using System.IO.Pipelines;
6+
using System.Net.ServerSentEvents;
67
using System.Security.Claims;
78
using System.Text;
89
using System.Text.Json;
@@ -1068,6 +1069,49 @@ public static AcceptedAtRoute<TValue> AcceptedAtRoute<TValue>(TValue? value, str
10681069
public static AcceptedAtRoute<TValue> AcceptedAtRoute<TValue>(TValue? value, string? routeName, RouteValueDictionary? routeValues)
10691070
=> new(routeName, routeValues, value);
10701071

1072+
/// <summary>
1073+
/// Produces a <see cref="ServerSentEventsResult{TValue}"/> response.
1074+
/// </summary>
1075+
/// <param name="values">The values to be included in the HTTP response body.</param>
1076+
/// <param name="eventType">The event type to be included in the HTTP response body.</param>
1077+
/// <returns>The created <see cref="ServerSentEventsResult{TValue}"/> for the response.</returns>
1078+
/// <remarks>
1079+
/// Strings serialized by this result type are serialized as raw strings without any additional formatting.
1080+
/// </remarks>
1081+
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
1082+
public static ServerSentEventsResult<string> ServerSentEvents(IAsyncEnumerable<string> values, string? eventType = null)
1083+
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
1084+
=> new(values, eventType);
1085+
1086+
/// <summary>
1087+
/// Produces a <see cref="ServerSentEventsResult{T}"/> response.
1088+
/// </summary>
1089+
/// <typeparam name="T">The type of object that will be serialized to the response body.</typeparam>
1090+
/// <param name="values">The values to be included in the HTTP response body.</param>
1091+
/// <param name="eventType">The event type to be included in the HTTP response body.</param>
1092+
/// <returns>The created <see cref="ServerSentEventsResult{T}"/> for the response.</returns>
1093+
/// <remarks>
1094+
/// Strings serialized by this result type are serialized as raw strings without any additional formatting.
1095+
/// Other types are serialized using the configured JSON serializer options.
1096+
/// </remarks>
1097+
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
1098+
public static ServerSentEventsResult<T> ServerSentEvents<T>(IAsyncEnumerable<T> values, string? eventType = null)
1099+
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
1100+
=> new(values, eventType);
1101+
1102+
/// <summary>
1103+
/// Produces a <see cref="ServerSentEventsResult{T}"/> response.
1104+
/// </summary>
1105+
/// <typeparam name="T">The type of object that will be serialized to the response body.</typeparam>
1106+
/// <param name="values">The values to be included in the HTTP response body.</param>
1107+
/// <returns>The created <see cref="ServerSentEventsResult{T}"/> for the response.</returns>
1108+
/// <remarks>
1109+
/// Strings serialized by this result type are serialized as raw strings without any additional formatting.
1110+
/// Other types are serialized using the configured JSON serializer options.
1111+
/// </remarks>
1112+
public static ServerSentEventsResult<T> ServerSentEvents<T>(IAsyncEnumerable<SseItem<T>> values)
1113+
=> new(values);
1114+
10711115
/// <summary>
10721116
/// Produces an empty result response, that when executed will do nothing.
10731117
/// </summary>

src/Http/Http.Results/test/ResultsTests.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -1777,7 +1777,8 @@ private static string GetMemberName(Expression expression)
17771777
(() => Results.Unauthorized(), typeof(UnauthorizedHttpResult)),
17781778
(() => Results.UnprocessableEntity(null), typeof(UnprocessableEntity)),
17791779
(() => Results.UnprocessableEntity(new()), typeof(UnprocessableEntity<object>)),
1780-
(() => Results.ValidationProblem(new Dictionary<string, string[]>(), null, null, null, null, null, null), typeof(ProblemHttpResult))
1780+
(() => Results.ValidationProblem(new Dictionary<string, string[]>(), null, null, null, null, null, null), typeof(ProblemHttpResult)),
1781+
(() => Results.ServerSentEvents(AsyncEnumerable.Empty<string>(), null), typeof(ServerSentEventsResult<string>)),
17811782
};
17821783

17831784
public static IEnumerable<object[]> FactoryMethodsFromTuples() => FactoryMethodsTuples.Select(t => new object[] { t.Item1, t.Item2 });

0 commit comments

Comments
 (0)