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
47 changes: 42 additions & 5 deletions docs/documents/aspnetcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,44 @@ string-keyed streams.
- **`StreamAggregate<T>`** is for event-sourced aggregates — Polecat rebuilds (or reads the
snapshot of) the latest aggregate state from events before writing the response.

### ETag / Conditional Requests

`StreamOne<T>` and `StreamAggregate<T>` support HTTP conditional requests
(`ETag` / `If-None-Match` → `304 Not Modified`) so polling clients skip re-downloading
unchanged documents/aggregates. It is **on by default**.

- On a normal hit, an `ETag` response header carrying the version is emitted.
- An incoming `If-None-Match` that matches the current version yields `304 Not Modified`
with an empty body and the `ETag` header — for `StreamAggregate<T>` this skips the
aggregation entirely (the stream version is read cheaply first).
- The version source differs by type:
- **`StreamOne<T>`** uses the document's `version` column (read inline with the document
JSON in a single round trip — no follow-up metadata query).
- **`StreamAggregate<T>`** uses the event stream's version (via `FetchStreamStateAsync`,
read before folding).
- `StreamMany<T>` is intentionally out of scope (a cheap collection-wide ETag is hard to
derive).

Opt out per endpoint with `EmitETag = false`, which restores the exact pre-ETag behavior
(no header, no conditional handling):

```csharp
app.MapGet("/issues/{id:guid}",
(Guid id, IQuerySession session) =>
new StreamOne<Issue>(session.Query<Issue>().Where(x => x.Id == id))
{
EmitETag = false
});
```

::: tip
ETag values are opaque per RFC 7232. Polecat document tables always carry a `version`
column, so a document ETag is always available when `EmitETag` is on; the only way to
suppress it is `EmitETag = false`. `ETagHelpers` handles the `*` wildcard, comma-separated
`If-None-Match` lists, and `W/` weak validators (weak comparison, the correct function for
`If-None-Match`).
:::

### Customizing status code and content type

All three types expose `init`-only properties:
Expand All @@ -81,9 +119,8 @@ app.MapPost("/issues",
```

::: tip
Unlike Marten.AspNetCore, Polecat does not currently offer a deserialize-free raw-JSON
streaming path. The streaming helpers materialize documents via the regular query path and
serialize through `System.Text.Json`. This still eliminates the endpoint boilerplate
(null-check, status code, content type, OpenAPI metadata). A future enhancement will add
a true streaming path.
`StreamOne<T>` streams the raw persisted document JSON straight through (no
deserialize/reserialize). `StreamMany<T>` and `StreamAggregate<T>` materialize via the
regular query/projection path and serialize through `System.Text.Json`. All of them
eliminate the endpoint boilerplate (null-check, status code, content type, OpenAPI metadata).
:::
7 changes: 7 additions & 0 deletions src/Polecat.AspNetCore.Testing/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using Xunit;

// These are integration tests that spin up an Alba host against the single shared SQL Server
// test database and mutate the same document/aggregate tables (clean-all + seed). Running the
// test classes in parallel cross-contaminates that shared state (e.g. one class's CleanAllDocuments
// wiping another's seeded page). Disable assembly-level parallelization so the classes run serially.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
10 changes: 10 additions & 0 deletions src/Polecat.AspNetCore.Testing/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@
app.MapGet("/api/aggregates/{id:guid}", async (Guid id, IQuerySession session) =>
new StreamAggregate<StreamingQuestParty>(session, id));

// EmitETag = false variants — restore the pre-ETag behavior (no ETag header, no 304)
app.MapGet("/api/issues-noetag/{id:guid}", (Guid id, IQuerySession session) =>
new StreamOne<StreamingIssue>(session.Query<StreamingIssue>().Where(x => x.Id == id))
{
EmitETag = false
});

app.MapGet("/api/aggregates-noetag/{id:guid}", (Guid id, IQuerySession session) =>
new StreamAggregate<StreamingQuestParty>(session, id) { EmitETag = false });

app.Run();

namespace Polecat.AspNetCore.Testing
Expand Down
80 changes: 80 additions & 0 deletions src/Polecat.AspNetCore.Testing/etag_helpers_tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using Microsoft.AspNetCore.Http;
using Polecat.AspNetCore;
using Shouldly;
using Xunit;

namespace Polecat.AspNetCore.Testing;

/// <summary>
/// Pure-HTTP unit coverage for <see cref="ETagHelpers"/> (marten#5015 parity, polecat#356):
/// the <c>*</c> wildcard, <c>W/</c> weak validators, and multi-value comma lists.
/// </summary>
public class etag_helpers_tests
{
private static HttpContext ContextWith(params string[] ifNoneMatch)
{
var context = new DefaultHttpContext();
if (ifNoneMatch.Length > 0)
{
context.Request.Headers["If-None-Match"] = ifNoneMatch;
}

return context;
}

[Fact]
public void format_long_produces_quoted_value()
{
ETagHelpers.Format(42L).ShouldBe("\"42\"");
}

[Fact]
public void format_guid_produces_quoted_value()
{
var id = Guid.NewGuid();
ETagHelpers.Format(id).ShouldBe($"\"{id}\"");
}

[Fact]
public void no_header_does_not_match()
{
ETagHelpers.IfNoneMatchMatches(ContextWith(), "\"5\"").ShouldBeFalse();
}

[Fact]
public void exact_match()
{
ETagHelpers.IfNoneMatchMatches(ContextWith("\"5\""), "\"5\"").ShouldBeTrue();
}

[Fact]
public void mismatch_does_not_match()
{
ETagHelpers.IfNoneMatchMatches(ContextWith("\"6\""), "\"5\"").ShouldBeFalse();
}

[Fact]
public void wildcard_matches()
{
ETagHelpers.IfNoneMatchMatches(ContextWith("*"), "\"5\"").ShouldBeTrue();
}

[Fact]
public void weak_validator_matches_strong_etag()
{
// W/ weak-validator prefix is stripped before comparison (RFC 7232 §3.2).
ETagHelpers.IfNoneMatchMatches(ContextWith("W/\"5\""), "\"5\"").ShouldBeTrue();
}

[Fact]
public void multi_value_comma_list_matches_any()
{
ETagHelpers.IfNoneMatchMatches(ContextWith("\"1\", \"5\", \"9\""), "\"5\"").ShouldBeTrue();
}

[Fact]
public void multi_value_comma_list_no_match()
{
ETagHelpers.IfNoneMatchMatches(ContextWith("\"1\", \"2\", \"3\""), "\"5\"").ShouldBeFalse();
}
}
Loading
Loading