Repository: JasperFx/wolverine (2026-07-23T18:54:12Z)

Issue:
OpenAPI: describe chain-bound parameters from bound variables, not re-derived attributes (retire the 3 subtractive guards)
## Background

Follow-up to #3586 / #3587 / #3594.

`HttpChain.fillChainBoundParameters` (in `HttpChain.ApiDescription.cs`) builds OpenAPI parameter descriptions by **re-deriving from attributes** — it walks every `MethodCall` in the chain and reflects over `[FromQuery]`/`[FromHeader]`. The sibling `fillQuerystringParameters` instead describes from `_querystringVariables`, i.e. **the variables the binder actually produced**.

Because the attribute-reflection model is a *second, parallel* source of truth that drifts from what the binder really does, every drift has been patched with another subtractive guard. There are now **three** in that one method:

1. the same-name dedupe in `addChainBoundParameter`,
2. the GH-3575 complex-`[FromQuery]` flattening skip,
3. the route-collision skip added in #3587 (`isBoundFromRouteValue`).

Each of these describes a binding that *doesn't actually happen* and then subtracts it back out. This is the root cause of the regression-proneness in this area (the #3586 → HTTP 500 crash was one instance).

## Proposal

Describe chain-bound query/header parameters from the **bound variables** the chain produced (the way `fillQuerystringParameters` already does for the endpoint method), rather than re-reflecting over attributes. If the description is sourced from what the binder emitted, the three subtractive guards become structurally unnecessary — a binding that never happens is never described in the first place.

## Notes / credit

- Structural insight from **@esond** during review of #3587 (may overlap with their #3577, which lands in the same method — worth coordinating).
- This is a refactor of the description path only; runtime binding is unchanged.
- Acceptance: the `openapi_shape_tests` battery (extended in #3594 across string/Guid/int/long/bool/DateTime/enum/Nullable/header) stays green with the guards removed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)



Discussion:

Patch:
diff --git a/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs b/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs
index c692df213..cb7ce2809 100644
--- a/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs
+++ b/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs
@@ -118,9 +118,10 @@ public ApiDescription CreateApiDescription(string httpMethod)
 
         fillKnownHeaderParameters(apiDescription);
 
-        // Query string and header values that are only ever bound by another method in the chain
-        // (a compound handler's Load/LoadAsync/Before, an After/Finally postprocessor, a middleware
-        // method applied by a policy) are still part of this endpoint's contract. See GH-3380.
+        // Query string and header values bound only by an After/Finally postprocessor are part of the
+        // endpoint's contract but never produce a bound variable (postprocessors are not parameter-matched),
+        // so they cannot be described from _querystringVariables / _headerVariables like every other binder.
+        // See GH-3380 / GH-3601.
         fillChainBoundParameters(apiDescription);
 
         fillResponseTypes(apiDescription);
@@ -614,50 +615,84 @@ private static Type unwrapNullable(Type type)
     }
 
     /// <summary>
-    /// Adds query string and header parameters that are bound *only* by another method in the binding
-    /// chain — a compound handler's Load/LoadAsync/Before, an After/Finally postprocessor, or a middleware
-    /// method applied by a policy — and therefore never show up in the endpoint method's own variables.
-    /// See GH-3380.
+    /// Adds query string and header parameters bound *only* by an After/Finally postprocessor.
+    ///
+    /// Every other binder in the chain — the endpoint method itself and the middleware / compound-handler
+    /// methods (Load/LoadAsync/Before, plus policy-applied middleware) — is run through
+    /// <see cref="HttpGraph.ApplyParameterMatching(HttpChain, MethodCall)"/>, so its query and header inputs
+    /// already exist as produced variables in <c>_querystringVariables</c> / <c>_headerVariables</c> and are
+    /// described from those by <see cref="fillQuerystringParameters"/> / <see cref="fillKnownHeaderParameters"/>.
+    /// Describing them here as well was a *second, parallel* source of truth derived by re-reflecting attributes,
+    /// which drifted from what the binder actually produced and had to be corrected with a series of subtractive
+    /// guards (the complex-[FromQuery] flatten skip, the route-collision skip, the same-name dedupe). Sourcing
+    /// those descriptions solely from the produced variables removes the drift at the root.
+    ///
+    /// Postprocessors are the one binder that is deliberately NOT parameter-matched (see the middleware-only
+    /// loop in <c>HttpChain</c>'s constructor), so they never produce a bound variable — they are the sole
+    /// remaining shape that must be derived from the method signature. What used to be three scattered guards is
+    /// now the single positive decision in <see cref="tryDescribePostprocessorBinding"/>: describe a postprocessor
+    /// parameter only when the binder would expose it on the wire as a query or header value. See GH-3380 / GH-3601.
     /// </summary>
     private void fillChainBoundParameters(ApiDescription apiDescription)
     {
-        foreach (var call in allMethodCalls())
+        foreach (var call in Postprocessors.OfType<MethodCall>())
         {
             foreach (var parameter in call.Method.GetParameters())
             {
-                // A simple [FromQuery]/[FromHeader] parameter whose name matches a route-template segment is
-                // actually bound from the route value, not the query string or a header: FromQueryAttributeUsage
-                // declines simple types and RouteParameterStrategy runs before the query/header strategies, so
-                // the route claims it and the attribute is a no-op. The route-template loop already describes it
-                // as a Path parameter; describing it again here would emit a second same-name parameter, which is
-                // invalid OpenAPI and crashes downstream transformers (e.g. the XML-doc operation transformer's
-                // Parameters.SingleOrDefault). Regressed when GH-3380 began deriving parameters from the chain.
-                if (isBoundFromRouteValue(parameter))
+                if (tryDescribePostprocessorBinding(parameter, out var name, out var source))
                 {
-                    continue;
+                    addChainBoundParameter(apiDescription, name, parameter.ParameterType, source);
                 }
+            }
+        }
+    }
 
-                if (parameter.TryGetAttribute<FromQueryAttribute>(out var fromQuery))
-                {
-                    // A complex [FromQuery] type is bound by flattening it into one query string value per
-                    // member, all of them already declared by fillQuerystringParameters. The container
-                    // itself never appears on the wire, so describing it would add a phantom query
-                    // parameter — and drag the type into components/schemas. See GH-3575.
-                    if (CodeGen.FromQueryAttributeUsage.IsComplexQueryStringType(parameter.ParameterType))
-                    {
-                        continue;
-                    }
+    /// <summary>
+    /// Does the binder expose this postprocessor parameter on the wire as a distinct query or header value, and
+    /// if so under what name and binding source? This is the single, positively-framed replacement for the three
+    /// subtractive guards <see cref="fillChainBoundParameters"/> used to carry:
+    /// <list type="bullet">
+    /// <item>a route-value binding — an explicit <c>[FromRoute]</c> or an implicit name collision with a
+    /// route-template segment on a route-bindable type — is claimed by the route and already described by the
+    /// route-template loop as a Path parameter, so it is not a query/header value here;</item>
+    /// <item>a complex <c>[FromQuery]</c> is bound by flattening into one query value per member, so the container
+    /// itself never appears on the wire (and a postprocessor's members are never produced as variables to flatten
+    /// into anyway) — describing it would add a phantom parameter and drag the type into components/schemas;</item>
+    /// <item>anything without <c>[FromQuery]</c>/<c>[FromHeader]</c> is not a query/header input.</item>
+    /// </list>
+    /// A value that is also read by an already-described binder (e.g. the same <c>[FromQuery]</c> on the endpoint
+    /// method) is de-duplicated by <see cref="addChainBoundParameter"/> on the way in.
+    /// </summary>
+    private bool tryDescribePostprocessorBinding(ParameterInfo parameter, out string name, out BindingSource source)
+    {
+        name = null!;
+        source = BindingSource.Query;
 
-                    var name = fromQuery.Name.IsNotEmpty() ? fromQuery.Name! : parameter.Name!;
-                    addChainBoundParameter(apiDescription, name, parameter.ParameterType, BindingSource.Query);
-                }
-                else if (parameter.TryGetAttribute<FromHeaderAttribute>(out var fromHeader))
-                {
-                    var name = fromHeader.Name.IsNotEmpty() ? fromHeader.Name! : parameter.Name!;
-                    addChainBoundParameter(apiDescription, name, parameter.ParameterType, BindingSource.Header);
-                }
+        if (isBoundFromRouteValue(parameter))
+        {
+            return false;
+        }
+
+        if (parameter.TryGetAttribute<FromQueryAttribute>(out var fromQuery))
+        {
+            if (CodeGen.FromQueryAttributeUsage.IsComplexQueryStringType(parameter.ParameterType))
+            {
+                return false;
             }
+
+            name = fromQuery.Name.IsNotEmpty() ? fromQuery.Name! : parameter.Name!;
+            source = BindingSource.Query;
+            return true;
         }
+
+        if (parameter.TryGetAttribute<FromHeaderAttribute>(out var fromHeader))
+        {
+            name = fromHeader.Name.IsNotEmpty() ? fromHeader.Name! : parameter.Name!;
+            source = BindingSource.Header;
+            return true;
+        }
+
+        return false;
     }
 
     /// <summary>


Test patch:
diff --git a/src/Http/Wolverine.Http.Tests.DifferentAssembly/OpenApiShapeEndpoints.cs b/src/Http/Wolverine.Http.Tests.DifferentAssembly/OpenApiShapeEndpoints.cs
index 48b62bbd7..0cfd2589a 100644
--- a/src/Http/Wolverine.Http.Tests.DifferentAssembly/OpenApiShapeEndpoints.cs
+++ b/src/Http/Wolverine.Http.Tests.DifferentAssembly/OpenApiShapeEndpoints.cs
@@ -72,6 +72,31 @@ public static void After([FromQuery] string? audit)
     }
 }
 
+// GH-3601: the postprocessor description path is now the single place that derives from the method signature,
+// so exercise its non-query branches too. A [FromHeader] on a postprocessor must render as a header parameter.
+public static class PostprocessorHeaderEndpoint
+{
+    [WolverineGet("/shapes/postprocessor-header")]
+    public static string Get() => "ok";
+
+    public static void After([FromHeader(Name = "x-audit")] string? audit)
+    {
+    }
+}
+
+// GH-3601: a postprocessor [FromQuery] whose name collides with a route segment on a route-bindable type is
+// claimed by the route (bound from the route value), so it must render ONLY the single Path parameter and
+// never a duplicate query parameter — the route-collision decision lives in tryDescribePostprocessorBinding.
+public static class PostprocessorRouteCollisionEndpoint
+{
+    [WolverineGet("/shapes/postprocessor-collision/{orderId:long}")]
+    public static string Get() => "ok";
+
+    public static void After([FromQuery] long orderId)
+    {
+    }
+}
+
 #endregion
 
 #region baseline shapes: plain handler signature bindings
diff --git a/src/Http/Wolverine.Http.Tests/openapi_shape_tests.cs b/src/Http/Wolverine.Http.Tests/openapi_shape_tests.cs
index aae5021b5..f12f661ed 100644
--- a/src/Http/Wolverine.Http.Tests/openapi_shape_tests.cs
+++ b/src/Http/Wolverine.Http.Tests/openapi_shape_tests.cs
@@ -134,6 +134,23 @@ public void query_values_bound_only_by_a_postprocessor_are_declared()
             ]);
     }
 
+    // GH-3601: header values bound only by a postprocessor are declared as header parameters.
+    [Fact]
+    public void header_values_bound_only_by_a_postprocessor_are_declared()
+    {
+        ParametersFor("/shapes/postprocessor-header", "get")
+            .ShouldBe([new ParameterShape("x-audit", "header", false, "string", null)]);
+    }
+
+    // GH-3601: a postprocessor [FromQuery] colliding with a route segment is route-bound, so the operation
+    // carries exactly ONE parameter (the path one) and never a duplicate query parameter.
+    [Fact]
+    public void route_colliding_postprocessor_query_is_not_duplicated()
+    {
+        ParametersFor("/shapes/postprocessor-collision/{orderId}", "get")
+            .ShouldBe([new ParameterShape("orderId", "path", true, "integer", "int64")]);
+    }
+
     [Fact]
     public void plain_handler_signature_route_and_query_bindings()
     {