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}";
+ }
+}
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;