Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions docs/documents/aspnetcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,117 @@ 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<Order>(orderId, stream);
```

## Typed Streaming Result Types <Badge type="tip" text="8.x" />

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<T>` | `IQueryable<T>` — regular Marten document query | Single `T` | yes |
| `StreamMany<T>` | `IQueryable<T>` — regular Marten document query | JSON array `T[]` | no (empty array = 200) |
| `StreamAggregate<T>` | `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<T>` — single document with 404 on miss

```csharp
app.MapGet("/issues/{id:guid}",
(Guid id, IQuerySession session) =>
new StreamOne<Issue>(session.Query<Issue>().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<T>`.

### `StreamMany<T>` — JSON array

```csharp
app.MapGet("/issues/open",
(IQuerySession session) =>
new StreamMany<Issue>(session.Query<Issue>().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<T>`.

### `StreamAggregate<T>` — event-sourced aggregate (latest)

```csharp
app.MapGet("/orders/{id:guid}",
(Guid id, IDocumentSession session) =>
new StreamAggregate<Order>(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<T>`** is for regular Marten documents — plain objects persisted
via `session.Store()` and queried with `session.Query<T>()`. The query hits
the document table directly.
- **`StreamAggregate<T>`** 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<Issue>(session.Query<Issue>().Where(x => x.Id == cmd.IssueId))
{
OnFoundStatus = StatusCodes.Status201Created,
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<Issue, Issue>
{
public Guid Id { get; set; }
public Expression<Func<IMartenQueryable<Issue>, Issue>> QueryIs()
=> q => q.FirstOrDefault(x => x.Id == Id);
}

public class OpenIssues : ICompiledListQuery<Issue>
{
public Expression<Func<IMartenQueryable<Issue>, IEnumerable<Issue>>> QueryIs()
=> q => q.Where(x => x.Open);
}

app.MapGet("/issues/{id:guid}",
(Guid id, IQuerySession session) =>
new StreamOne<Issue, Issue>(session, new IssueById { Id = id }));

app.MapGet("/issues/open",
(IQuerySession session) =>
new StreamMany<Issue, IEnumerable<Issue>>(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.
1 change: 1 addition & 0 deletions src/IssueService/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapStreamingMinimalEndpoints();
});
}
}
89 changes: 89 additions & 0 deletions src/IssueService/StreamingMinimalEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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;

/// <summary>
/// Minimal-API endpoint registrations that exercise the
/// <see cref="StreamOne{T}"/>, <see cref="StreamMany{T}"/>, and
/// <see cref="StreamAggregate{T}"/> helpers. Used by the Marten.AspNetCore.Testing
/// Alba tests to prove the helpers work on bare Minimal API (no Wolverine.Http
/// code generation required).
/// </summary>
public static class StreamingMinimalEndpoints
{
public static IEndpointRouteBuilder MapStreamingMinimalEndpoints(this IEndpointRouteBuilder app)
{
// --- StreamOne<T> ---

app.MapGet("/minimal/issue/{id:guid}",
(Guid id, IQuerySession session)
=> new StreamOne<Issue>(session.Query<Issue>().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<Issue>(session.Query<Issue>().Where(x => x.Id == id))
{
OnFoundStatus = StatusCodes.Status202Accepted
});

// Custom ContentType
app.MapGet("/minimal/issue/{id:guid}/vendor-type",
(Guid id, IQuerySession session)
=> new StreamOne<Issue>(session.Query<Issue>().Where(x => x.Id == id))
{
ContentType = "application/vnd.marten.issue+json"
});

// --- StreamMany<T> ---

app.MapGet("/minimal/issues/open",
(IQuerySession session)
=> new StreamMany<Issue>(session.Query<Issue>().Where(x => x.Open)));

// Known-empty result — exercises the "no 404, empty array" contract
app.MapGet("/minimal/issues/none",
(IQuerySession session)
=> new StreamMany<Issue>(session.Query<Issue>().Where(x => x.Id == Guid.Empty)));

// --- StreamAggregate<T> ---

app.MapGet("/minimal/order/{id:guid}",
(Guid id, IDocumentSession session)
=> new StreamAggregate<Order>(session, id));

app.MapGet("/minimal/named-order/{id}",
(string id, IDocumentSession session)
=> new StreamAggregate<NamedOrder>(session, id));

// --- StreamOne<TDoc, TOut> — compiled query ---

app.MapGet("/minimal/compiled/issue/{id:guid}",
(Guid id, IQuerySession session)
=> new StreamOne<Issue, Issue>(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<Issue, Issue>(session, new IssueById { Id = id })
{
OnFoundStatus = StatusCodes.Status202Accepted
});

// --- StreamMany<TDoc, TOut> — compiled list query ---

app.MapGet("/minimal/compiled/issues/open",
(IQuerySession session)
=> new StreamMany<Issue, System.Collections.Generic.IEnumerable<Issue>>(
session, new OpenIssues()));

return app;
}
}
Loading
Loading