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;