Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions repro/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<Project>
</Project>
18 changes: 18 additions & 0 deletions repro/README.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions repro/kebab-cased-route-parameter.cs
Original file line number Diff line number Diff line change
@@ -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<Task<string>> 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}";
}
}
97 changes: 97 additions & 0 deletions src/Http/Wolverine.Http.Tests/named_route_parameter_binding.cs
Original file line number Diff line number Diff line change
@@ -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<IAlbaHost> 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}";
}
}

13 changes: 13 additions & 0 deletions src/Http/Wolverine.Http/CodeGen/RouteHandling.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,6 +53,17 @@ internal class RouteParameterStrategy : IParameterStrategy

public bool TryMatch(HttpChain chain, IServiceContainer container, ParameterInfo parameter, out Variable? variable)
{
if (parameter.TryGetAttribute<FromRouteAttribute>(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);
}

Expand Down
3 changes: 2 additions & 1 deletion src/Http/Wolverine.Http/HttpChain.ApiDescription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodeGen.HttpElementVariable>()
.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
Expand Down
4 changes: 2 additions & 2 deletions src/Http/Wolverine.Http/HttpChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpElementVariable>()
.FirstOrDefault(x => x.VariableType == variableType && x.Name.EqualsIgnoreCase(routeOrParameterName));
if (matched is not null)
{
variable = matched;
Expand Down
Loading