From ede5545d823478297b1e497cedc7c95b00948ccd Mon Sep 17 00:00:00 2001 From: Geoffrey MARC Date: Fri, 10 Jul 2026 17:18:39 +0200 Subject: [PATCH 1/2] fix(http): honor the Name on [FromRoute] for endpoint method parameters [FromRoute(Name = "my-id")] was only read by AsParametersBindingFrame. On a plain endpoint method parameter the attribute name was ignored: matching fell through to the parameter name, missed the route token, and QueryStringParameterStrategy silently bound the argument from the query string instead. A kebab-cased route token could not be bound at all, since it is not a legal C# identifier. RouteParameterStrategy now resolves the route argument by the attribute name when one is given, and throws when no such route argument exists rather than binding from somewhere else. Route variables are also now looked up by their route key rather than by their sanitized C# usage, so a route token containing '-', '.' or '/' de-duplicates correctly and carries its real type onto the generated OpenAPI parameter. --- .../named_route_parameter_binding.cs | 97 +++++++++++++++++++ .../Wolverine.Http/CodeGen/RouteHandling.cs | 13 +++ .../HttpChain.ApiDescription.cs | 3 +- src/Http/Wolverine.Http/HttpChain.cs | 4 +- 4 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 src/Http/Wolverine.Http.Tests/named_route_parameter_binding.cs diff --git a/src/Http/Wolverine.Http.Tests/named_route_parameter_binding.cs b/src/Http/Wolverine.Http.Tests/named_route_parameter_binding.cs new file mode 100644 index 000000000..d5fbbbd58 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/named_route_parameter_binding.cs @@ -0,0 +1,97 @@ +using Alba; +using IntegrationTests; +using Marten; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Wolverine.Marten; + +namespace Wolverine.Http.Tests; + +public class named_route_parameter_binding +{ + private static async Task hostFor(Type endpointType) + { + var builder = WebApplication.CreateBuilder([]); + + builder.Services.AddMarten(opts => + { + opts.Connection(Servers.PostgresConnectionString); + opts.DisableNpgsqlLogging = true; + }).IntegrateWithWolverine(); + + builder.Host.UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(endpointType); + opts.ApplicationAssembly = typeof(named_route_parameter_binding).Assembly; + }); + + builder.Services.AddWolverineHttp(); + + return await AlbaHost.For(builder, app => app.MapWolverineEndpoints()); + } + + [Fact] + public async Task bind_kebab_cased_route_argument_to_a_method_parameter() + { + await using var host = await hostFor(typeof(NamedRouteEndpoints)); + + await host.Scenario(x => + { + x.Get.Url("/named-route/direct/42"); + x.ContentShouldBe("42"); + }); + } + + [Fact] + public async Task bind_kebab_cased_route_argument_to_a_string_method_parameter() + { + await using var host = await hostFor(typeof(NamedRouteEndpoints)); + + await host.Scenario(x => + { + x.Get.Url("/named-route/direct-string/Aubrey"); + x.ContentShouldBe("Aubrey"); + }); + } + + [Fact] + public async Task bind_kebab_cased_route_argument_through_as_parameters_with_a_body() + { + await using var host = await hostFor(typeof(NamedRouteEndpoints)); + + await host.Scenario(x => + { + x.Post.Json(new NamedRouteBody("Maverick")).ToUrl("/named-route/as-parameters/11"); + x.ContentShouldBe("11:Maverick"); + }); + } + +} + +public record NamedRouteBody(string Name); + +public record NamedRouteRequest([FromRoute(Name = "my-id")] int Id, [FromBody] NamedRouteBody Body); + +public static class NamedRouteEndpoints +{ + [WolverineGet("/named-route/direct/{my-id}")] + public static string Direct([FromRoute(Name = "my-id")] int id) + { + return id.ToString(); + } + + [WolverineGet("/named-route/direct-string/{my-name}")] + public static string DirectString([FromRoute(Name = "my-name")] string name) + { + return name; + } + + [WolverinePost("/named-route/as-parameters/{my-id}")] + public static string WithBody([AsParameters] NamedRouteRequest request) + { + return $"{request.Id}:{request.Body.Name}"; + } +} + diff --git a/src/Http/Wolverine.Http/CodeGen/RouteHandling.cs b/src/Http/Wolverine.Http/CodeGen/RouteHandling.cs index 4e9f5da73..d38df3e7d 100644 --- a/src/Http/Wolverine.Http/CodeGen/RouteHandling.cs +++ b/src/Http/Wolverine.Http/CodeGen/RouteHandling.cs @@ -4,8 +4,10 @@ using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; using JasperFx.CodeGeneration.Model; +using JasperFx.Core; using JasperFx.Core.Reflection; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Wolverine.Runtime; namespace Wolverine.Http.CodeGen; @@ -51,6 +53,17 @@ internal class RouteParameterStrategy : IParameterStrategy public bool TryMatch(HttpChain chain, IServiceContainer container, ParameterInfo parameter, out Variable? variable) { + if (parameter.TryGetAttribute(out var att) && att.Name.IsNotEmpty()) + { + if (chain.FindRouteVariable(parameter.ParameterType, att.Name!, out variable)) + { + return true; + } + + throw new InvalidOperationException( + $"Unable to find a route argument '{att.Name}' specified on parameter '{parameter.Name}' of {chain.Method.HandlerType.FullNameInCode()}.{chain.Method.Method.Name}()"); + } + return chain.FindRouteVariable(parameter, out variable); } diff --git a/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs b/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs index 52ceb699e..2f9e7d377 100644 --- a/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs +++ b/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs @@ -452,7 +452,8 @@ private ApiParameterDescription buildParameterDescription(RoutePatternParameterP // lower-cased (e.g. {journeyId}) while the bound member/argument is PascalCased // (JourneyId). A case-sensitive match here silently misses and falls back to string, // losing the real type (e.g. Guid/int) on the generated OpenAPI parameter. See GH-3135. - var variable = _routeVariables.FirstOrDefault(x => x.Usage.EqualsIgnoreCase(routeParameter.Name)); + var variable = _routeVariables.OfType() + .FirstOrDefault(x => x.Name.EqualsIgnoreCase(routeParameter.Name)); // When no typed argument is bound to the route value (e.g. a plain complex-body endpoint // whose body property overlaps a route token, or an aggregate-id route), fall back to the diff --git a/src/Http/Wolverine.Http/HttpChain.cs b/src/Http/Wolverine.Http/HttpChain.cs index 7c4d81037..93409940b 100644 --- a/src/Http/Wolverine.Http/HttpChain.cs +++ b/src/Http/Wolverine.Http/HttpChain.cs @@ -788,8 +788,8 @@ public bool FindRouteVariable(ParameterInfo parameter, [NotNullWhen(true)]out Va public bool FindRouteVariable(Type variableType, string routeOrParameterName, [NotNullWhen(true)]out Variable? variable) { - var matched = - _routeVariables.FirstOrDefault(x => x.VariableType == variableType && x.Usage.EqualsIgnoreCase(routeOrParameterName)); + var matched = _routeVariables.OfType() + .FirstOrDefault(x => x.VariableType == variableType && x.Name.EqualsIgnoreCase(routeOrParameterName)); if (matched is not null) { variable = matched; From 2948c89470312e8d80e55bc332b9d04b5105f8aa Mon Sep 17 00:00:00 2001 From: Geoffrey MARC Date: Fri, 10 Jul 2026 17:24:51 +0200 Subject: [PATCH 2/2] test(http): add a file-based repro app for kebab-cased route arguments --- repro/Directory.Build.props | 2 + repro/README.md | 18 +++++++ repro/kebab-cased-route-parameter.cs | 81 ++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 repro/Directory.Build.props create mode 100644 repro/README.md create mode 100644 repro/kebab-cased-route-parameter.cs diff --git a/repro/Directory.Build.props b/repro/Directory.Build.props new file mode 100644 index 000000000..8c119d541 --- /dev/null +++ b/repro/Directory.Build.props @@ -0,0 +1,2 @@ + + diff --git a/repro/README.md b/repro/README.md new file mode 100644 index 000000000..ce6f39ba3 --- /dev/null +++ b/repro/README.md @@ -0,0 +1,18 @@ +# Reproductions + +Self-verifying [file-based apps](https://learn.microsoft.com/dotnet/core/whats-new/dotnet-10/sdk#file-based-apps). +Each prints `PASS`/`FAIL` per assertion and exits non-zero on failure: + +```bash +dotnet run repro/kebab-cased-route-parameter.cs +``` + +`Directory.Build.props` is intentionally empty — it stops the repo-wide +props (multi-targeting, warnings-as-errors, versioning) from reaching these +single-file apps. + +## kebab-cased-route-parameter.cs + +`[FromRoute(Name = "journey-id")]` on an endpoint method parameter. Against +`main` the first assertion fails with `0`, because the attribute's name was +ignored and the argument was bound from the query string instead. diff --git a/repro/kebab-cased-route-parameter.cs b/repro/kebab-cased-route-parameter.cs new file mode 100644 index 000000000..522a9dc64 --- /dev/null +++ b/repro/kebab-cased-route-parameter.cs @@ -0,0 +1,81 @@ +#:sdk Microsoft.NET.Sdk.Web +#:project ../src/Http/Wolverine.Http/Wolverine.Http.csproj +#:project ../src/Wolverine.RuntimeCompilation/Wolverine.RuntimeCompilation.csproj +#:property TargetFramework=net10.0 +#:property PublishAot=false +#:property JsonSerializerIsReflectionEnabledByDefault=true + +using System.Net.Http.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Wolverine; +using Wolverine.Http; + +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls("http://127.0.0.1:5399"); +builder.Host.UseWolverine(); +builder.Services.AddWolverineHttp(); + +var app = builder.Build(); +app.MapWolverineEndpoints(); + +await app.StartAsync(); + +using var client = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5399") }; + +var failures = 0; + +await expect("GET /journeys/42/legs", "42", () => client.GetStringAsync("/journeys/42/legs")); + +await expect("POST /journeys/7/passengers", "7:Maverick", async () => +{ + var response = await client.PostAsJsonAsync("/journeys/7/passengers", new AddPassengerPayload("Maverick")); + return await response.Content.ReadAsStringAsync(); +}); + +await app.StopAsync(); + +return failures; + +async Task expect(string description, string expected, Func> call) +{ + string actual; + try + { + actual = await call(); + } + catch (Exception e) + { + actual = $"<{e.GetType().Name}: {e.Message}>"; + } + + if (actual == expected) + { + Console.WriteLine($"PASS {description} -> \"{actual}\""); + } + else + { + failures++; + Console.WriteLine($"FAIL {description} -> expected \"{expected}\", got \"{actual}\""); + } +} + +public record AddPassengerPayload(string Name); + +public record AddPassengerCommand([FromRoute(Name = "journey-id")] int JourneyId, [FromBody] AddPassengerPayload Payload); + +public static class JourneyEndpoints +{ + [WolverineGet("/journeys/{journey-id}/legs")] + public static string CountLegs([FromRoute(Name = "journey-id")] int journeyId) + { + return journeyId.ToString(); + } + + [WolverinePost("/journeys/{journey-id}/passengers")] + public static string AddPassenger([AsParameters] AddPassengerCommand command) + { + return $"{command.JourneyId}:{command.Payload.Name}"; + } +}