Skip to content

Commit 5d844ff

Browse files
committed
Add support for ServerSentEventsResult and extension methods
1 parent 2edcf3b commit 5d844ff

File tree

8 files changed

+420
-2
lines changed

8 files changed

+420
-2
lines changed

eng/SharedFramework.External.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
<ExternalAspNetCoreAppReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="$(MicrosoftExtensionsOptionsDataAnnotationsVersion)" />
4343
<ExternalAspNetCoreAppReference Include="Microsoft.Extensions.Options" Version="$(MicrosoftExtensionsOptionsVersion)" />
4444
<ExternalAspNetCoreAppReference Include="Microsoft.Extensions.Primitives" Version="$(MicrosoftExtensionsPrimitivesVersion)" />
45+
<ExternalAspNetCoreAppReference Include="System.Net.ServerSentEvents" Version="$(SystemNextServerSentEvents)" />
4546
<ExternalAspNetCoreAppReference Include="System.Security.Cryptography.Xml" Version="$(SystemSecurityCryptographyXmlVersion)" />
4647
<ExternalAspNetCoreAppReference Include="System.Threading.RateLimiting" Version="$(SystemThreadingRateLimitingVersion)" />
4748

src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<Reference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
2929
<Reference Include="Microsoft.AspNetCore.Http.Extensions" />
3030
<Reference Include="Microsoft.AspNetCore.Routing" />
31+
<Reference Include="System.Net.ServerSentEvents" />
3132

3233
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Results.Tests" />
3334
</ItemGroup>
Lines changed: 9 additions & 1 deletion
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!>! value, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult!
6+
static Microsoft.AspNetCore.Http.Results.ServerSentEvents<T>(System.Collections.Generic.IAsyncEnumerable<System.Net.ServerSentEvents.SseItem<T>>! value) -> Microsoft.AspNetCore.Http.IResult!
7+
static Microsoft.AspNetCore.Http.Results.ServerSentEvents<T>(System.Collections.Generic.IAsyncEnumerable<T>! value, 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

Lines changed: 36 additions & 0 deletions
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,41 @@ 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="value">The value 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> value, string? eventType = null)
993+
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
994+
=> new ServerSentEventsResult<string>(value, 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="value">The value 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+
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
1004+
public static IResult ServerSentEvents<T>(IAsyncEnumerable<T> value, string? eventType = null)
1005+
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
1006+
=> new ServerSentEventsResult<T>(value, eventType);
1007+
1008+
/// <summary>
1009+
/// Produces a <see cref="ServerSentEventsResult{T}"/> response.
1010+
/// </summary>
1011+
/// <typeparam name="T">The type of object that will be serialized to the response body.</typeparam>
1012+
/// <param name="value">The value to be included in the HTTP response body.</param>
1013+
/// <returns>The created <see cref="ServerSentEventsResult{T}"/> for the response.</returns>
1014+
public static IResult ServerSentEvents<T>(IAsyncEnumerable<SseItem<T>> value)
1015+
=> new ServerSentEventsResult<T>(value);
1016+
9811017
/// <summary>
9821018
/// Produces an empty result response, that when executed will do nothing.
9831019
/// </summary>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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 System.Text;
7+
using Microsoft.AspNetCore.Http.Metadata;
8+
using System.Reflection;
9+
using Microsoft.AspNetCore.Builder;
10+
using System.Text.Json;
11+
using Microsoft.Extensions.Options;
12+
using Microsoft.AspNetCore.Http.Json;
13+
using Microsoft.Extensions.DependencyInjection;
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+
45+
await SseFormatter.WriteAsync(_events, httpContext.Response.Body,
46+
(item, writer) => FormatSseItem(item, writer, httpContext),
47+
httpContext.RequestAborted);
48+
}
49+
50+
private static void FormatSseItem(SseItem<T> item, IBufferWriter<byte> writer, HttpContext httpContext)
51+
{
52+
// Emit string and null values as-is
53+
if (item.Data is string stringData)
54+
{
55+
writer.Write(Encoding.UTF8.GetBytes(stringData));
56+
return;
57+
}
58+
59+
if (item.Data is null)
60+
{
61+
writer.Write([]);
62+
return;
63+
}
64+
65+
// For non-string types, use JSON serialization with options from DI
66+
var jsonOptions = httpContext.RequestServices.GetService<IOptions<JsonOptions>>()?.Value ?? new JsonOptions();
67+
var runtimeType = item.Data.GetType();
68+
var jsonTypeInfo = jsonOptions.SerializerOptions.GetTypeInfo(typeof(T));
69+
70+
// Use the appropriate JsonTypeInfo based on whether we need polymorphic serialization
71+
var typeInfo = jsonTypeInfo.ShouldUseWith(runtimeType)
72+
? jsonTypeInfo
73+
: jsonOptions.SerializerOptions.GetTypeInfo(typeof(object));
74+
75+
var json = JsonSerializer.Serialize(item.Data, typeInfo);
76+
writer.Write(Encoding.UTF8.GetBytes(json));
77+
}
78+
79+
private static async IAsyncEnumerable<SseItem<T>> WrapEvents(IAsyncEnumerable<T> events, string? eventType = null)
80+
{
81+
await foreach (var item in events)
82+
{
83+
yield return new SseItem<T>(item, eventType);
84+
}
85+
}
86+
87+
static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder)
88+
{
89+
ArgumentNullException.ThrowIfNull(method);
90+
ArgumentNullException.ThrowIfNull(builder);
91+
92+
builder.Metadata.Add(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(SseItem<T>), contentTypes: ["text/event-stream"]));
93+
}
94+
}

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

Lines changed: 36 additions & 0 deletions
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,41 @@ 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 value 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 value 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+
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
1094+
public static ServerSentEventsResult<T> ServerSentEvents<T>(IAsyncEnumerable<T> values, string? eventType = null)
1095+
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
1096+
=> new(values, eventType);
1097+
1098+
/// <summary>
1099+
/// Produces a <see cref="ServerSentEventsResult{T}"/> response.
1100+
/// </summary>
1101+
/// <typeparam name="T">The type of object that will be serialized to the response body.</typeparam>
1102+
/// <param name="values">The value to be included in the HTTP response body.</param>
1103+
/// <returns>The created <see cref="ServerSentEventsResult{T}"/> for the response.</returns>
1104+
public static ServerSentEventsResult<T> ServerSentEvents<T>(IAsyncEnumerable<SseItem<T>> values)
1105+
=> new(values);
1106+
10711107
/// <summary>
10721108
/// Produces an empty result response, that when executed will do nothing.
10731109
/// </summary>

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

Lines changed: 2 additions & 1 deletion
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)