From 5d844ffdc278d5c5dc85aa367e11f2b231a0ddcf Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Tue, 25 Feb 2025 11:49:59 -0800 Subject: [PATCH 1/5] Add support for ServerSentEventsResult and extension methods --- eng/SharedFramework.External.props | 1 + .../Microsoft.AspNetCore.Http.Results.csproj | 1 + .../Http.Results/src/PublicAPI.Unshipped.txt | 10 +- src/Http/Http.Results/src/Results.cs | 36 +++ .../src/ServerSentEventsResult.cs | 94 +++++++ src/Http/Http.Results/src/TypedResults.cs | 36 +++ src/Http/Http.Results/test/ResultsTests.cs | 3 +- .../test/ServerSentEventsResultTests.cs | 241 ++++++++++++++++++ 8 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 src/Http/Http.Results/src/ServerSentEventsResult.cs create mode 100644 src/Http/Http.Results/test/ServerSentEventsResultTests.cs diff --git a/eng/SharedFramework.External.props b/eng/SharedFramework.External.props index 8b5b19fb9861..87c4963cc7ab 100644 --- a/eng/SharedFramework.External.props +++ b/eng/SharedFramework.External.props @@ -42,6 +42,7 @@ + diff --git a/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj b/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj index ea792b089048..b7947cb3b78e 100644 --- a/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj +++ b/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Http/Http.Results/src/PublicAPI.Unshipped.txt b/src/Http/Http.Results/src/PublicAPI.Unshipped.txt index e33522ad2841..4365cf8ce9ae 100644 --- a/src/Http/Http.Results/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Results/src/PublicAPI.Unshipped.txt @@ -1,2 +1,10 @@ -#nullable enable +Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult +Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult.StatusCode.get -> int? static Microsoft.AspNetCore.Http.HttpResults.RedirectHttpResult.IsLocalUrl(string? url) -> bool +static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable! value, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable>! value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable! value, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable! values, string? eventType = null) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult! +static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable>! values) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult! +static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable! values, string? eventType = null) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult! diff --git a/src/Http/Http.Results/src/Results.cs b/src/Http/Http.Results/src/Results.cs index 662fe0769cce..3098a24cbcfd 100644 --- a/src/Http/Http.Results/src/Results.cs +++ b/src/Http/Http.Results/src/Results.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Pipelines; +using System.Net.ServerSentEvents; using System.Security.Claims; using System.Text; using System.Text.Json; @@ -978,6 +979,41 @@ public static IResult AcceptedAtRoute(string? routeName, RouteValueDicti #pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters => value is null ? TypedResults.AcceptedAtRoute(routeName, routeValues) : TypedResults.AcceptedAtRoute(value, routeName, routeValues); + /// + /// Produces a response. + /// + /// The value to be included in the HTTP response body. + /// The event type to be included in the HTTP response body. + /// The created for the response. + /// + /// Strings serialized by this result type are serialized as raw strings without any additional formatting. + /// +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters + public static IResult ServerSentEvents(IAsyncEnumerable value, string? eventType = null) +#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters + => new ServerSentEventsResult(value, eventType); + + /// + /// Produces a response. + /// + /// The type of object that will be serialized to the response body. + /// The value to be included in the HTTP response body. + /// The event type to be included in the HTTP response body. + /// The created for the response. +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters + public static IResult ServerSentEvents(IAsyncEnumerable value, string? eventType = null) +#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters + => new ServerSentEventsResult(value, eventType); + + /// + /// Produces a response. + /// + /// The type of object that will be serialized to the response body. + /// The value to be included in the HTTP response body. + /// The created for the response. + public static IResult ServerSentEvents(IAsyncEnumerable> value) + => new ServerSentEventsResult(value); + /// /// Produces an empty result response, that when executed will do nothing. /// diff --git a/src/Http/Http.Results/src/ServerSentEventsResult.cs b/src/Http/Http.Results/src/ServerSentEventsResult.cs new file mode 100644 index 000000000000..12a3e3f40112 --- /dev/null +++ b/src/Http/Http.Results/src/ServerSentEventsResult.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Net.ServerSentEvents; +using System.Text; +using Microsoft.AspNetCore.Http.Metadata; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using System.Text.Json; +using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Http.HttpResults; + +/// +/// Represents a result that writes a stream of server-sent events to the response. +/// +/// The underlying type of the events emitted. +public sealed class ServerSentEventsResult : IResult, IEndpointMetadataProvider, IStatusCodeHttpResult +{ + private readonly IAsyncEnumerable> _events; + + /// + public int? StatusCode => StatusCodes.Status200OK; + + internal ServerSentEventsResult(IAsyncEnumerable events, string? eventType) + { + _events = WrapEvents(events, eventType); + } + + internal ServerSentEventsResult(IAsyncEnumerable> events) + { + _events = events; + } + + /// + public async Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + httpContext.Response.ContentType = "text/event-stream"; + + await SseFormatter.WriteAsync(_events, httpContext.Response.Body, + (item, writer) => FormatSseItem(item, writer, httpContext), + httpContext.RequestAborted); + } + + private static void FormatSseItem(SseItem item, IBufferWriter writer, HttpContext httpContext) + { + // Emit string and null values as-is + if (item.Data is string stringData) + { + writer.Write(Encoding.UTF8.GetBytes(stringData)); + return; + } + + if (item.Data is null) + { + writer.Write([]); + return; + } + + // For non-string types, use JSON serialization with options from DI + var jsonOptions = httpContext.RequestServices.GetService>()?.Value ?? new JsonOptions(); + var runtimeType = item.Data.GetType(); + var jsonTypeInfo = jsonOptions.SerializerOptions.GetTypeInfo(typeof(T)); + + // Use the appropriate JsonTypeInfo based on whether we need polymorphic serialization + var typeInfo = jsonTypeInfo.ShouldUseWith(runtimeType) + ? jsonTypeInfo + : jsonOptions.SerializerOptions.GetTypeInfo(typeof(object)); + + var json = JsonSerializer.Serialize(item.Data, typeInfo); + writer.Write(Encoding.UTF8.GetBytes(json)); + } + + private static async IAsyncEnumerable> WrapEvents(IAsyncEnumerable events, string? eventType = null) + { + await foreach (var item in events) + { + yield return new SseItem(item, eventType); + } + } + + static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(builder); + + builder.Metadata.Add(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(SseItem), contentTypes: ["text/event-stream"])); + } +} diff --git a/src/Http/Http.Results/src/TypedResults.cs b/src/Http/Http.Results/src/TypedResults.cs index 8eb7c9fcd834..60a1c0cd0208 100644 --- a/src/Http/Http.Results/src/TypedResults.cs +++ b/src/Http/Http.Results/src/TypedResults.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Pipelines; +using System.Net.ServerSentEvents; using System.Security.Claims; using System.Text; using System.Text.Json; @@ -1068,6 +1069,41 @@ public static AcceptedAtRoute AcceptedAtRoute(TValue? value, str public static AcceptedAtRoute AcceptedAtRoute(TValue? value, string? routeName, RouteValueDictionary? routeValues) => new(routeName, routeValues, value); + /// + /// Produces a response. + /// + /// The value to be included in the HTTP response body. + /// The event type to be included in the HTTP response body. + /// The created for the response. + /// + /// Strings serialized by this result type are serialized as raw strings without any additional formatting. + /// +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters + public static ServerSentEventsResult ServerSentEvents(IAsyncEnumerable values, string? eventType = null) +#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters + => new(values, eventType); + + /// + /// Produces a response. + /// + /// The type of object that will be serialized to the response body. + /// The value to be included in the HTTP response body. + /// The event type to be included in the HTTP response body. + /// The created for the response. +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters + public static ServerSentEventsResult ServerSentEvents(IAsyncEnumerable values, string? eventType = null) +#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters + => new(values, eventType); + + /// + /// Produces a response. + /// + /// The type of object that will be serialized to the response body. + /// The value to be included in the HTTP response body. + /// The created for the response. + public static ServerSentEventsResult ServerSentEvents(IAsyncEnumerable> values) + => new(values); + /// /// Produces an empty result response, that when executed will do nothing. /// diff --git a/src/Http/Http.Results/test/ResultsTests.cs b/src/Http/Http.Results/test/ResultsTests.cs index 3e7220e717d0..ce87ee10e907 100644 --- a/src/Http/Http.Results/test/ResultsTests.cs +++ b/src/Http/Http.Results/test/ResultsTests.cs @@ -1777,7 +1777,8 @@ private static string GetMemberName(Expression expression) (() => Results.Unauthorized(), typeof(UnauthorizedHttpResult)), (() => Results.UnprocessableEntity(null), typeof(UnprocessableEntity)), (() => Results.UnprocessableEntity(new()), typeof(UnprocessableEntity)), - (() => Results.ValidationProblem(new Dictionary(), null, null, null, null, null, null), typeof(ProblemHttpResult)) + (() => Results.ValidationProblem(new Dictionary(), null, null, null, null, null, null), typeof(ProblemHttpResult)), + (() => Results.ServerSentEvents(AsyncEnumerable.Empty(), null), typeof(ServerSentEventsResult)), }; public static IEnumerable FactoryMethodsFromTuples() => FactoryMethodsTuples.Select(t => new object[] { t.Item1, t.Item2 }); diff --git a/src/Http/Http.Results/test/ServerSentEventsResultTests.cs b/src/Http/Http.Results/test/ServerSentEventsResultTests.cs new file mode 100644 index 000000000000..be8377fadd9f --- /dev/null +++ b/src/Http/Http.Results/test/ServerSentEventsResultTests.cs @@ -0,0 +1,241 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.ServerSentEvents; +using System.Reflection; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Http.HttpResults; + +public class ServerSentEventsResultTests +{ + [Fact] + public async Task ExecuteAsync_SetsContentType() + { + // Arrange + var httpContext = GetHttpContext(); + var events = AsyncEnumerable.Empty(); + var result = TypedResults.ServerSentEvents(events); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal("text/event-stream", httpContext.Response.ContentType); + } + + [Fact] + public async Task ExecuteAsync_WritesStringEventsToResponse() + { + // Arrange + var httpContext = GetHttpContext(); + var events = new[] { "event1\"with\"quotes", "event2" }.ToAsyncEnumerable(); + var result = TypedResults.ServerSentEvents(events); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains("data: event1\"with\"quotes\n\n", responseBody); + Assert.Contains("data: event2\n\n", responseBody); + + // Verify strings are not JSON serialized + Assert.DoesNotContain("data: \"event1", responseBody); + Assert.DoesNotContain("data: \"event2", responseBody); + } + + [Fact] + public async Task ExecuteAsync_WritesStringsEventsWithType() + { + // Arrange + var httpContext = GetHttpContext(); + var events = new[] { "event1" }.ToAsyncEnumerable(); + var result = TypedResults.ServerSentEvents(events, "test-event"); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains("event: test-event\n", responseBody); + Assert.Contains("data: event1\n\n", responseBody); + } + + [Fact] + public async Task ExecuteAsync_WithSseItems_WritesStringEventsDirectly() + { + // Arrange + var httpContext = GetHttpContext(); + var events = new[] { new SseItem("event1", "custom-event") }.ToAsyncEnumerable(); + var result = TypedResults.ServerSentEvents(events); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains("event: custom-event\n", responseBody); + Assert.Contains("data: event1\n\n", responseBody); + } + + [Fact] + public async Task ExecuteAsync_ThrowsArgumentNullException_WhenHttpContextIsNull() + { + // Arrange + var events = AsyncEnumerable.Empty(); + var result = TypedResults.ServerSentEvents(events); + HttpContext httpContext = null; + + // Act & Assert + await Assert.ThrowsAsync("httpContext", () => result.ExecuteAsync(httpContext)); + } + + [Fact] + public async Task ExecuteAsync_HandlesNullData() + { + // Arrange + var httpContext = GetHttpContext(); + var events = new string[] { null }.ToAsyncEnumerable(); + var result = TypedResults.ServerSentEvents(events); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains("data: \n\n", responseBody); + } + + [Fact] + public void PopulateMetadata_AddsResponseTypeMetadata() + { + // Arrange + ServerSentEventsResult MyApi() { throw new NotImplementedException(); } + var builder = new RouteEndpointBuilder(requestDelegate: null, RoutePatternFactory.Parse("/"), order: 0); + + // Act + PopulateMetadata>(((Delegate)MyApi).GetMethodInfo(), builder); + + // Assert + var producesResponseTypeMetadata = builder.Metadata.OfType().Last(); + Assert.Equal(StatusCodes.Status200OK, producesResponseTypeMetadata.StatusCode); + Assert.Equal(typeof(SseItem), producesResponseTypeMetadata.Type); + Assert.Collection(producesResponseTypeMetadata.ContentTypes, + contentType => Assert.Equal("text/event-stream", contentType)); + } + + [Fact] + public async Task ExecuteAsync_WithObjectData_SerializesAsJson() + { + // Arrange + var httpContext = GetHttpContext(); + var testObject = new TestObject { Name = "Test", Value = 42 }; + var events = new[] { testObject }.ToAsyncEnumerable(); + var result = TypedResults.ServerSentEvents(events); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains(@"data: {""name"":""Test"",""value"":42}", responseBody); + } + + [Fact] + public async Task ExecuteAsync_WithSsItems_SerializesDataAsJson() + { + // Arrange + var httpContext = GetHttpContext(); + var testObject = new TestObject { Name = "Test", Value = 42 }; + var events = new[] { new SseItem(testObject) }.ToAsyncEnumerable(); + var result = TypedResults.ServerSentEvents(events); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains(@"data: {""name"":""Test"",""value"":42}", responseBody); + } + + [Fact] + public async Task ExecuteAsync_WithCustomJsonOptions_UsesConfiguredOptions() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.Configure(options => + { + options.SerializerOptions.PropertyNamingPolicy = null; // Use PascalCase + }); + var httpContext = new DefaultHttpContext + { + Response = { Body = new MemoryStream() }, + RequestServices = services.BuildServiceProvider() + }; + + var testObject = new TestObject { Name = "Test", Value = 42 }; + var events = new[] { testObject }.ToAsyncEnumerable(); + var result = TypedResults.ServerSentEvents(events); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains(@"data: {""Name"":""Test"",""Value"":42}", responseBody); + } + + [Fact] + public async Task ExecuteAsync_WithPolymorphicType_SerializesCorrectly() + { + // Arrange + var httpContext = GetHttpContext(); + var baseClass = new DerivedTestObject { Name = "Test", Value = 42, Extra = "Additional" }; + var events = new TestObject[] { baseClass }.ToAsyncEnumerable(); + var result = TypedResults.ServerSentEvents(events); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains(@"""extra"":""Additional""", responseBody); + } + + private static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + where TResult : IEndpointMetadataProvider => TResult.PopulateMetadata(method, builder); + + private static DefaultHttpContext GetHttpContext() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = CreateServices(); + return httpContext; + } + + private static ServiceProvider CreateServices() + { + var services = new ServiceCollection(); + services.AddLogging(); + return services.BuildServiceProvider(); + } + + private class TestObject + { + public string Name { get; set; } + public int Value { get; set; } + } + + private class DerivedTestObject : TestObject + { + public string Extra { get; set; } + } +} From de869209f614ea1cdcbe243df2b09bf535a522e4 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Tue, 25 Feb 2025 16:11:25 -0800 Subject: [PATCH 2/5] Address feedback --- eng/SharedFramework.External.props | 2 +- src/Framework/test/TestData.cs | 2 + .../Http.Results/src/PublicAPI.Unshipped.txt | 6 +- src/Http/Http.Results/src/Results.cs | 26 +++++--- .../src/ServerSentEventsResult.cs | 29 +++++---- src/Http/Http.Results/src/TypedResults.cs | 14 ++++- .../test/ServerSentEventsResultTests.cs | 59 +++++++++++++++++-- 7 files changed, 103 insertions(+), 35 deletions(-) diff --git a/eng/SharedFramework.External.props b/eng/SharedFramework.External.props index 87c4963cc7ab..a7626f0ba84f 100644 --- a/eng/SharedFramework.External.props +++ b/eng/SharedFramework.External.props @@ -42,7 +42,7 @@ - + diff --git a/src/Framework/test/TestData.cs b/src/Framework/test/TestData.cs index fe88b95fcdd6..63bb214b497e 100644 --- a/src/Framework/test/TestData.cs +++ b/src/Framework/test/TestData.cs @@ -155,6 +155,7 @@ static TestData() "Microsoft.Net.Http.Headers", "System.Diagnostics.EventLog", "System.Diagnostics.EventLog.Messages", + "System.Net.ServerSentEvents", "System.Security.Cryptography.Pkcs", "System.Security.Cryptography.Xml", "System.Threading.RateLimiting", @@ -305,6 +306,7 @@ static TestData() { "Microsoft.JSInterop" }, { "Microsoft.Net.Http.Headers" }, { "System.Diagnostics.EventLog" }, + { "System.Net.ServerSentEvents" }, { "System.Security.Cryptography.Xml" }, { "System.Threading.RateLimiting" }, }; diff --git a/src/Http/Http.Results/src/PublicAPI.Unshipped.txt b/src/Http/Http.Results/src/PublicAPI.Unshipped.txt index 4365cf8ce9ae..65f0c286aa46 100644 --- a/src/Http/Http.Results/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Results/src/PublicAPI.Unshipped.txt @@ -2,9 +2,9 @@ Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult.StatusCode.get -> int? static Microsoft.AspNetCore.Http.HttpResults.RedirectHttpResult.IsLocalUrl(string? url) -> bool -static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable! value, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult! -static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable>! value) -> Microsoft.AspNetCore.Http.IResult! -static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable! value, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable! values, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable>! values) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable! values, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult! static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable! values, string? eventType = null) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult! static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable>! values) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult! static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable! values, string? eventType = null) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult! diff --git a/src/Http/Http.Results/src/Results.cs b/src/Http/Http.Results/src/Results.cs index 3098a24cbcfd..27c58d0eb22a 100644 --- a/src/Http/Http.Results/src/Results.cs +++ b/src/Http/Http.Results/src/Results.cs @@ -982,37 +982,45 @@ public static IResult AcceptedAtRoute(string? routeName, RouteValueDicti /// /// Produces a response. /// - /// The value to be included in the HTTP response body. + /// The values to be included in the HTTP response body. /// The event type to be included in the HTTP response body. /// The created for the response. /// /// Strings serialized by this result type are serialized as raw strings without any additional formatting. /// #pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters - public static IResult ServerSentEvents(IAsyncEnumerable value, string? eventType = null) + public static IResult ServerSentEvents(IAsyncEnumerable values, string? eventType = null) #pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters - => new ServerSentEventsResult(value, eventType); + => new ServerSentEventsResult(values, eventType); /// /// Produces a response. /// /// The type of object that will be serialized to the response body. - /// The value to be included in the HTTP response body. + /// The values to be included in the HTTP response body. /// The event type to be included in the HTTP response body. /// The created for the response. + /// + /// Strings serialized by this result type are serialized as raw strings without any additional formatting. + /// Other types are serialized using the configured JSON serializer options. + /// #pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters - public static IResult ServerSentEvents(IAsyncEnumerable value, string? eventType = null) + public static IResult ServerSentEvents(IAsyncEnumerable values, string? eventType = null) #pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters - => new ServerSentEventsResult(value, eventType); + => new ServerSentEventsResult(values, eventType); /// /// Produces a response. /// /// The type of object that will be serialized to the response body. - /// The value to be included in the HTTP response body. + /// The values to be included in the HTTP response body. /// The created for the response. - public static IResult ServerSentEvents(IAsyncEnumerable> value) - => new ServerSentEventsResult(value); + /// + /// Strings serialized by this result type are serialized as raw strings without any additional formatting. + /// Other types are serialized using the configured JSON serializer options. + /// + public static IResult ServerSentEvents(IAsyncEnumerable> values) + => new ServerSentEventsResult(values); /// /// Produces an empty result response, that when executed will do nothing. diff --git a/src/Http/Http.Results/src/ServerSentEventsResult.cs b/src/Http/Http.Results/src/ServerSentEventsResult.cs index 12a3e3f40112..2cdbed737ebc 100644 --- a/src/Http/Http.Results/src/ServerSentEventsResult.cs +++ b/src/Http/Http.Results/src/ServerSentEventsResult.cs @@ -3,7 +3,6 @@ using System.Buffers; using System.Net.ServerSentEvents; -using System.Text; using Microsoft.AspNetCore.Http.Metadata; using System.Reflection; using Microsoft.AspNetCore.Builder; @@ -41,21 +40,26 @@ public async Task ExecuteAsync(HttpContext httpContext) ArgumentNullException.ThrowIfNull(httpContext); httpContext.Response.ContentType = "text/event-stream"; + httpContext.Response.Headers.CacheControl = "no-cache,no-store"; + httpContext.Response.Headers.Pragma = "no-cache"; - await SseFormatter.WriteAsync(_events, httpContext.Response.Body, - (item, writer) => FormatSseItem(item, writer, httpContext), - httpContext.RequestAborted); - } + var jsonOptions = httpContext.RequestServices.GetService>()?.Value ?? new JsonOptions(); - private static void FormatSseItem(SseItem item, IBufferWriter writer, HttpContext httpContext) - { - // Emit string and null values as-is - if (item.Data is string stringData) + // If the event type is string, we can skip JSON serialization + // and directly use the SseFormatter's WriteAsync overload for strings. + if (_events is IAsyncEnumerable> stringEvents) { - writer.Write(Encoding.UTF8.GetBytes(stringData)); + await SseFormatter.WriteAsync(stringEvents, httpContext.Response.Body, httpContext.RequestAborted); return; } + await SseFormatter.WriteAsync(_events, httpContext.Response.Body, + (item, writer) => FormatSseItem(item, writer, jsonOptions), + httpContext.RequestAborted); + } + + private static void FormatSseItem(SseItem item, IBufferWriter writer, JsonOptions jsonOptions) + { if (item.Data is null) { writer.Write([]); @@ -63,7 +67,6 @@ private static void FormatSseItem(SseItem item, IBufferWriter writer, H } // For non-string types, use JSON serialization with options from DI - var jsonOptions = httpContext.RequestServices.GetService>()?.Value ?? new JsonOptions(); var runtimeType = item.Data.GetType(); var jsonTypeInfo = jsonOptions.SerializerOptions.GetTypeInfo(typeof(T)); @@ -72,8 +75,8 @@ private static void FormatSseItem(SseItem item, IBufferWriter writer, H ? jsonTypeInfo : jsonOptions.SerializerOptions.GetTypeInfo(typeof(object)); - var json = JsonSerializer.Serialize(item.Data, typeInfo); - writer.Write(Encoding.UTF8.GetBytes(json)); + var json = JsonSerializer.SerializeToUtf8Bytes(item.Data, typeInfo); + writer.Write(json); } private static async IAsyncEnumerable> WrapEvents(IAsyncEnumerable events, string? eventType = null) diff --git a/src/Http/Http.Results/src/TypedResults.cs b/src/Http/Http.Results/src/TypedResults.cs index 60a1c0cd0208..d3615c676de1 100644 --- a/src/Http/Http.Results/src/TypedResults.cs +++ b/src/Http/Http.Results/src/TypedResults.cs @@ -1072,7 +1072,7 @@ public static AcceptedAtRoute AcceptedAtRoute(TValue? value, str /// /// Produces a response. /// - /// The value to be included in the HTTP response body. + /// The values to be included in the HTTP response body. /// The event type to be included in the HTTP response body. /// The created for the response. /// @@ -1087,9 +1087,13 @@ public static ServerSentEventsResult ServerSentEvents(IAsyncEnumerable response. /// /// The type of object that will be serialized to the response body. - /// The value to be included in the HTTP response body. + /// The values to be included in the HTTP response body. /// The event type to be included in the HTTP response body. /// The created for the response. + /// + /// Strings serialized by this result type are serialized as raw strings without any additional formatting. + /// Other types are serialized using the configured JSON serializer options. + /// #pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters public static ServerSentEventsResult ServerSentEvents(IAsyncEnumerable values, string? eventType = null) #pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters @@ -1099,8 +1103,12 @@ public static ServerSentEventsResult ServerSentEvents(IAsyncEnumerable /// Produces a response. /// /// The type of object that will be serialized to the response body. - /// The value to be included in the HTTP response body. + /// The values to be included in the HTTP response body. /// The created for the response. + /// + /// Strings serialized by this result type are serialized as raw strings without any additional formatting. + /// Other types are serialized using the configured JSON serializer options. + /// public static ServerSentEventsResult ServerSentEvents(IAsyncEnumerable> values) => new(values); diff --git a/src/Http/Http.Results/test/ServerSentEventsResultTests.cs b/src/Http/Http.Results/test/ServerSentEventsResultTests.cs index be8377fadd9f..999e79bcf0bc 100644 --- a/src/Http/Http.Results/test/ServerSentEventsResultTests.cs +++ b/src/Http/Http.Results/test/ServerSentEventsResultTests.cs @@ -3,6 +3,7 @@ using System.Net.ServerSentEvents; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http.Json; @@ -16,7 +17,7 @@ namespace Microsoft.AspNetCore.Http.HttpResults; public class ServerSentEventsResultTests { [Fact] - public async Task ExecuteAsync_SetsContentType() + public async Task ExecuteAsync_SetsContentTypeAndHeaders() { // Arrange var httpContext = GetHttpContext(); @@ -28,6 +29,8 @@ public async Task ExecuteAsync_SetsContentType() // Assert Assert.Equal("text/event-stream", httpContext.Response.ContentType); + Assert.Equal("no-cache,no-store", httpContext.Response.Headers.CacheControl); + Assert.Equal("no-cache", httpContext.Response.Headers.Pragma); } [Fact] @@ -52,11 +55,11 @@ public async Task ExecuteAsync_WritesStringEventsToResponse() } [Fact] - public async Task ExecuteAsync_WritesStringsEventsWithType() + public async Task ExecuteAsync_WritesStringsEventsWithEventType() { // Arrange var httpContext = GetHttpContext(); - var events = new[] { "event1" }.ToAsyncEnumerable(); + var events = new[] { "event1", "event2" }.ToAsyncEnumerable(); var result = TypedResults.ServerSentEvents(events, "test-event"); // Act @@ -64,8 +67,8 @@ public async Task ExecuteAsync_WritesStringsEventsWithType() // Assert var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); - Assert.Contains("event: test-event\n", responseBody); - Assert.Contains("data: event1\n\n", responseBody); + Assert.Contains("event: test-event\ndata: event1\n\n", responseBody); + Assert.Contains("event: test-event\ndata: event2\n\n", responseBody); } [Fact] @@ -207,7 +210,51 @@ public async Task ExecuteAsync_WithPolymorphicType_SerializesCorrectly() // Assert var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); - Assert.Contains(@"""extra"":""Additional""", responseBody); + Assert.Contains(@"data: {""extra"":""Additional"",""name"":""Test"",""value"":42}", responseBody); + } + + [Fact] + public async Task ExecuteAsync_ObservesCancellationViaRequestAborted() + { + // Arrange + var cts = new CancellationTokenSource(); + var httpContext = GetHttpContext(); + httpContext.RequestAborted = cts.Token; + var firstEventReceived = new TaskCompletionSource(); + var secondEventAttempted = new TaskCompletionSource(); + + var events = GetEvents(cts.Token); + var result = TypedResults.ServerSentEvents(events); + + // Act & Assert + var executeTask = result.ExecuteAsync(httpContext); + + // Wait for first event to be processed then cancel the request and wait + // to observe the cancellation + await firstEventReceived.Task; + cts.Cancel(); + await secondEventAttempted.Task; + + // Verify the execution was cancelled and only the first event was written + await Assert.ThrowsAsync(() => executeTask); + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains("data: event1\n\n", responseBody); + Assert.DoesNotContain("data: event2\n\n", responseBody); + + async IAsyncEnumerable GetEvents([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + try + { + yield return "event1"; + firstEventReceived.SetResult(); + await Task.Delay(1, cancellationToken); + yield return "event2"; + } + finally + { + secondEventAttempted.SetResult(); + } + } } private static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) From 6f1f6bed0c70788def161bd6090d432f7282cfbf Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Tue, 25 Feb 2025 16:39:15 -0800 Subject: [PATCH 3/5] Use TaskCompletionSource to observe cancellation in tests --- src/Http/Http.Results/test/ServerSentEventsResultTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Http/Http.Results/test/ServerSentEventsResultTests.cs b/src/Http/Http.Results/test/ServerSentEventsResultTests.cs index 999e79bcf0bc..d764ab70ba36 100644 --- a/src/Http/Http.Results/test/ServerSentEventsResultTests.cs +++ b/src/Http/Http.Results/test/ServerSentEventsResultTests.cs @@ -222,6 +222,7 @@ public async Task ExecuteAsync_ObservesCancellationViaRequestAborted() httpContext.RequestAborted = cts.Token; var firstEventReceived = new TaskCompletionSource(); var secondEventAttempted = new TaskCompletionSource(); + var cancellationObserved = new TaskCompletionSource(); var events = GetEvents(cts.Token); var result = TypedResults.ServerSentEvents(events); @@ -247,7 +248,8 @@ async IAsyncEnumerable GetEvents([EnumeratorCancellation] CancellationTo { yield return "event1"; firstEventReceived.SetResult(); - await Task.Delay(1, cancellationToken); + cancellationToken.Register(cancellationObserved.SetResult); + await cancellationObserved.Task; yield return "event2"; } finally From 664aa6d513ddaa2fb996d78e75126d12f2b60ca1 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 26 Feb 2025 09:59:33 -0800 Subject: [PATCH 4/5] Disable buffering and add byte[] handling --- .../src/ServerSentEventsResult.cs | 12 ++++ .../test/ServerSentEventsResultTests.cs | 72 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/Http/Http.Results/src/ServerSentEventsResult.cs b/src/Http/Http.Results/src/ServerSentEventsResult.cs index 2cdbed737ebc..448b2076daa6 100644 --- a/src/Http/Http.Results/src/ServerSentEventsResult.cs +++ b/src/Http/Http.Results/src/ServerSentEventsResult.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Options; using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Http.Features; namespace Microsoft.AspNetCore.Http.HttpResults; @@ -42,6 +43,10 @@ public async Task ExecuteAsync(HttpContext httpContext) httpContext.Response.ContentType = "text/event-stream"; httpContext.Response.Headers.CacheControl = "no-cache,no-store"; httpContext.Response.Headers.Pragma = "no-cache"; + httpContext.Response.Headers.ContentEncoding = "identity"; + + var bufferingFeature = httpContext.Features.GetRequiredFeature(); + bufferingFeature.DisableBuffering(); var jsonOptions = httpContext.RequestServices.GetService>()?.Value ?? new JsonOptions(); @@ -66,6 +71,13 @@ private static void FormatSseItem(SseItem item, IBufferWriter writer, J return; } + // Handle byte arrays byt writing them directly as strings. + if (item.Data is byte[] byteArray) + { + writer.Write(byteArray); + return; + } + // For non-string types, use JSON serialization with options from DI var runtimeType = item.Data.GetType(); var jsonTypeInfo = jsonOptions.SerializerOptions.GetTypeInfo(typeof(T)); diff --git a/src/Http/Http.Results/test/ServerSentEventsResultTests.cs b/src/Http/Http.Results/test/ServerSentEventsResultTests.cs index d764ab70ba36..542cece0fbd5 100644 --- a/src/Http/Http.Results/test/ServerSentEventsResultTests.cs +++ b/src/Http/Http.Results/test/ServerSentEventsResultTests.cs @@ -1,11 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Pipelines; using System.Net.ServerSentEvents; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; @@ -31,6 +33,7 @@ public async Task ExecuteAsync_SetsContentTypeAndHeaders() Assert.Equal("text/event-stream", httpContext.Response.ContentType); Assert.Equal("no-cache,no-store", httpContext.Response.Headers.CacheControl); Assert.Equal("no-cache", httpContext.Response.Headers.Pragma); + Assert.Equal("identity", httpContext.Response.Headers.ContentEncoding); } [Fact] @@ -259,6 +262,75 @@ async IAsyncEnumerable GetEvents([EnumeratorCancellation] CancellationTo } } + [Fact] + public async Task ExecuteAsync_DisablesBuffering() + { + // Arrange + var httpContext = GetHttpContext(); + var events = AsyncEnumerable.Empty(); + var result = TypedResults.ServerSentEvents(events); + var bufferingDisabled = false; + + var mockBufferingFeature = new MockHttpResponseBodyFeature( + onDisableBuffering: () => bufferingDisabled = true); + + httpContext.Features.Set(mockBufferingFeature); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.True(bufferingDisabled); + } + + [Fact] + public async Task ExecuteAsync_WithByteArrayData_WritesDataDirectly() + { + // Arrange + var httpContext = GetHttpContext(); + var bytes = "event1"u8.ToArray(); + var events = new[] { new SseItem(bytes) }.ToAsyncEnumerable(); + var result = TypedResults.ServerSentEvents(events); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains("data: event1\n\n", responseBody); + + // Assert that string is not JSON serialized + Assert.DoesNotContain("data: \"event1", responseBody); + } + + [Fact] + public async Task ExecuteAsync_WithByteArrayData_HandlesNullData() + { + // Arrange + var httpContext = GetHttpContext(); + var events = new[] { new SseItem(null) }.ToAsyncEnumerable(); + var result = TypedResults.ServerSentEvents(events); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray()); + Assert.Contains("data: \n\n", responseBody); + } + + private class MockHttpResponseBodyFeature(Action onDisableBuffering) : IHttpResponseBodyFeature + { + public Stream Stream => new MemoryStream(); + public PipeWriter Writer => throw new NotImplementedException(); + public Task CompleteAsync() => throw new NotImplementedException(); + public void DisableBuffering() => onDisableBuffering(); + public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task StartAsync(CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + } + private static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) where TResult : IEndpointMetadataProvider => TResult.PopulateMetadata(method, builder); From b0c76b88f080538aaebcb22c8ba20176e64c5219 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Sun, 2 Mar 2025 18:02:37 -0800 Subject: [PATCH 5/5] Rely on SSE in shared framework --- eng/SharedFramework.External.props | 1 - src/Framework/test/TestData.cs | 2 -- .../Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj | 1 - 3 files changed, 4 deletions(-) diff --git a/eng/SharedFramework.External.props b/eng/SharedFramework.External.props index a7626f0ba84f..8b5b19fb9861 100644 --- a/eng/SharedFramework.External.props +++ b/eng/SharedFramework.External.props @@ -42,7 +42,6 @@ - diff --git a/src/Framework/test/TestData.cs b/src/Framework/test/TestData.cs index 63bb214b497e..fe88b95fcdd6 100644 --- a/src/Framework/test/TestData.cs +++ b/src/Framework/test/TestData.cs @@ -155,7 +155,6 @@ static TestData() "Microsoft.Net.Http.Headers", "System.Diagnostics.EventLog", "System.Diagnostics.EventLog.Messages", - "System.Net.ServerSentEvents", "System.Security.Cryptography.Pkcs", "System.Security.Cryptography.Xml", "System.Threading.RateLimiting", @@ -306,7 +305,6 @@ static TestData() { "Microsoft.JSInterop" }, { "Microsoft.Net.Http.Headers" }, { "System.Diagnostics.EventLog" }, - { "System.Net.ServerSentEvents" }, { "System.Security.Cryptography.Xml" }, { "System.Threading.RateLimiting" }, }; diff --git a/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj b/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj index b7947cb3b78e..ea792b089048 100644 --- a/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj +++ b/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj @@ -28,7 +28,6 @@ -