diff --git a/src/Http/Http.Results/src/PublicAPI.Unshipped.txt b/src/Http/Http.Results/src/PublicAPI.Unshipped.txt index e33522ad2841..65f0c286aa46 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! 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 662fe0769cce..27c58d0eb22a 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,49 @@ 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 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 values, string? eventType = null) +#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters + => new ServerSentEventsResult(values, eventType); + + /// + /// Produces a response. + /// + /// The type of object that will be serialized to the 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 values, string? eventType = null) +#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters + => new ServerSentEventsResult(values, eventType); + + /// + /// Produces a response. + /// + /// The type of object that will be serialized to the 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 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 new file mode 100644 index 000000000000..448b2076daa6 --- /dev/null +++ b/src/Http/Http.Results/src/ServerSentEventsResult.cs @@ -0,0 +1,109 @@ +// 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 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; +using Microsoft.AspNetCore.Http.Features; + +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"; + 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(); + + // 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) + { + 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([]); + 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)); + + // 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.SerializeToUtf8Bytes(item.Data, typeInfo); + writer.Write(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..d3615c676de1 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,49 @@ 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 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 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 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 + => new(values, eventType); + + /// + /// Produces a response. + /// + /// The type of object that will be serialized to the 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); + /// /// 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..542cece0fbd5 --- /dev/null +++ b/src/Http/Http.Results/test/ServerSentEventsResultTests.cs @@ -0,0 +1,362 @@ +// 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; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Http.HttpResults; + +public class ServerSentEventsResultTests +{ + [Fact] + public async Task ExecuteAsync_SetsContentTypeAndHeaders() + { + // 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); + 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] + 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_WritesStringsEventsWithEventType() + { + // Arrange + var httpContext = GetHttpContext(); + var events = new[] { "event1", "event2" }.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\ndata: event1\n\n", responseBody); + Assert.Contains("event: test-event\ndata: event2\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(@"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 cancellationObserved = 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(); + cancellationToken.Register(cancellationObserved.SetResult); + await cancellationObserved.Task; + yield return "event2"; + } + finally + { + secondEventAttempted.SetResult(); + } + } + } + + [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); + + 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; } + } +}