Skip to content
12 changes: 10 additions & 2 deletions docs/guide/http/deduplication.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,8 +337,16 @@ releasing it with a rolled-back transaction.
- `Durability.EnableDeduplicatedResponses`, and the PostgreSQL, SQL Server, MySQL or SQLite message store
(including Marten's). With a database per tenant, claims are kept in the main database. Without them
the host logs a warning at startup and the endpoint throws at its first request.
- The endpoint must return a resource, written as JSON by System.Text.Json; any other response writer is
refused when the endpoint is first compiled.
- The endpoint must return a resource, written as JSON by System.Text.Json, or an `IResult` (below); any
other response writer is refused when the endpoint is first compiled.
- An endpoint can also return an ASP.NET Core `IResult`. `Ok`, `Created`, `Accepted` (each with or without
a value), `NoContent`, `StatusCodeHttpResult` and `Results.Empty` are replayed with their status, JSON
value and `Location`, the value serialized as ASP.NET Core writes it. A `Results<...>` union is replayed
when the arm it answers with is one of these. An arm with a status of 400 or above, or a challenge or
forbid result, gives the key back as any failure does. Any other result, such as text, files, redirects,
`JsonHttpResult` or `CreatedAtRoute`, is not stored: the key is kept unanswered, so repeats get 409 until
it expires, and an error is logged. An endpoint whose declared return type can never be one of the
replayed results is refused at startup.
- The request body is buffered to compute the fingerprint, and the resource is serialized a second
time to store it.
- Only the status, body and `Location` are replayed; other response headers are not.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Marten.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
Expand Down Expand Up @@ -405,6 +406,36 @@ public async Task a_failure_answer_claims_nothing_and_its_retry_runs()
MartenDeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(2);
}

[Fact]
public async Task a_result_union_is_claimed_inside_the_commit_and_replayed()
{
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder("result-" + key);

var first = await postAsync("/marten-deduplicated-response/result", request, key, 201);
var repeat = await postAsync("/marten-deduplicated-response/result", request, key, 201);

(await repeat.ReadAsTextAsync()).ShouldBe(await first.ReadAsTextAsync());
repeat.Context.Response.Headers.Location.ToString()
.ShouldBe(first.Context.Response.Headers.Location.ToString());
MartenDeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);

sourceFor("/marten-deduplicated-response/result").ShouldNotContain(".TryClaimAsync(");
}

[Fact]
public async Task the_failure_arm_of_a_result_union_claims_nothing_and_its_retry_runs()
{
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder(MartenDeduplicatedResponseEndpoints.Absent + key);

await postAsync("/marten-deduplicated-response/result", request, key, 404);
(await findClaimAsync(key)).ShouldBeNull();

await postAsync("/marten-deduplicated-response/result", request, key, 404);
MartenDeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(2);
}

[Fact]
public async Task a_request_without_an_optional_key_runs_and_claims_nothing()
{
Expand Down Expand Up @@ -925,6 +956,23 @@ public static DeduplicatedOrderCreated PostOrderByDefault(DeduplicatedOrder requ
return null;
}

public const string Absent = "absent-";

[DeduplicatedWithResponse(DeduplicationScope.User, ClaimInTransaction = true)]
[WolverinePost("/marten-deduplicated-response/result")]
public static Results<Created<DeduplicatedOrderCreated>, NotFound> PostResult(DeduplicatedOrder request,
IDocumentSession session)
{
if (request.Name.StartsWith(Absent))
{
_runs.AddOrUpdate(request.Name, 1, (_, count) => count + 1);
return TypedResults.NotFound();
}

var created = PostOrder(request, session);
return TypedResults.Created($"/marten-deduplicated-response/orders/{created.Id}", created);
}

[DeduplicatedWithResponse(DeduplicationScope.User, ClaimInTransaction = true, Required = false)]
[WolverinePost("/marten-deduplicated-response/optional-key")]
public static DeduplicatedOrderCreated PostOptionalKey(DeduplicatedOrder request, IDocumentSession session)
Expand Down
232 changes: 231 additions & 1 deletion src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@
using Alba;
using IntegrationTests;
using JasperFx.CodeGeneration;
using JasperFx.CodeGeneration.Frames;
using JasperFx.Core;
using JasperFx.Core.Reflection;
using JasperFx.Resources;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
using Shouldly;
using Wolverine.Http.Resources;
using Wolverine.Http.Runtime;
using Wolverine.Postgresql;
using Wolverine.Tracking;
Expand Down Expand Up @@ -54,10 +57,13 @@ public async ValueTask InitializeAsync()

// No tenant id detection, deliberately -- see a_tenant_scope_needs_tenant_detection.
theHost = await AlbaHost.For(builder, app => app.MapWolverineEndpoints(opts =>
{
opts.AddResourceWriterPolicy<CustomAnswerWriterPolicy>();
opts.CustomizeHttpEndpointDiscovery(q =>
q.Excludes.WithCondition("Not a [DeduplicatedWithResponse] test endpoint",
type => type != typeof(DeduplicatedResponseEndpoints) && type != typeof(EarlyExitDeduplicatedResponseEndpoint)
&& type != typeof(MissingResourceDeduplicatedEndpoint)))));
&& type != typeof(MissingResourceDeduplicatedEndpoint)));
}));

await ((IHost)theHost).ResetResourceState();

Expand Down Expand Up @@ -337,6 +343,139 @@ public void a_response_that_is_not_system_text_json_is_refused()
ex.Message.ShouldContain("System.Text.Json");
}

[Fact]
public void a_result_a_custom_writer_policy_writes_is_refused()
{
var ex = Should.Throw<NotSupportedException>(() => compile("/deduplicated-response/custom-written-result"));

ex.Message.ShouldContain("System.Text.Json");
}

[Fact]
public async Task a_created_result_is_replayed_with_its_status_body_and_location()
{
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder("created-result");

var first = await postAsync(request, key, 201, url: "/deduplicated-response/created-result");
var repeat = await postAsync(request, key, 201, url: "/deduplicated-response/created-result");

var body = await first.ReadAsTextAsync();
body.ShouldNotBeNullOrEmpty();
(await repeat.ReadAsTextAsync()).ShouldBe(body);

var location = first.Context.Response.Headers.Location.ToString();
location.ShouldStartWith("/deduplicated-response/orders/");
repeat.Context.Response.Headers.Location.ToString().ShouldBe(location);

DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
}

[Fact]
public async Task an_ok_result_is_replayed_as_its_value_was_written()
{
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder("ok-result");

var first = await postAsync(request, key, 200, url: "/deduplicated-response/ok-result");
var repeat = await postAsync(request, key, 200, url: "/deduplicated-response/ok-result");

var body = await first.ReadAsTextAsync();
body.ShouldContain("\"detail\"");
(await repeat.ReadAsTextAsync()).ShouldBe(body);
repeat.Context.Response.ContentType.ShouldBe(first.Context.Response.ContentType);

DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
}

[Fact]
public async Task a_result_declared_as_object_is_replayed_as_the_json_it_was_written_as()
{
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder("object-result");

var first = await postAsync(request, key, 200, url: "/deduplicated-response/object-result");
var repeat = await postAsync(request, key, 200, url: "/deduplicated-response/object-result");

var body = await first.ReadAsTextAsync();
body.ShouldContain("\"location\"");
(await repeat.ReadAsTextAsync()).ShouldBe(body);

DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
}

[Fact]
public async Task a_no_content_result_is_replayed_with_no_body()
{
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder("no-content-result");

await postAsync(request, key, 204, url: "/deduplicated-response/no-content-result");
var repeat = await postAsync(request, key, 204, url: "/deduplicated-response/no-content-result");

(await repeat.ReadAsTextAsync()).ShouldBeEmpty();
DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
}

[Fact]
public async Task an_accepted_result_is_replayed_with_its_location()
{
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder("accepted-result");

var first = await postAsync(request, key, 202, url: "/deduplicated-response/accepted-result");
var repeat = await postAsync(request, key, 202, url: "/deduplicated-response/accepted-result");

(await repeat.ReadAsTextAsync()).ShouldBe(await first.ReadAsTextAsync());
var location = first.Context.Response.Headers.Location.ToString();
location.ShouldStartWith("/deduplicated-response/orders/");
repeat.Context.Response.Headers.Location.ToString().ShouldBe(location);

DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
}

[Fact]
public async Task the_success_arm_of_a_result_union_is_replayed()
{
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder("union-result");

var first = await postAsync(request, key, 201, url: "/deduplicated-response/union-result");
var repeat = await postAsync(request, key, 201, url: "/deduplicated-response/union-result");

(await repeat.ReadAsTextAsync()).ShouldBe(await first.ReadAsTextAsync());
repeat.Context.Response.Headers.Location.ToString()
.ShouldBe(first.Context.Response.Headers.Location.ToString());

DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
}

[Fact]
public async Task the_failure_arm_of_a_result_union_releases_the_claim()
{
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder(DeduplicatedResponseEndpoints.Absent);

await postAsync(request, key, 404, url: "/deduplicated-response/union-result");
await postAsync(request, key, 404, url: "/deduplicated-response/union-result");

DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(2);
}

[Fact]
public async Task a_result_that_cannot_be_stored_keeps_the_claim_and_refuses_repeats()
{
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder("text-result");

var first = await postAsync(request, key, 200, url: "/deduplicated-response/text-result");
(await first.ReadAsTextAsync()).ShouldBe(request.Name);

await postAsync(request, key, 409, url: "/deduplicated-response/text-result");

DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
}

[Fact]
public async Task a_replay_does_not_re_send_the_first_requests_cascaded_messages()
{
Expand Down Expand Up @@ -492,6 +631,7 @@ public static class DeduplicatedResponseEndpoints
public const string Throw = "throw";
public const string Missing = "missing";
public const string Slow = "slow";
public const string Absent = "absent";

private static readonly Dictionary<string, int> _runs = new();
private static TaskCompletionSource _slowArrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
Expand Down Expand Up @@ -573,8 +713,98 @@ public static ProblemDetails Validate(DeduplicatedOrder request)
[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/text")]
public static string PostText(DeduplicatedOrder request) => request.Name;

[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/created-result")]
public static Created<DeduplicatedOrderCreated> PostCreatedResult(DeduplicatedOrder request)
{
var created = run(request);
return TypedResults.Created($"/deduplicated-response/orders/{created.Id}", created);
}

[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/ok-result")]
public static Ok<DeduplicatedAnswer> PostOkResult(DeduplicatedOrder request)
{
Count(request.Name);
return TypedResults.Ok<DeduplicatedAnswer>(new DetailedDeduplicatedAnswer(request.Name, Guid.NewGuid().ToString()));
}

[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/custom-written-result")]
public static Ok<CustomWrittenAnswer> PostCustomWrittenResult(DeduplicatedOrder request)
=> TypedResults.Ok(new CustomWrittenAnswer(request.Name));

[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/object-result")]
public static object PostObjectResult(DeduplicatedOrder request)
{
var created = run(request);
return TypedResults.Created($"/deduplicated-response/orders/{created.Id}", created);
}

[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/no-content-result")]
public static NoContent PostNoContentResult(DeduplicatedOrder request)
{
Count(request.Name);
return TypedResults.NoContent();
}

[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/accepted-result")]
public static Accepted<DeduplicatedOrderCreated> PostAcceptedResult(DeduplicatedOrder request)
{
var created = run(request);
return TypedResults.Accepted($"/deduplicated-response/orders/{created.Id}", created);
}

[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/union-result")]
public static Results<Created<DeduplicatedOrderCreated>, NotFound> PostUnionResult(DeduplicatedOrder request)
{
if (request.Name == Absent)
{
Count(request.Name);
return TypedResults.NotFound();
}

var created = run(request);
return TypedResults.Created($"/deduplicated-response/orders/{created.Id}", created);
}

[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/text-result")]
public static IResult PostTextResult(DeduplicatedOrder request)
{
Count(request.Name);
return TypedResults.Text(request.Name);
}
}

public record DeduplicatedAnswer(string Name);

public record CustomWrittenAnswer(string Name);

// Takes over Ok<CustomWrittenAnswer> ahead of the built-in result writer, writing it as plain text
public class CustomAnswerWriterPolicy : IResourceWriterPolicy
{
public bool TryApply(HttpChain chain)
{
if (chain.ResourceType != typeof(Ok<CustomWrittenAnswer>)) return false;

var call = new MethodCall(typeof(CustomAnswerWriterPolicy), nameof(Write));
call.Arguments[0] = chain.ResourceVariable ?? chain.Method.Creates.First();
chain.Postprocessors.Add(call);
return true;
}

public static Task Write(Ok<CustomWrittenAnswer> result, HttpContext context)
=> context.Response.WriteAsync(result.Value!.Name);
}

public record DetailedDeduplicatedAnswer(string Name, string Detail) : DeduplicatedAnswer(Name);

// Its own class: the Validate middleware above needs a body.
public static class MissingResourceDeduplicatedEndpoint
{
Expand Down
Loading
Loading