Skip to content

Commit f34d709

Browse files
Derive OpenAPI parameters from the full binding chain, not just the endpoint signature (GH-3380) (#3418)
HttpChain's ApiDescription only knew about values bound by the endpoint method itself (plus its [AsParameters] container). Anything bound elsewhere in the chain could go undeclared: - query string / header values bound only by an After/Finally postprocessor were omitted from the operation entirely - route parameter types were read off resolved binding *variables*, so they silently degraded to the route constraint (or string) whenever the description was assembled before those frames were resolved — ASP.NET Core caches the first ApiExplorer read, which can happen long before codegen CreateApiDescription now walks every MethodCall in the chain — the endpoint method, middleware (compound handler Load/LoadAsync/Before, attribute- and policy-applied middleware) and postprocessors — plus their [AsParameters] container members: - every RoutePattern parameter is still declared as a required path parameter, with its type taken from a resolved route variable, then the binding chain (honoring [FromRoute(Name = "...")]), then the route constraint, and finally string - [FromQuery]/[FromHeader] arguments anywhere in the chain are declared once, deduplicated by name + binding source Also adds the OpenAPI shape-test coverage that was missing (and that let this class of omission ship): openapi_shape_tests renders the real Microsoft.AspNetCore.OpenApi document for an infrastructure-free host and asserts on the operation's parameters and request body, and the WolverineWebApi Expect* attributes (now with [ExpectParameterCount] and ExpectParameter.Required) cover the same shapes on the Swashbuckle stack. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 99969a0 commit f34d709

7 files changed

Lines changed: 718 additions & 5 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using Microsoft.AspNetCore.Http;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Wolverine.Http;
4+
5+
namespace Wolverine.Http.Tests.DifferentAssembly.OpenApi;
6+
7+
// Endpoint shapes used by the `openapi_shape_tests` harness, which renders the *real*
8+
// Microsoft.AspNetCore.OpenApi document for a host built on this assembly and asserts on the rendered
9+
// operation (parameters + request body). These live here — rather than in WolverineWebApi — because
10+
// this assembly has no database/Marten/EF dependencies, so the document can be generated from a host
11+
// that never touches infrastructure.
12+
//
13+
// To add a shape: add an endpoint below, then add a [Fact] to openapi_shape_tests.cs. The Swashbuckle
14+
// side of the same coverage lives in WolverineWebApi (see the Expect* attributes and
15+
// open_api_generation.verify_open_api_expectations).
16+
17+
public record OrderLine(long OrderId, long OrderLineId);
18+
19+
#region GH-3380: route values bound ONLY by a compound handler
20+
21+
// The GH-3380 reproduction: both route ids are consumed by LoadAsync and never appear in the endpoint
22+
// method signature. They still have to be declared as required path parameters.
23+
public static class CompoundRouteOnlyEndpoint
24+
{
25+
public static OrderLine LoadAsync([FromRoute] long orderId, [FromRoute] long orderLineId)
26+
=> new(orderId, orderLineId);
27+
28+
[WolverineGet("/shapes/orders/{orderId:long}/order-lines/{orderLineId:long}")]
29+
public static string Get(OrderLine line) => $"{line.OrderId}:{line.OrderLineId}";
30+
}
31+
32+
// Same, but the route token is UNCONSTRAINED — so the parameter type can only come from the binding
33+
// chain (the LoadAsync argument), not from a route constraint.
34+
public static class CompoundUnconstrainedRouteEndpoint
35+
{
36+
public static OrderLine Before([FromRoute] long orderId) => new(orderId, 0);
37+
38+
[WolverineGet("/shapes/unconstrained/orders/{orderId}")]
39+
public static string Get(OrderLine line) => line.OrderId.ToString();
40+
}
41+
42+
// A route token bound by a renamed [FromRoute(Name = "order-id")] argument on the compound handler.
43+
public static class CompoundRenamedRouteEndpoint
44+
{
45+
public static OrderLine Load([FromRoute(Name = "order-id")] long orderId) => new(orderId, 0);
46+
47+
[WolverineGet("/shapes/renamed/orders/{order-id:long}")]
48+
public static string Get(OrderLine line) => line.OrderId.ToString();
49+
}
50+
51+
// Query string + header values bound only by a compound handler method.
52+
public record RequestContext(string? Name, string? Tenant);
53+
54+
public static class CompoundQueryAndHeaderEndpoint
55+
{
56+
public static RequestContext Load([FromQuery] string? name, [FromHeader(Name = "x-tenant")] string? tenant)
57+
=> new(name, tenant);
58+
59+
[WolverineGet("/shapes/compound-query-header")]
60+
public static string Get(RequestContext context) => $"{context.Name}:{context.Tenant}";
61+
}
62+
63+
// A query string value bound only by an After/Finally postprocessor. Postprocessor arguments are part of
64+
// the endpoint's contract too.
65+
public static class PostprocessorQueryEndpoint
66+
{
67+
[WolverineGet("/shapes/postprocessor/{orderId:long}")]
68+
public static string Get() => "ok";
69+
70+
public static void After([FromQuery] string? audit)
71+
{
72+
}
73+
}
74+
75+
#endregion
76+
77+
#region baseline shapes: plain handler signature bindings
78+
79+
public record CreateOrderLine(string Description, int Quantity);
80+
81+
// Route + query bound the plain way, straight off the endpoint method signature.
82+
public static class PlainRouteAndQueryEndpoint
83+
{
84+
[WolverineGet("/shapes/plain/orders/{orderId:long}")]
85+
public static string Get([FromRoute] long orderId, [FromQuery] string? filter) => $"{orderId}:{filter}";
86+
}
87+
88+
// A JSON request body alongside a route value that is bound only by the compound handler.
89+
public static class BodyWithCompoundRouteEndpoint
90+
{
91+
public static OrderLine LoadAsync([FromRoute] long orderId) => new(orderId, 0);
92+
93+
[WolverinePost("/shapes/body/orders/{orderId:long}/order-lines")]
94+
public static string Post(CreateOrderLine body, OrderLine line) => $"{line.OrderId}:{body.Description}";
95+
}
96+
97+
// Nothing in the chain binds {code} at all — it still has to be declared as a required path parameter,
98+
// falling back to string.
99+
public static class UnboundRouteValueEndpoint
100+
{
101+
[WolverineGet("/shapes/unbound/{code}")]
102+
public static string Get() => "ok";
103+
}
104+
105+
#endregion
106+
107+
#region [AsParameters] shapes
108+
109+
public record OrderLineQuery([FromRoute] long OrderId, [FromQuery] string? Filter);
110+
111+
public static class AsParametersEndpoint
112+
{
113+
[WolverineGet("/shapes/asparameters/orders/{orderId:long}")]
114+
public static string Get([AsParameters] OrderLineQuery query) => $"{query.OrderId}:{query.Filter}";
115+
}
116+
117+
// An [AsParameters] container on the endpoint whose route value is *also* bound by the compound handler.
118+
public static class AsParametersWithCompoundEndpoint
119+
{
120+
public static OrderLine LoadAsync([FromRoute(Name = "order-id")] long orderId) => new(orderId, 0);
121+
122+
[WolverineGet("/shapes/asparameters-compound/orders/{order-id:long}")]
123+
public static string Get([AsParameters] RenamedOrderQuery query, OrderLine line)
124+
=> $"{query.OrderId}:{line.OrderLineId}";
125+
}
126+
127+
public class RenamedOrderQuery
128+
{
129+
[FromRoute(Name = "order-id")]
130+
public long OrderId { get; set; }
131+
132+
[FromQuery]
133+
public string? Filter { get; set; }
134+
}
135+
136+
#endregion

src/Http/Wolverine.Http.Tests/open_api_generation.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@
88

99
namespace Wolverine.Http.Tests;
1010

11+
/// <summary>
12+
/// The attribute-driven OpenAPI *shape* harness on the Swashbuckle stack: any endpoint in WolverineWebApi
13+
/// annotated with an <see cref="OpenApiExpectationAttribute"/> ([ExpectParameter], [ExpectParameterCount],
14+
/// [ExpectRequestBody], [ExpectNoRequestBody], [ExpectProduces], [ExpectStatusCodes], [ExpectMatch]) is fed
15+
/// through <see cref="verify_open_api_expectations"/>, which resolves the *rendered* OpenAPI document and
16+
/// validates the expectations against the real operation.
17+
///
18+
/// TO ADD A SHAPE: annotate an endpoint method in WolverineWebApi with the Expect* attributes describing
19+
/// what the operation must look like. No test code required — this theory picks it up automatically.
20+
///
21+
/// The Microsoft.AspNetCore.OpenApi half of the shape coverage lives in openapi_shape_tests.cs.
22+
/// </summary>
1123
public class open_api_generation : IntegrationContext
1224
{
1325
public open_api_generation(AppFixture fixture) : base(fixture)

0 commit comments

Comments
 (0)