From 91c355bdbf62dfc5c3e1fc5a5c6b94b13fd165d2 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Fri, 17 Apr 2026 09:23:33 -0500 Subject: [PATCH 1/3] Add StreamOne/StreamMany/StreamAggregate typed streaming result types Marten.AspNetCore already has WriteSingle/WriteArray/WriteLatest extension helpers that stream raw JSON directly to an HttpResponse. These three new types wrap that behavior as typed endpoint return values for Minimal API (and Wolverine.Http, or any framework that dispatches IResult): StreamOne(IQueryable) -> WriteSingle, 200/404 StreamMany(IQueryable) -> WriteArray, always 200 (empty []) StreamAggregate(session, id) -> WriteLatest, 200/404 Each implements IResult so ASP.NET Minimal API dispatches it via ExecuteAsync, and IEndpointMetadataProvider so Swashbuckle, NSwag, and the built-in OpenAPI generator see the right response shape (Produces / Produces> / Produces(404)). OnFoundStatus and ContentType are init-only properties for customization. - Types live in Marten.AspNetCore namespace alongside QueryableExtensions - IssueService now also maps Minimal-API endpoints under /minimal/... for test coverage - New streaming_result_types_tests.cs asserts all three types (hit/miss, custom status, custom content type, OpenAPI metadata) via Alba Co-Authored-By: Claude Opus 4.6 --- docs/documents/aspnetcore.md | 79 ++++++ src/IssueService/Startup.cs | 1 + src/IssueService/StreamingMinimalEndpoints.cs | 68 +++++ .../streaming_result_types_tests.cs | 239 ++++++++++++++++++ src/Marten.AspNetCore/StreamAggregate.cs | 93 +++++++ src/Marten.AspNetCore/StreamMany.cs | 66 +++++ src/Marten.AspNetCore/StreamOne.cs | 74 ++++++ 7 files changed, 620 insertions(+) create mode 100644 src/IssueService/StreamingMinimalEndpoints.cs create mode 100644 src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs create mode 100644 src/Marten.AspNetCore/StreamAggregate.cs create mode 100644 src/Marten.AspNetCore/StreamMany.cs create mode 100644 src/Marten.AspNetCore/StreamOne.cs diff --git a/docs/documents/aspnetcore.md b/docs/documents/aspnetcore.md index e16e0a5aab..e4109fe180 100644 --- a/docs/documents/aspnetcore.md +++ b/docs/documents/aspnetcore.md @@ -231,3 +231,82 @@ writes the raw JSON to any `Stream`, which you can use to build your own respons var stream = new MemoryStream(); bool found = await session.Events.StreamLatestJson(orderId, stream); ``` + +## Typed Streaming Result Types + +For Minimal API endpoints (and for frameworks like [Wolverine.Http](https://wolverinefx.net/guide/http/) +that dispatch any `IResult` return value), `Marten.AspNetCore` ships three typed +result wrappers that carry the streaming behavior above as endpoint return values +while also contributing correct OpenAPI metadata: + +| Type | Source | Response shape | 404 on miss? | +| -------------------- | ------------------------------------------------ | ----------------- | ------------ | +| `StreamOne` | `IQueryable` — regular Marten document query | Single `T` | yes | +| `StreamMany` | `IQueryable` — regular Marten document query | JSON array `T[]` | no (empty array = 200) | +| `StreamAggregate` | `IDocumentSession` + stream id — event-sourced | Single `T` | yes | + +Each type implements both `IResult` (so ASP.NET Minimal API dispatches it via +`ExecuteAsync`) and `IEndpointMetadataProvider` (so Swashbuckle, NSwag, and the +built-in OpenAPI generator see the right response shape), while delegating the +actual body write to `WriteSingle`/`WriteArray`/`WriteLatest`. Returning one +from an endpoint is a concise, typed alternative to writing the HTTP handshake +manually. + +### `StreamOne` — single document with 404 on miss + +```csharp +app.MapGet("/issues/{id:guid}", + (Guid id, IQuerySession session) => + new StreamOne(session.Query().Where(x => x.Id == id))); +``` + +Returns `200 application/json` with the document JSON on a hit, `404` on a miss. +`Content-Length` and `Content-Type` are set automatically, matching the +behavior of `WriteSingle`. + +### `StreamMany` — JSON array + +```csharp +app.MapGet("/issues/open", + (IQuerySession session) => + new StreamMany(session.Query().Where(x => x.Open))); +``` + +Returns `200 application/json` with a JSON array body. An empty result set +yields `[]`, not a 404 — matching the behavior of `WriteArray`. + +### `StreamAggregate` — event-sourced aggregate (latest) + +```csharp +app.MapGet("/orders/{id:guid}", + (Guid id, IDocumentSession session) => + new StreamAggregate(session, id)); +``` + +Returns `200 application/json` with the JSON of the latest projected aggregate +state, or `404` if no stream exists. A constructor overload accepts `string` +ids for stores configured with string-keyed streams. + +### StreamOne vs StreamAggregate + +- **`StreamOne`** is for regular Marten documents — plain objects persisted + via `session.Store()` and queried with `session.Query()`. The query hits + the document table directly. +- **`StreamAggregate`** is for event-sourced aggregates. Marten rebuilds the + latest aggregate state by folding events from the event store (or reads a + projected snapshot if one is configured). Use this when `T` is an + event-sourced aggregate, not a stored document. + +### Customizing status code and content type + +All three types expose init-only properties: + +```csharp +app.MapPost("/issues", + (CreateIssue cmd, IQuerySession session) => + new StreamOne(session.Query().Where(x => x.Id == cmd.IssueId)) + { + OnFoundStatus = StatusCodes.Status201Created, + ContentType = "application/vnd.myapi.issue+json" + }); +``` diff --git a/src/IssueService/Startup.cs b/src/IssueService/Startup.cs index 053574aa47..068d2dba73 100644 --- a/src/IssueService/Startup.cs +++ b/src/IssueService/Startup.cs @@ -82,6 +82,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseEndpoints(endpoints => { endpoints.MapControllers(); + endpoints.MapStreamingMinimalEndpoints(); }); } } diff --git a/src/IssueService/StreamingMinimalEndpoints.cs b/src/IssueService/StreamingMinimalEndpoints.cs new file mode 100644 index 0000000000..771ae34f1e --- /dev/null +++ b/src/IssueService/StreamingMinimalEndpoints.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using IssueService.Controllers; +using Marten; +using Marten.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace IssueService; + +/// +/// Minimal-API endpoint registrations that exercise the +/// , , and +/// helpers. Used by the Marten.AspNetCore.Testing +/// Alba tests to prove the helpers work on bare Minimal API (no Wolverine.Http +/// code generation required). +/// +public static class StreamingMinimalEndpoints +{ + public static IEndpointRouteBuilder MapStreamingMinimalEndpoints(this IEndpointRouteBuilder app) + { + // --- StreamOne --- + + app.MapGet("/minimal/issue/{id:guid}", + (Guid id, IQuerySession session) + => new StreamOne(session.Query().Where(x => x.Id == id))); + + // Custom OnFoundStatus (e.g., 202 Accepted to exercise the init property) + app.MapGet("/minimal/issue/{id:guid}/accepted", + (Guid id, IQuerySession session) + => new StreamOne(session.Query().Where(x => x.Id == id)) + { + OnFoundStatus = StatusCodes.Status202Accepted + }); + + // Custom ContentType + app.MapGet("/minimal/issue/{id:guid}/vendor-type", + (Guid id, IQuerySession session) + => new StreamOne(session.Query().Where(x => x.Id == id)) + { + ContentType = "application/vnd.marten.issue+json" + }); + + // --- StreamMany --- + + app.MapGet("/minimal/issues/open", + (IQuerySession session) + => new StreamMany(session.Query().Where(x => x.Open))); + + // Known-empty result — exercises the "no 404, empty array" contract + app.MapGet("/minimal/issues/none", + (IQuerySession session) + => new StreamMany(session.Query().Where(x => x.Id == Guid.Empty))); + + // --- StreamAggregate --- + + app.MapGet("/minimal/order/{id:guid}", + (Guid id, IDocumentSession session) + => new StreamAggregate(session, id)); + + app.MapGet("/minimal/named-order/{id}", + (string id, IDocumentSession session) + => new StreamAggregate(session, id)); + + return app; + } +} diff --git a/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs b/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs new file mode 100644 index 0000000000..4c40568c24 --- /dev/null +++ b/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Alba; +using IssueService.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Marten.AspNetCore.Testing; + +/// +/// Alba-based tests for , , +/// and executing against plain Minimal API +/// endpoints (no Wolverine required). +/// +[Collection("integration")] +public class streaming_result_types_tests: IntegrationContext +{ + private readonly IAlbaHost theHost; + + public streaming_result_types_tests(AppFixture fixture) : base(fixture) + { + theHost = fixture.Host; + } + + // ───────────────────────── StreamOne ───────────────────────── + + [Fact] + public async Task stream_one_returns_matching_document_as_json() + { + var issue = new Issue { Description = "stream_one hit", Open = true }; + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Store(issue); + await session.SaveChangesAsync(); + } + + var result = await theHost.Scenario(s => + { + s.Get.Url($"/minimal/issue/{issue.Id}"); + s.StatusCodeShouldBe(200); + s.ContentTypeShouldBe("application/json"); + }); + + var read = result.ReadAsJson(); + read.Description.ShouldBe(issue.Description); + } + + [Fact] + public async Task stream_one_sets_content_length_on_hit() + { + var issue = new Issue { Description = "has-length", Open = false }; + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Store(issue); + await session.SaveChangesAsync(); + } + + var result = await theHost.Scenario(s => + { + s.Get.Url($"/minimal/issue/{issue.Id}"); + s.StatusCodeShouldBe(200); + }); + + // Marten.AspNetCore's WriteSingle buffers the document and sets Content-Length. + result.Context.Response.ContentLength.HasValue.ShouldBeTrue(); + } + + [Fact] + public async Task stream_one_returns_404_when_no_match() + { + await theHost.Scenario(s => + { + s.Get.Url($"/minimal/issue/{Guid.NewGuid()}"); + s.StatusCodeShouldBe(404); + }); + } + + [Fact] + public async Task stream_one_respects_custom_on_found_status() + { + var issue = new Issue { Description = "accepted", Open = true }; + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Store(issue); + await session.SaveChangesAsync(); + } + + await theHost.Scenario(s => + { + s.Get.Url($"/minimal/issue/{issue.Id}/accepted"); + s.StatusCodeShouldBe(202); + s.ContentTypeShouldBe("application/json"); + }); + } + + [Fact] + public async Task stream_one_respects_custom_content_type() + { + var issue = new Issue { Description = "vendor", Open = true }; + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Store(issue); + await session.SaveChangesAsync(); + } + + await theHost.Scenario(s => + { + s.Get.Url($"/minimal/issue/{issue.Id}/vendor-type"); + s.StatusCodeShouldBe(200); + s.ContentTypeShouldBe("application/vnd.marten.issue+json"); + }); + } + + // ───────────────────────── StreamMany ───────────────────────── + + [Fact] + public async Task stream_many_returns_json_array() + { + // Seed three open issues with a unique description prefix to assert against + var prefix = "many_" + Guid.NewGuid().ToString("N")[..8]; + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Store(new Issue { Description = prefix + "_a", Open = true }); + session.Store(new Issue { Description = prefix + "_b", Open = true }); + session.Store(new Issue { Description = prefix + "_c", Open = true }); + await session.SaveChangesAsync(); + } + + var result = await theHost.Scenario(s => + { + s.Get.Url("/minimal/issues/open"); + s.StatusCodeShouldBe(200); + s.ContentTypeShouldBe("application/json"); + }); + + var body = result.ReadAsJson>(); + body.Count(x => x.Description.StartsWith(prefix)).ShouldBe(3); + } + + [Fact] + public async Task stream_many_returns_empty_array_when_no_match_not_404() + { + var result = await theHost.Scenario(s => + { + s.Get.Url("/minimal/issues/none"); + s.StatusCodeShouldBe(200); + s.ContentTypeShouldBe("application/json"); + }); + + result.ReadAsText().Trim().ShouldBe("[]"); + } + + // ───────────────────── StreamAggregate ───────────────────── + + [Fact] + public async Task stream_aggregate_returns_latest_aggregate_as_json() + { + var orderId = Guid.NewGuid(); + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Events.StartStream(orderId, new OrderPlaced("Book", 19.99m)); + await session.SaveChangesAsync(); + } + + var result = await theHost.Scenario(s => + { + s.Get.Url($"/minimal/order/{orderId}"); + s.StatusCodeShouldBe(200); + s.ContentTypeShouldBe("application/json"); + }); + + var order = result.ReadAsJson(); + order.Id.ShouldBe(orderId); + order.Description.ShouldBe("Book"); + order.Amount.ShouldBe(19.99m); + } + + [Fact] + public async Task stream_aggregate_returns_404_for_unknown_id() + { + await theHost.Scenario(s => + { + s.Get.Url($"/minimal/order/{Guid.NewGuid()}"); + s.StatusCodeShouldBe(404); + }); + } + + // ───────────────────────── OpenAPI metadata ───────────────────────── + + [Fact] + public void stream_one_endpoint_advertises_produces_T_and_404_in_metadata() + { + var metadata = EndpointMetadataFor("GET", "/minimal/issue/{id:guid}"); + + metadata.OfType() + .ShouldContain(m => m.StatusCode == 200 && m.Type == typeof(Issue)); + metadata.OfType() + .ShouldContain(m => m.StatusCode == 404); + } + + [Fact] + public void stream_many_endpoint_advertises_produces_array_in_metadata() + { + var metadata = EndpointMetadataFor("GET", "/minimal/issues/open"); + + metadata.OfType() + .ShouldContain(m => m.StatusCode == 200 && m.Type == typeof(IReadOnlyList)); + } + + [Fact] + public void stream_aggregate_endpoint_advertises_produces_T_and_404_in_metadata() + { + var metadata = EndpointMetadataFor("GET", "/minimal/order/{id:guid}"); + + metadata.OfType() + .ShouldContain(m => m.StatusCode == 200 && m.Type == typeof(Order)); + metadata.OfType() + .ShouldContain(m => m.StatusCode == 404); + } + + private EndpointMetadataCollection EndpointMetadataFor(string method, string pattern) + { + var endpoint = theHost.Services.GetServices() + .SelectMany(x => x.Endpoints) + .OfType() + .FirstOrDefault(x => + x.RoutePattern.RawText == pattern && + x.Metadata.GetMetadata()!.HttpMethods.Contains(method)); + + endpoint.ShouldNotBeNull($"No endpoint found for {method} {pattern}"); + return endpoint.Metadata; + } +} diff --git a/src/Marten.AspNetCore/StreamAggregate.cs b/src/Marten.AspNetCore/StreamAggregate.cs new file mode 100644 index 0000000000..60f816b8d7 --- /dev/null +++ b/src/Marten.AspNetCore/StreamAggregate.cs @@ -0,0 +1,93 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; + +namespace Marten.AspNetCore; + +/// +/// Minimal-API / Wolverine.Http endpoint return value that streams the latest projected +/// JSON of an event-sourced aggregate directly to the . +/// Uses +/// under the hood, which pulls the latest aggregate state from the event store without a +/// deserialize/serialize round-trip. +/// +/// Returns HTTP 404 if no aggregate exists for the supplied id, +/// (default 200) if it does. +/// +/// +/// StreamAggregate vs StreamOne. Use +/// for event-sourced aggregates — Marten rebuilds (or reads the snapshot of) the +/// latest aggregate state from events before streaming. Use +/// for regular Marten documents that are stored directly (not event-sourced). +/// +/// +/// The aggregate type. +public sealed class StreamAggregate : IResult, IEndpointMetadataProvider where T : class +{ + private readonly IDocumentSession _session; + private readonly Guid _guidId; + private readonly string? _stringId; + private readonly bool _useGuid; + + /// + /// Stream the latest aggregate state of type for + /// the aggregate whose stream is identified by . + /// + public StreamAggregate(IDocumentSession session, Guid id) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _guidId = id; + _stringId = null; + _useGuid = true; + } + + /// + /// Stream the latest aggregate state of type for + /// the aggregate whose stream is identified by string + /// (for Marten stores configured with string-keyed streams). + /// + public StreamAggregate(IDocumentSession session, string id) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _stringId = id ?? throw new ArgumentNullException(nameof(id)); + _guidId = Guid.Empty; + _useGuid = false; + } + + /// + /// Status code written when the aggregate is found. Defaults to 200. + /// + public int OnFoundStatus { get; init; } = StatusCodes.Status200OK; + + /// + /// Response content type. Defaults to application/json. + /// + public string ContentType { get; init; } = "application/json"; + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + + return _useGuid + ? _session.Events.WriteLatest(_guidId, httpContext, ContentType, OnFoundStatus) + : _session.Events.WriteLatest(_stringId!, httpContext, ContentType, OnFoundStatus); + } + + /// + /// Populates endpoint metadata so OpenAPI correctly advertises a + /// 200: T and 404 response for this endpoint. + /// + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Metadata.Add(new ProducesResponseTypeMetadata( + StatusCodes.Status200OK, typeof(T), new[] { "application/json" })); + builder.Metadata.Add(new ProducesResponseTypeMetadata( + StatusCodes.Status404NotFound, typeof(void), Array.Empty())); + } +} diff --git a/src/Marten.AspNetCore/StreamMany.cs b/src/Marten.AspNetCore/StreamMany.cs new file mode 100644 index 0000000000..731a72743a --- /dev/null +++ b/src/Marten.AspNetCore/StreamMany.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; + +namespace Marten.AspNetCore; + +/// +/// Minimal-API / Wolverine.Http endpoint return value that streams a JSON array of +/// Marten documents directly to the . Uses +/// under the hood — the JSON array is +/// written straight to the response stream without a deserialize/serialize round-trip. +/// +/// Unlike , this type never returns 404: an empty result +/// set yields an empty JSON array ([]) with status +/// (default 200). +/// +/// +/// The document type contained in the array. +public sealed class StreamMany : IResult, IEndpointMetadataProvider +{ + private readonly IQueryable _queryable; + + /// + /// Create a wrapping a Marten . + /// All matching documents are streamed as a JSON array. + /// + public StreamMany(IQueryable queryable) + { + _queryable = queryable ?? throw new ArgumentNullException(nameof(queryable)); + } + + /// + /// Status code written with the response. Defaults to 200. + /// + public int OnFoundStatus { get; init; } = StatusCodes.Status200OK; + + /// + /// Response content type. Defaults to application/json. + /// + public string ContentType { get; init; } = "application/json"; + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + return _queryable.WriteArray(httpContext, ContentType, OnFoundStatus); + } + + /// + /// Populates endpoint metadata so OpenAPI correctly advertises a + /// 200: T[] response for this endpoint. No 404 is advertised + /// because an empty array is a valid response. + /// + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Metadata.Add(new ProducesResponseTypeMetadata( + StatusCodes.Status200OK, typeof(IReadOnlyList), new[] { "application/json" })); + } +} diff --git a/src/Marten.AspNetCore/StreamOne.cs b/src/Marten.AspNetCore/StreamOne.cs new file mode 100644 index 0000000000..4d1228a5d2 --- /dev/null +++ b/src/Marten.AspNetCore/StreamOne.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; + +namespace Marten.AspNetCore; + +/// +/// Minimal-API / Wolverine.Http endpoint return value that streams the first matching +/// Marten document as raw JSON directly to the . +/// Uses under the hood — the JSON is +/// written straight to the response stream without a deserialize/serialize round-trip. +/// +/// Returns HTTP 404 if the query produces no result, +/// (default 200) if it does. and +/// are set automatically. +/// +/// +/// StreamOne vs StreamAggregate. Use for regular +/// Marten documents — plain objects persisted via session.Store() and queried +/// with session.Query<T>(). Use when +/// the target is an event-sourced aggregate projected live from the event stream +/// (the "latest" aggregate snapshot). +/// +/// +/// The document type to stream. +public sealed class StreamOne : IResult, IEndpointMetadataProvider +{ + private readonly IQueryable _queryable; + + /// + /// Create a wrapping a Marten . + /// The query's first matching document is streamed as JSON; 404 if none. + /// + public StreamOne(IQueryable queryable) + { + _queryable = queryable ?? throw new ArgumentNullException(nameof(queryable)); + } + + /// + /// Status code written when the query produces a result. Defaults to 200. + /// Use 201 (Created) on a POST that returns a freshly-created document, etc. + /// + public int OnFoundStatus { get; init; } = StatusCodes.Status200OK; + + /// + /// Response content type. Defaults to application/json. + /// + public string ContentType { get; init; } = "application/json"; + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + return _queryable.WriteSingle(httpContext, ContentType, OnFoundStatus); + } + + /// + /// Populates endpoint metadata so OpenAPI correctly advertises a + /// 200: T and 404 response for this endpoint. + /// + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Metadata.Add(new ProducesResponseTypeMetadata( + StatusCodes.Status200OK, typeof(T), new[] { "application/json" })); + builder.Metadata.Add(new ProducesResponseTypeMetadata( + StatusCodes.Status404NotFound, typeof(void), Array.Empty())); + } +} From 1191c59375331f19aa1c778ac0b3ca2ebdb48f38 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Fri, 17 Apr 2026 10:20:28 -0500 Subject: [PATCH 2/3] Fix markdown table column alignment in aspnetcore.md The "404 on miss?" column's pipes did not line up with the header row because the StreamMany row's value "no (empty array = 200)" was longer than the header. Pad header/separator/other rows to match for MD060. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/documents/aspnetcore.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/documents/aspnetcore.md b/docs/documents/aspnetcore.md index e4109fe180..91c85a26d7 100644 --- a/docs/documents/aspnetcore.md +++ b/docs/documents/aspnetcore.md @@ -239,11 +239,11 @@ that dispatch any `IResult` return value), `Marten.AspNetCore` ships three typed result wrappers that carry the streaming behavior above as endpoint return values while also contributing correct OpenAPI metadata: -| Type | Source | Response shape | 404 on miss? | -| -------------------- | ------------------------------------------------ | ----------------- | ------------ | -| `StreamOne` | `IQueryable` — regular Marten document query | Single `T` | yes | +| Type | Source | Response shape | 404 on miss? | +| -------------------- | ------------------------------------------------ | ----------------- | ---------------------- | +| `StreamOne` | `IQueryable` — regular Marten document query | Single `T` | yes | | `StreamMany` | `IQueryable` — regular Marten document query | JSON array `T[]` | no (empty array = 200) | -| `StreamAggregate` | `IDocumentSession` + stream id — event-sourced | Single `T` | yes | +| `StreamAggregate` | `IDocumentSession` + stream id — event-sourced | Single `T` | yes | Each type implements both `IResult` (so ASP.NET Minimal API dispatches it via `ExecuteAsync`) and `IEndpointMetadataProvider` (so Swashbuckle, NSwag, and the From 9f63d1b07bfb4a3b6ed5d7af4f2cb500a88b6f51 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Fri, 17 Apr 2026 10:41:18 -0500 Subject: [PATCH 3/3] Add compiled-query overloads for StreamOne and StreamMany Adds two-arity generic variants of the streaming IResult types that wrap Marten's ICompiledQuery: StreamOne(IQuerySession, ICompiledQuery) -> WriteOne, 200/404 StreamMany(IQuerySession, ICompiledQuery) -> WriteArray, always 200 These sit alongside the existing single-arity IQueryable-based types (StreamOne, StreamMany). Each implements IResult and IEndpointMetadataProvider, advertising 200: TOut (and 404 for StreamOne) in OpenAPI metadata. - Two new Marten.AspNetCore types (StreamOneCompiled.cs, StreamManyCompiled.cs) - IssueService minimal endpoints exercise IssueById and OpenIssues compiled queries - Six new Alba tests: hit, 404, custom OnFoundStatus, metadata shape, and JSON array body for the list overload - Docs updated with a compiled-query example section Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/documents/aspnetcore.md | 35 +++++++ src/IssueService/StreamingMinimalEndpoints.cs | 21 ++++ .../streaming_result_types_tests.cs | 96 +++++++++++++++++++ src/Marten.AspNetCore/StreamManyCompiled.cs | 79 +++++++++++++++ src/Marten.AspNetCore/StreamOneCompiled.cs | 75 +++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 src/Marten.AspNetCore/StreamManyCompiled.cs create mode 100644 src/Marten.AspNetCore/StreamOneCompiled.cs diff --git a/docs/documents/aspnetcore.md b/docs/documents/aspnetcore.md index 91c85a26d7..b54b242991 100644 --- a/docs/documents/aspnetcore.md +++ b/docs/documents/aspnetcore.md @@ -310,3 +310,38 @@ app.MapPost("/issues", ContentType = "application/vnd.myapi.issue+json" }); ``` + +### Compiled query overloads + +`StreamOne` and `StreamMany` also accept Marten compiled queries. These overloads +take an extra generic argument for the query result type and the `IQuerySession` +alongside the compiled query: + +```csharp +public class IssueById : ICompiledQuery +{ + public Guid Id { get; set; } + public Expression, Issue>> QueryIs() + => q => q.FirstOrDefault(x => x.Id == Id); +} + +public class OpenIssues : ICompiledListQuery +{ + public Expression, IEnumerable>> QueryIs() + => q => q.Where(x => x.Open); +} + +app.MapGet("/issues/{id:guid}", + (Guid id, IQuerySession session) => + new StreamOne(session, new IssueById { Id = id })); + +app.MapGet("/issues/open", + (IQuerySession session) => + new StreamMany>(session, new OpenIssues())); +``` + +These use `WriteOne` / `WriteArray` for compiled queries under the hood. OpenAPI +metadata advertises `200: TOut` (and `404` for `StreamOne`), where `TOut` is the +compiled query's declared return type. Prefer compiled queries when the endpoint +is on a hot path — Marten caches the compiled SQL and bypasses LINQ parsing on +subsequent calls. diff --git a/src/IssueService/StreamingMinimalEndpoints.cs b/src/IssueService/StreamingMinimalEndpoints.cs index 771ae34f1e..162348bb8f 100644 --- a/src/IssueService/StreamingMinimalEndpoints.cs +++ b/src/IssueService/StreamingMinimalEndpoints.cs @@ -63,6 +63,27 @@ public static IEndpointRouteBuilder MapStreamingMinimalEndpoints(this IEndpointR (string id, IDocumentSession session) => new StreamAggregate(session, id)); + // --- StreamOne — compiled query --- + + app.MapGet("/minimal/compiled/issue/{id:guid}", + (Guid id, IQuerySession session) + => new StreamOne(session, new IssueById { Id = id })); + + // Custom OnFoundStatus for the compiled single overload + app.MapGet("/minimal/compiled/issue/{id:guid}/accepted", + (Guid id, IQuerySession session) + => new StreamOne(session, new IssueById { Id = id }) + { + OnFoundStatus = StatusCodes.Status202Accepted + }); + + // --- StreamMany — compiled list query --- + + app.MapGet("/minimal/compiled/issues/open", + (IQuerySession session) + => new StreamMany>( + session, new OpenIssues())); + return app; } } diff --git a/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs b/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs index 4c40568c24..6fc92a4061 100644 --- a/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs +++ b/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs @@ -224,6 +224,102 @@ public void stream_aggregate_endpoint_advertises_produces_T_and_404_in_metadata( .ShouldContain(m => m.StatusCode == 404); } + // ───────────────── StreamOne compiled query ───────────────── + + [Fact] + public async Task compiled_stream_one_returns_matching_document_as_json() + { + var issue = new Issue { Description = "compiled stream_one hit", Open = true }; + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Store(issue); + await session.SaveChangesAsync(); + } + + var result = await theHost.Scenario(s => + { + s.Get.Url($"/minimal/compiled/issue/{issue.Id}"); + s.StatusCodeShouldBe(200); + s.ContentTypeShouldBe("application/json"); + }); + + var read = result.ReadAsJson(); + read.Description.ShouldBe(issue.Description); + } + + [Fact] + public async Task compiled_stream_one_returns_404_when_no_match() + { + await theHost.Scenario(s => + { + s.Get.Url($"/minimal/compiled/issue/{Guid.NewGuid()}"); + s.StatusCodeShouldBe(404); + }); + } + + [Fact] + public async Task compiled_stream_one_honours_custom_onfound_status() + { + var issue = new Issue { Description = "compiled custom-status", Open = true }; + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Store(issue); + await session.SaveChangesAsync(); + } + + await theHost.Scenario(s => + { + s.Get.Url($"/minimal/compiled/issue/{issue.Id}/accepted"); + s.StatusCodeShouldBe(202); + }); + } + + [Fact] + public void compiled_stream_one_endpoint_advertises_produces_T_and_404_in_metadata() + { + var metadata = EndpointMetadataFor("GET", "/minimal/compiled/issue/{id:guid}"); + + metadata.OfType() + .ShouldContain(m => m.StatusCode == 200 && m.Type == typeof(Issue)); + metadata.OfType() + .ShouldContain(m => m.StatusCode == 404); + } + + // ──────────────── StreamMany compiled list query ──────────────── + + [Fact] + public async Task compiled_stream_many_returns_json_array() + { + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Store(new Issue { Description = "compiled-open-1", Open = true }); + session.Store(new Issue { Description = "compiled-open-2", Open = true }); + session.Store(new Issue { Description = "compiled-closed", Open = false }); + await session.SaveChangesAsync(); + } + + var result = await theHost.Scenario(s => + { + s.Get.Url("/minimal/compiled/issues/open"); + s.StatusCodeShouldBe(200); + s.ContentTypeShouldBe("application/json"); + }); + + var read = result.ReadAsJson>(); + read.ShouldNotBeNull(); + read.ShouldAllBe(x => x.Open); + } + + [Fact] + public void compiled_stream_many_endpoint_advertises_produces_enumerable_in_metadata() + { + var metadata = EndpointMetadataFor("GET", "/minimal/compiled/issues/open"); + + // TOut is IEnumerable for OpenIssues : ICompiledListQuery + metadata.OfType() + .ShouldContain(m => m.StatusCode == 200 && m.Type == typeof(IEnumerable)); + } + private EndpointMetadataCollection EndpointMetadataFor(string method, string pattern) { var endpoint = theHost.Services.GetServices() diff --git a/src/Marten.AspNetCore/StreamManyCompiled.cs b/src/Marten.AspNetCore/StreamManyCompiled.cs new file mode 100644 index 0000000000..9813152958 --- /dev/null +++ b/src/Marten.AspNetCore/StreamManyCompiled.cs @@ -0,0 +1,79 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Marten.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; + +namespace Marten.AspNetCore; + +/// +/// Minimal-API / Wolverine.Http endpoint return value that streams the JSON array +/// produced by a Marten (typically an +/// ) directly to the +/// . Uses +/// under the hood — the JSON +/// array is written straight to the response stream without a deserialize/serialize +/// round-trip. +/// +/// Unlike , this type never returns 404: an empty +/// result set yields an empty JSON array ([]) with status +/// (default 200). +/// +/// +/// This is the overload. Use +/// (the single-arity version) for regular +/// -based queries. +/// +/// +/// The Marten document type the query runs against. +/// +/// The projected return type of the compiled query. Typically +/// IEnumerable<TItem> — e.g. when using +/// . +/// +public sealed class StreamMany : IResult, IEndpointMetadataProvider where TDoc : notnull +{ + private readonly IQuerySession _session; + private readonly ICompiledQuery _query; + + /// + /// Create a wrapping a compiled query. + /// All matching results are streamed as a JSON array. + /// + public StreamMany(IQuerySession session, ICompiledQuery query) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _query = query ?? throw new ArgumentNullException(nameof(query)); + } + + /// + /// Status code written with the response. Defaults to 200. + /// + public int OnFoundStatus { get; init; } = StatusCodes.Status200OK; + + /// + /// Response content type. Defaults to application/json. + /// + public string ContentType { get; init; } = "application/json"; + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + return _session.WriteArray(_query, httpContext, ContentType, OnFoundStatus); + } + + /// + /// Populates endpoint metadata so OpenAPI advertises a 200: TOut response + /// for this endpoint. No 404 is advertised because an empty array is a valid response. + /// + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Metadata.Add(new ProducesResponseTypeMetadata( + StatusCodes.Status200OK, typeof(TOut), new[] { "application/json" })); + } +} diff --git a/src/Marten.AspNetCore/StreamOneCompiled.cs b/src/Marten.AspNetCore/StreamOneCompiled.cs new file mode 100644 index 0000000000..3c42ba7458 --- /dev/null +++ b/src/Marten.AspNetCore/StreamOneCompiled.cs @@ -0,0 +1,75 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Marten.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; + +namespace Marten.AspNetCore; + +/// +/// Minimal-API / Wolverine.Http endpoint return value that streams the first matching +/// result of a Marten as raw JSON directly to +/// the . Uses +/// under the hood — the JSON is +/// written straight to the response stream without a deserialize/serialize round-trip. +/// +/// Returns HTTP 404 if the query produces no result, +/// (default 200) if it does. and +/// are set automatically. +/// +/// +/// This is the overload. Use +/// (the single-arity version) for regular +/// -based queries. +/// +/// +/// The Marten document type the query runs against. +/// The projected return type of the compiled query. +public sealed class StreamOne : IResult, IEndpointMetadataProvider where TDoc : notnull +{ + private readonly IQuerySession _session; + private readonly ICompiledQuery _query; + + /// + /// Create a wrapping a compiled query. + /// The query's single result is streamed as JSON; 404 if none. + /// + public StreamOne(IQuerySession session, ICompiledQuery query) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _query = query ?? throw new ArgumentNullException(nameof(query)); + } + + /// + /// Status code written when the query produces a result. Defaults to 200. + /// + public int OnFoundStatus { get; init; } = StatusCodes.Status200OK; + + /// + /// Response content type. Defaults to application/json. + /// + public string ContentType { get; init; } = "application/json"; + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + return _session.WriteOne(_query, httpContext, ContentType, OnFoundStatus); + } + + /// + /// Populates endpoint metadata so OpenAPI correctly advertises a + /// 200: TOut and 404 response for this endpoint. + /// + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Metadata.Add(new ProducesResponseTypeMetadata( + StatusCodes.Status200OK, typeof(TOut), new[] { "application/json" })); + builder.Metadata.Add(new ProducesResponseTypeMetadata( + StatusCodes.Status404NotFound, typeof(void), Array.Empty())); + } +}