Skip to content

OpenAPI: derive parameters from the full binding chain, plus a shape-test harness (GH-3380)#3418

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-3380-openapi-compound-handler-route-params
Jul 14, 2026
Merged

OpenAPI: derive parameters from the full binding chain, plus a shape-test harness (GH-3380)#3418
jeremydmiller merged 1 commit into
mainfrom
gh-3380-openapi-compound-handler-route-params

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes #3380

What was actually wrong (and what wasn't)

Being straight about the repro first: the literal parameters: [] symptom in the issue does not reproduce on main (6.17.2). CreateApiDescription has always looped RoutePattern.Parameters, so a route token is emitted no matter which method binds it. I reproduced every reported shape — compound LoadAsync binding both ids (with a service + CancellationToken), kebab-case {order-id:long} + [FromRoute(Name = ...)], [AsParameters] + LoadAsync combinations — against both OpenAPI stacks (Swashbuckle and Microsoft.AspNetCore.OpenApi), and the path parameters were present and correctly typed in all of them. My best guess is the reporter's document predates the GH-3374 fix (their host wasn't starting) — but I could not confirm it.

What the investigation did turn up is the real version of the root cause the issue describes — the API description was derived from the endpoint method signature plus its own resolved binding variables, not from the full chain:

  1. Query string / header values bound only by an After/Finally postprocessor were omitted from the operation entirely. This is a genuine, reproducing bug (the new query_values_bound_only_by_a_postprocessor_are_declared test fails on main).
  2. Route parameter types were read off resolved binding variables, so they 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, and that read can happen long before codegen (build-time OpenAPI generation, the openapi command, monitoring snapshots).

The fix

HttpChain.CreateApiDescription now walks every MethodCall in the chain — the endpoint method, all middleware (compound handler Load/LoadAsync/Before, attribute- and policy-applied middleware) and all postprocessors — plus their [AsParameters] container members:

  • Every RoutePattern parameter is declared as a required path parameter, with its type resolved in order: resolved route variable → the binding chain (honoring [FromRoute(Name = "order-id")], ignoring name collisions with non-bindable types like services) → the route constraint ({id:guid}) → string. That is the non-negotiable floor, now structurally guaranteed rather than dependent on codegen having run.
  • [FromQuery] / [FromHeader] arguments anywhere in the chain are declared, deduplicated by name + binding source.

The shape-test harness (the actual gap)

There were essentially no assertions on the rendered document's parameters/requestBody. Now there are, on both stacks:

1. src/Http/Wolverine.Http.Tests/openapi_shape_tests.cs — Microsoft.AspNetCore.OpenApi. Renders the real OpenAPI document (same path the openapi command uses, no host start, no database) for a host pinned to the infrastructure-free Wolverine.Http.Tests.DifferentAssembly, and asserts on it via ParametersFor(path, method) / HasRequestBody(...) / RequestBodyPropertiesFor(...).

To add a shape: add an endpoint to Wolverine.Http.Tests.DifferentAssembly/OpenApiShapeEndpoints.cs, then add a [Fact] using those helpers.

Covered today: compound-handler-only route bindings (the #3380 case), an unconstrained route token typed purely from the binding chain, a renamed [FromRoute(Name = ...)] compound binding, compound-handler-only query + header, postprocessor-only query, plain handler-signature route + query, a request body alongside a compound route binding, a route value nothing binds (floor → required string), [AsParameters] route + query, and an [AsParameters] container sharing a route value with a compound handler (declared exactly once).

2. WolverineWebApi Expect* attributes — Swashbuckle. Folded into the existing GH-3135 harness (open_api_generation.verify_open_api_expectations) rather than duplicated. Added [ExpectParameterCount] (so [ExpectParameter] can't miss a phantom/duplicate parameter) and ExpectParameter.Required, plus CompoundHandlerOpenApiEndpoints.cs with the #3380 shapes.

To add a shape: annotate an endpoint method in WolverineWebApi with the Expect* attributes. No test code required — the theory picks it up automatically.

Known gap (not fixed here)

Route ids bound only by a Marten aggregate frame on an unconstrained route (e.g. [AggregateHandler] [WolverinePost("/orders/{id}/confirm")]) still render as string rather than uuid — that binding is decided during codegen, after the description is cached, and the id type is Marten-domain knowledge that Wolverine.Http can't infer from the method signatures. Worth a follow-up issue.

Tests

  • Wolverine.Http.Tests: 857 passed, 0 failed, 10 skipped (net9.0)
  • Wolverine.Http.AspVersioning.Tests: 66 passed (net10.0)
  • dotnet build wolverine.slnx -c Release: succeeded

🤖 Generated with Claude Code

…ndpoint signature (GH-3380)

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>
@jeremydmiller
jeremydmiller merged commit f34d709 into main Jul 14, 2026
26 checks passed
mysticmind added a commit that referenced this pull request Jul 14, 2026
… of falling back to string (GH-3420)

On the [AggregateHandler] shape:

    [AggregateHandler]
    [WolverinePost("/orders/{id}/confirm")]
    public static OrderConfirmed Handle(ConfirmOrder command, Order order)

nothing in the endpoint signature binds {id} -- the aggregate id is read off the
command by the aggregate workflow during codegen -- so with no route constraint to
fall back on, GH-3418's resolution ladder (route variable -> binding chain -> route
constraint -> string) came up empty on every rung and the operation declared `id` as
`string` rather than `uuid`.

The aggregate's identity type is Marten/Polecat domain knowledge that Wolverine.Http
cannot infer from method signatures, so this adds a seam for the aggregate workflow to
hand that type back:

- IRoutedChain (Wolverine core, so Wolverine.Marten/Wolverine.Polecat can reach it
  without referencing Wolverine.Http) exposes the chain's route parameter names and
  lets middleware declare the CLR type behind one. It is purely descriptive: it creates
  no binding and does not affect generated code.
- HttpChain implements it, and CreateApiDescription consults declared types *after* the
  route constraint -- an explicit {id:guid} is what ASP.NET Core actually enforces, so
  a declared type can only ever improve on the `string` fallback and cannot change an
  endpoint that already resolves a type.
- AggregateHandling.Apply (Marten and Polecat both) declares the aggregate id's type
  against the first route token matching WriteAggregateAttribute.FindIdentity's own
  precedence: RouteOrParameterName, then {aggregate}Id, then id.

[WriteAggregate]/[ReadAggregate]/[Aggregate] already bound the route value themselves
and were unaffected; they are now pinned by tests as part of the same family.

Coverage on both OpenAPI stacks: openapi_shape_tests grows Marten aggregate shapes
(Microsoft.AspNetCore.OpenApi) and /orders/{id}/confirm in WolverineWebApi grows an
[ExpectParameter] expectation (Swashbuckle). The shape harness registers Marten against
an unreachable database (port 9999, per generate_openapi_without_database) so it stays
infrastructure-free by construction -- verified by running it with the Postgres
container stopped. api_explorer_before_host_start builds a second host over that same
assembly and needs the same registration.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenAPI: route parameters bound only by compound-handler LoadAsync/Before methods are missing from the operation (parameters: [])

1 participant