Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added AcceptResponse to allow automated mapping of the 202 status with the Location Header #726

Merged
merged 2 commits into from
Feb 15, 2024
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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<LangVersion>11</LangVersion>
<LangVersion>12</LangVersion>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COMMENT: Boy Scout rule 🫡

<NoWarn>1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618</NoWarn>

<Authors>Jeremy D. Miller</Authors>
Expand Down
1 change: 1 addition & 0 deletions docs/guide/http/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ In terms of the response type, you can use:
| `int` | Empty | Value of response | |
| Type that implements `IResult` | Varies | Varies | The `IResult.ExecuteAsync()` method is executed |
| `CreationResponse` or subclass | JSON | 201 | The response is serialized, and writes a `location` response header |
| `AcceptResponse` or subclass | JSON | 202 | The response is serialized, and writes a `location` response header |
| Any other type | JSON | 200 | The response is serialized to JSON |

In all cases up above, if the endpoint method is asynchronous using either `Task<T>` or `ValueTask<T>`, the `T` is the
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/http/metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,6 @@ public record CreationResponse(string Url) : IHttpAware
<!-- endSnippet -->

Any endpoint that returns `CreationResponse` or a sub class will automatically expose a status code of `201` for successful
processing to denote resource creation instead of the generic `200`. Your own custom implementations of the `IHttpAware`
processing to denote resource creation instead of the generic `200`. Same goes for the built-in `AcceptResponse` type, but returning `202` status. Your own custom implementations of the `IHttpAware`
interface would apply the metadata declarations at configuration time so that those customizations would be part of the
exported Swashbuckle documentation of the system.
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,30 @@

namespace Wolverine.Http.Tests.Marten;

public class using_aggregate_handler_workflow : IntegrationContext
public class using_aggregate_handler_workflow(AppFixture fixture) : IntegrationContext(fixture)
{
public using_aggregate_handler_workflow(AppFixture fixture) : base(fixture)
{
}

[Theory]
[InlineData("/orders/create")]
[InlineData("/orders/create2")]
public async Task use_marten_command_workflow(string createEndpoint)
{
var result1 = await Scenario(x =>
{
x.Post.Json(new StartOrder(new[] { "Socks", "Shoes", "Shirt" })).ToUrl(createEndpoint);
x.Post.Json(new StartOrder(["Socks", "Shoes", "Shirt"])).ToUrl(createEndpoint);
});

var status1 = result1.ReadAsJson<OrderStatus>();
status1.ShouldNotBeNull();

await Scenario(x =>
{
x.Post.Json(new MarkItemReady(status1.OrderId, "Socks", 1)).ToUrl("/orders/itemready");
});

await using var session = Store.LightweightSession();

var order = await session.Events.AggregateStreamAsync<Order>(status1.OrderId);

order.ShouldNotBeNull();
order.Items["Socks"].Ready.ShouldBeTrue();
}
Expand All @@ -39,12 +37,13 @@ public async Task mix_creation_response_and_start_stream()
{
var result1 = await Scenario(x =>
{
x.Post.Json(new StartOrder(new[] { "Socks", "Shoes", "Shirt" })).ToUrl("/orders/create3");
x.Post.Json(new StartOrder(["Socks", "Shoes", "Shirt"])).ToUrl("/orders/create3");
x.StatusCodeShouldBe(201);
});

var response = result1.ReadAsJson<CreationResponse>();
var raw = response.Url.ToString().Split('/').Last();
response.ShouldNotBeNull();
var raw = response.Url.Split('/').Last();
var id = Guid.Parse(raw);

await Scenario(x =>
Expand All @@ -53,6 +52,7 @@ await Scenario(x =>
});

await using var session = Store.LightweightSession();

var order = await session.Events.AggregateStreamAsync<Order>(id);

order.ShouldNotBeNull();
Expand All @@ -64,19 +64,23 @@ public async Task use_a_return_value_as_event()
{
var result1 = await Scenario(x =>
{
x.Post.Json(new StartOrder(new[] { "Socks", "Shoes", "Shirt" })).ToUrl("/orders/create");
x.Post.Json(new StartOrder(["Socks", "Shoes", "Shirt"])).ToUrl("/orders/create");
});

var status1 = result1.ReadAsJson<OrderStatus>();
status1.ShouldNotBeNull();

await Scenario(x =>
{
x.Post.Json(new ShipOrder(status1.OrderId)).ToUrl("/orders/ship");
x.StatusCodeShouldBe(204);
});

using var session = Store.LightweightSession();
await using var session = Store.LightweightSession();

var order = await session.Events.AggregateStreamAsync<Order>(status1.OrderId);

order.ShouldNotBeNull();
order.Shipped.HasValue.ShouldBeTrue();
}

Expand All @@ -85,10 +89,11 @@ public async Task use_a_return_value_as_event_using_route_id_and_command_aggrega
{
var result1 = await Scenario(x =>
{
x.Post.Json(new StartOrder(new[] { "Socks", "Shoes", "Shirt" })).ToUrl("/orders/create");
x.Post.Json(new StartOrder(["Socks", "Shoes", "Shirt"])).ToUrl("/orders/create");
});

var status1 = result1.ReadAsJson<OrderStatus>();
status1.ShouldNotBeNull();

await Scenario(x =>
{
Expand All @@ -97,8 +102,11 @@ await Scenario(x =>
x.StatusCodeShouldBe(204);
});

using var session = Store.LightweightSession();
await using var session = Store.LightweightSession();

var order = await session.Events.AggregateStreamAsync<Order>(status1.OrderId);

order.ShouldNotBeNull();
order.Shipped.HasValue.ShouldBeTrue();
}

Expand All @@ -107,10 +115,11 @@ public async Task use_a_return_value_as_event_using_route_id_and_aggregate_but_n
{
var result1 = await Scenario(x =>
{
x.Post.Json(new StartOrder(new[] { "Socks", "Shoes", "Shirt" })).ToUrl("/orders/create");
x.Post.Json(new StartOrder(["Socks", "Shoes", "Shirt"])).ToUrl("/orders/create");
});

var status1 = result1.ReadAsJson<OrderStatus>();
status1.ShouldNotBeNull();

await Scenario(x =>
{
Expand All @@ -119,8 +128,11 @@ await Scenario(x =>
x.StatusCodeShouldBe(204);
});

using var session = Store.LightweightSession();
await using var session = Store.LightweightSession();

var order = await session.Events.AggregateStreamAsync<Order>(status1.OrderId);

order.ShouldNotBeNull();
order.Shipped.HasValue.ShouldBeTrue();
}

Expand All @@ -140,10 +152,11 @@ public async Task use_a_return_value_as_event_using_route_id_but_no_parameter_an
{
var result1 = await Scenario(x =>
{
x.Post.Json(new StartOrder(new[] { "Socks", "Shoes", "Shirt" })).ToUrl("/orders/create");
x.Post.Json(new StartOrder(["Socks", "Shoes", "Shirt"])).ToUrl("/orders/create");
});

var status1 = result1.ReadAsJson<OrderStatus>();
status1.ShouldNotBeNull();

await Scenario(x =>
{
Expand All @@ -152,8 +165,11 @@ await Scenario(x =>
x.StatusCodeShouldBe(204);
});

using var session = Store.LightweightSession();
await using var session = Store.LightweightSession();

var order = await session.Events.AggregateStreamAsync<Order>(status1.OrderId);

order.ShouldNotBeNull();
order.Shipped.HasValue.ShouldBeTrue();
}

Expand All @@ -165,20 +181,47 @@ public async Task use_stream_collision_policy()
// First time should be fine
await Scenario(x =>
{
x.Post.Json(new StartOrderWithId(id, new[] { "Socks", "Shoes", "Shirt" })).ToUrl("/orders/create4");
x.Post.Json(new StartOrderWithId(id, ["Socks", "Shoes", "Shirt"])).ToUrl("/orders/create4");
});

// Second time hits an exception from stream id collision
var result2 = await Scenario(x =>
{
x.Post.Json(new StartOrderWithId(id, new[] { "Socks", "Shoes", "Shirt" })).ToUrl("/orders/create4");
x.Post.Json(new StartOrderWithId(id, ["Socks", "Shoes", "Shirt"])).ToUrl("/orders/create4");
x.StatusCodeShouldBe(400);
});

// And let's verify that we got what we expected for the ProblemDetails
// in the HTTP response body of the 2nd request
var details = result2.ReadAsJson<ProblemDetails>();
Guid.Parse(details.Extensions["Id"].ToString()).ShouldBe(id);
details.ShouldNotBeNull();
var detailsId = details.Extensions["Id"]?.ToString();
detailsId.ShouldNotBeEmpty();

Guid.Parse(detailsId).ShouldBe(id);
details.Detail.ShouldBe($"Duplicated id '{id}'");
}

[Fact]
public async Task accept_response_returns_proper_status_and_url()
{
var result = await Scenario(x =>
{
x.Post.Json(new StartOrder(["Socks", "Shoes", "Shirt"])).ToUrl("/orders/create");
});

var status = result.ReadAsJson<OrderStatus>();
status.ShouldNotBeNull();

result = await Scenario(x =>
{
x.Post.Json(new ConfirmOrder(status.OrderId)).ToUrl($"/orders/{status.OrderId}/confirm");

x.StatusCodeShouldBe(202);
});

var acceptResponse = await result.ReadAsJsonAsync<AcceptResponse>();
acceptResponse.ShouldNotBeNull();
acceptResponse.Url.ShouldBe($"/orders/{status.OrderId}");
}
}
33 changes: 33 additions & 0 deletions src/Http/Wolverine.Http/IHttpAware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,37 @@ void IHttpAware.Apply(HttpContext context)
public record CreationResponse<T>(string Url, T Value) : CreationResponse(Url)
{

}


#region sample_AcceptResponse

/// <summary>
/// Base class for resource types that denote some kind of request being accepted in the system.
/// </summary>
public record AcceptResponse(string Url) : IHttpAware
{
public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder)
{
builder.RemoveStatusCodeResponse(200);

var create = new MethodCall(method.DeclaringType!, method).Creates.FirstOrDefault()?.VariableType;
var metadata = new WolverineProducesResponseTypeMetadata { Type = create, StatusCode = 202 };
builder.Metadata.Add(metadata);
}

void IHttpAware.Apply(HttpContext context)
{
context.Response.Headers.Location = Url;
context.Response.StatusCode = 202;
}

public static AcceptResponse<T> For<T>(T value, string url) => new AcceptResponse<T>(url, value);
}

#endregion

public record AcceptResponse<T>(string Url, T Value) : CreationResponse(Url)
{

}
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using Microsoft.AspNetCore.Http.Metadata;

namespace Wolverine.Http.Resources;

internal class WolverineProducesResponseTypeMetadata : IProducesResponseTypeMetadata
public class WolverineProducesResponseTypeMetadata : IProducesResponseTypeMetadata
{
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COMMENT: Without that one wouldn't be able to create its own IHttpAware implementation.

public Type? Type { get; init; }
public int StatusCode { get; init; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// <auto-generated/>
#pragma warning disable
using Microsoft.AspNetCore.Routing;
using System;
using System.Linq;
using Wolverine.Http;
using Wolverine.Marten.Publishing;
using Wolverine.Runtime;

namespace Internal.Generated.WolverineHandlers
{
// START: POST_orders_id_confirm
public class POST_orders_id_confirm : Wolverine.Http.HttpHandler
{
private readonly Wolverine.Http.WolverineHttpOptions _wolverineHttpOptions;
private readonly Wolverine.Runtime.IWolverineRuntime _wolverineRuntime;
private readonly Wolverine.Marten.Publishing.OutboxedSessionFactory _outboxedSessionFactory;

public POST_orders_id_confirm(Wolverine.Http.WolverineHttpOptions wolverineHttpOptions, Wolverine.Runtime.IWolverineRuntime wolverineRuntime, Wolverine.Marten.Publishing.OutboxedSessionFactory outboxedSessionFactory) : base(wolverineHttpOptions)
{
_wolverineHttpOptions = wolverineHttpOptions;
_wolverineRuntime = wolverineRuntime;
_outboxedSessionFactory = outboxedSessionFactory;
}



public override async System.Threading.Tasks.Task Handle(Microsoft.AspNetCore.Http.HttpContext httpContext)
{
var messageContext = new Wolverine.Runtime.MessageContext(_wolverineRuntime);
// Reading the request body via JSON deserialization
var (command, jsonContinue) = await ReadJsonAsync<WolverineWebApi.Marten.ConfirmOrder>(httpContext);
if (jsonContinue == Wolverine.HandlerContinuation.Stop) return;
await using var documentSession = _outboxedSessionFactory.OpenSession(messageContext);
var eventStore = documentSession.Events;

// Loading Marten aggregate
var eventStream = await eventStore.FetchForWriting<WolverineWebApi.Marten.Order>(command.OrderId, httpContext.RequestAborted).ConfigureAwait(false);


// The actual HTTP request handler execution
(var acceptResponse_response, var events) = WolverineWebApi.Marten.MarkItemEndpoint.Confirm(command, eventStream.Aggregate);

if (events != null)
{

// Capturing any possible events returned from the command handlers
eventStream.AppendMany(events);

}

// This response type customizes the HTTP response
ApplyHttpAware(acceptResponse_response, httpContext);
await documentSession.SaveChangesAsync(httpContext.RequestAborted).ConfigureAwait(false);
// Writing the response body to JSON because this was the first 'return variable' in the method signature
await WriteJsonAsync(httpContext, acceptResponse_response);
}

}

// END: POST_orders_id_confirm


}

14 changes: 13 additions & 1 deletion src/Http/WolverineWebApi/Marten/Orders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public record MarkItemReady(Guid OrderId, string ItemName, int Version);
public record OrderShipped;
public record OrderCreated(Item[] Items);
public record OrderReady;
public record OrderConfirmed;
public interface IShipOrder
{
Guid OrderId { init; }
Expand Down Expand Up @@ -78,6 +79,8 @@ public record StartOrder(string[] Items);

public record StartOrderWithId(Guid Id, string[] Items);

public record ConfirmOrder(Guid OrderId);

public static class CanShipOrderMiddleWare
{
#region sample_using_before_on_http_aggregate
Expand Down Expand Up @@ -237,5 +240,14 @@ public static (OrderStatus, IStartStream) StartOrder4(StartOrderWithId command)
startStream
);
}


[AggregateHandler]
[WolverinePost("/orders/{id}/confirm")]
public static (AcceptResponse, Events) Confirm(ConfirmOrder command, Order order)
{
return (
new AcceptResponse($"/orders/{order.Id}"),
[new OrderConfirmed()]
);
}
}
Loading