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
63 changes: 63 additions & 0 deletions docs/guide/http/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,69 @@ public static StrongLetterAggregate Handle(
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Marten/StrongTypedIdentifiers.cs#L11-L22' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_strong_typed_id_as_route_argument' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Route Prefixes <Badge type="tip" text="5.14" />

Wolverine.HTTP supports route prefix groups to automatically prepend a common prefix to your endpoint routes. This is useful for API versioning, organizing endpoints under a common base path, or grouping related endpoints.

### Global Route Prefix

Use the `RoutePrefix` method on `WolverineHttpOptions` to set a prefix that applies to all Wolverine HTTP endpoints:

```cs
app.MapWolverineEndpoints(opts =>
{
// All endpoints will be prefixed with /api
// e.g., /orders becomes /api/orders
opts.RoutePrefix("api");
});
```

### Namespace-Specific Prefix

You can target endpoints by their handler class namespace:

```cs
app.MapWolverineEndpoints(opts =>
{
// Only endpoints in this namespace (and child namespaces)
// will get the prefix
opts.RoutePrefix("api/orders",
forEndpointsInNamespace: "MyApp.Features.Orders");

opts.RoutePrefix("api/customers",
forEndpointsInNamespace: "MyApp.Features.Customers");
});
```

When multiple namespace prefixes could match (e.g., `MyApp` and `MyApp.Features`), the most specific (longest) matching namespace wins.

### `[RoutePrefix]` Attribute

You can also apply a prefix to all endpoints in a specific class using the `[RoutePrefix]` attribute:

```cs
[RoutePrefix("api/v2")]
public class OrderEndpoints
{
// Route becomes /api/v2/orders
[WolverineGet("/orders")]
public string GetOrders() => "orders";

// Route becomes /api/v2/orders/{id}
[WolverineGet("/orders/{id}")]
public string GetOrder(int id) => $"order {id}";
}
```

### Precedence

When multiple prefix sources are configured, the following precedence applies (most specific wins):

1. `[RoutePrefix]` attribute on the endpoint class
2. Namespace-specific prefix (most specific namespace match)
3. Global prefix

Only one prefix is applied per endpoint -- they do not stack.
## Route Prefixes

You can apply a common URL prefix to all endpoints in a handler class using the `[RoutePrefix]` attribute:
Expand Down
182 changes: 182 additions & 0 deletions src/Http/Wolverine.Http.Tests/RoutePrefixTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
using Shouldly;
using Wolverine.Http;

namespace Wolverine.Http.Tests;

public class RoutePrefixTests
{
[Fact]
public void global_prefix_applied_to_all_endpoints()
{
var options = new WolverineHttpOptions();
options.RoutePrefix("api");

var chain = HttpChain.ChainFor<RoutePrefixTestEndpoints>(x => x.GetItems());
chain.RoutePattern!.RawText.ShouldBe("/items");

var policy = new RoutePrefixPolicy(options);
var prefix = policy.DeterminePrefix(chain);
prefix.ShouldBe("api");

RoutePrefixPolicy.PrependPrefix(chain, prefix!);
chain.RoutePattern!.RawText.ShouldBe("/api/items");
}

[Fact]
public void namespace_specific_prefix()
{
var options = new WolverineHttpOptions();
options.RoutePrefix("api/v1", forEndpointsInNamespace: "Wolverine.Http.Tests");

var chain = HttpChain.ChainFor<RoutePrefixTestEndpoints>(x => x.GetItems());

var policy = new RoutePrefixPolicy(options);
var prefix = policy.DeterminePrefix(chain);
prefix.ShouldBe("api/v1");

RoutePrefixPolicy.PrependPrefix(chain, prefix!);
chain.RoutePattern!.RawText.ShouldBe("/api/v1/items");
}

[Fact]
public void namespace_prefix_does_not_match_unrelated_namespace()
{
var options = new WolverineHttpOptions();
options.RoutePrefix("api/orders", forEndpointsInNamespace: "SomeOther.Namespace");

var chain = HttpChain.ChainFor<RoutePrefixTestEndpoints>(x => x.GetItems());

var policy = new RoutePrefixPolicy(options);
var prefix = policy.DeterminePrefix(chain);
// Should fall back to null since no global prefix and namespace doesn't match
prefix.ShouldBeNull();
}

[Fact]
public void namespace_prefix_with_global_fallback()
{
var options = new WolverineHttpOptions();
options.RoutePrefix("api");
options.RoutePrefix("api/orders", forEndpointsInNamespace: "SomeOther.Namespace");

var chain = HttpChain.ChainFor<RoutePrefixTestEndpoints>(x => x.GetItems());

var policy = new RoutePrefixPolicy(options);
var prefix = policy.DeterminePrefix(chain);
// Should fall back to global prefix since namespace doesn't match
prefix.ShouldBe("api");
}

[Fact]
public void attribute_prefix_on_class_takes_precedence()
{
var options = new WolverineHttpOptions();
options.RoutePrefix("api");
options.RoutePrefix("api/orders", forEndpointsInNamespace: "Wolverine.Http.Tests");

var chain = HttpChain.ChainFor<PrefixedEndpoints>(x => x.GetOrders());

var policy = new RoutePrefixPolicy(options);
var prefix = policy.DeterminePrefix(chain);
// Should use the attribute prefix, not the namespace or global one
prefix.ShouldBe("v2/orders");
}

[Fact]
public void no_prefix_when_none_configured()
{
var options = new WolverineHttpOptions();

var chain = HttpChain.ChainFor<RoutePrefixTestEndpoints>(x => x.GetItems());

var policy = new RoutePrefixPolicy(options);
var prefix = policy.DeterminePrefix(chain);
prefix.ShouldBeNull();
}

[Fact]
public void handles_leading_and_trailing_slashes_in_global_prefix()
{
var options = new WolverineHttpOptions();
options.RoutePrefix("/api/");

options.GlobalRoutePrefix.ShouldBe("api");
}

[Fact]
public void handles_leading_and_trailing_slashes_in_namespace_prefix()
{
var options = new WolverineHttpOptions();
options.RoutePrefix("/api/orders/", forEndpointsInNamespace: "MyApp");

options.NamespacePrefixes[0].Prefix.ShouldBe("api/orders");
}

[Fact]
public void prepend_prefix_handles_route_with_leading_slash()
{
var chain = HttpChain.ChainFor<RoutePrefixTestEndpoints>(x => x.GetItems());
chain.RoutePattern!.RawText.ShouldBe("/items");

RoutePrefixPolicy.PrependPrefix(chain, "api");
chain.RoutePattern!.RawText.ShouldBe("/api/items");
}

[Fact]
public void prepend_prefix_handles_route_with_parameters()
{
var chain = HttpChain.ChainFor<RoutePrefixTestEndpoints>(x => x.GetItem(0));
chain.RoutePattern!.RawText.ShouldBe("/items/{id}");

RoutePrefixPolicy.PrependPrefix(chain, "api");
chain.RoutePattern!.RawText.ShouldBe("/api/items/{id}");
chain.RoutePattern.Parameters.Count.ShouldBe(1);
chain.RoutePattern.Parameters[0].Name.ShouldBe("id");
}

[Fact]
public void attribute_prefix_is_trimmed()
{
var attr = new RoutePrefixAttribute("/v2/orders/");
attr.Prefix.ShouldBe("v2/orders");
}

[Fact]
public void attribute_prefix_throws_on_null()
{
Should.Throw<ArgumentNullException>(() => new RoutePrefixAttribute(null!));
}

[Fact]
public void most_specific_namespace_wins()
{
var options = new WolverineHttpOptions();
options.RoutePrefix("api", forEndpointsInNamespace: "Wolverine");
options.RoutePrefix("api/http", forEndpointsInNamespace: "Wolverine.Http");
options.RoutePrefix("api/http/tests", forEndpointsInNamespace: "Wolverine.Http.Tests");

var chain = HttpChain.ChainFor<RoutePrefixTestEndpoints>(x => x.GetItems());

var policy = new RoutePrefixPolicy(options);
var prefix = policy.DeterminePrefix(chain);
// Should match the most specific (longest) namespace
prefix.ShouldBe("api/http/tests");
}
}

// Test handler types
public class RoutePrefixTestEndpoints
{
[WolverineGet("/items")]
public string GetItems() => "items";

[WolverineGet("/items/{id}")]
public string GetItem(int id) => $"item {id}";
}

[RoutePrefix("v2/orders")]
public class PrefixedEndpoints
{
[WolverineGet("/orders")]
public string GetOrders() => "orders";
}
2 changes: 1 addition & 1 deletion src/Http/Wolverine.Http/HttpChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ internal void MapToRoute(string method, string url, int? order = null, string? d
}

[IgnoreDescription]
public RoutePattern? RoutePattern { get; private set; }
public RoutePattern? RoutePattern { get; internal set; }

public Type? RequestType
{
Expand Down
5 changes: 5 additions & 0 deletions src/Http/Wolverine.Http/HttpGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ public void DiscoverEndpoints(WolverineHttpOptions wolverineHttpOptions)
wolverineHttpOptions.Middleware.Apply(_chains, Rules, Container);
_optionsWriterPolicies.AddRange(wolverineHttpOptions.ResourceWriterPolicies);

// Apply route prefix policy before other policies so that
// downstream policies see the final route patterns
var routePrefixPolicy = new RoutePrefixPolicy(wolverineHttpOptions);
routePrefixPolicy.Apply(_chains, Rules, Container);

var policies = _options.Policies.OfType<IChainPolicy>();
foreach (var policy in policies) policy.Apply(_chains, Rules, Container);

Expand Down
16 changes: 16 additions & 0 deletions src/Http/Wolverine.Http/RoutePrefixAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Wolverine.Http;

/// <summary>
/// Apply a route prefix to all Wolverine HTTP endpoints within the decorated class.
/// The prefix will be prepended to all route patterns defined by endpoint methods in the class.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class RoutePrefixAttribute : Attribute
{
public string Prefix { get; }

public RoutePrefixAttribute(string prefix)
{
Prefix = prefix?.Trim('/') ?? throw new ArgumentNullException(nameof(prefix));
}
}
73 changes: 73 additions & 0 deletions src/Http/Wolverine.Http/RoutePrefixPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using JasperFx;
using JasperFx.CodeGeneration;
using JasperFx.Core.Reflection;
using Microsoft.AspNetCore.Routing.Patterns;

namespace Wolverine.Http;

internal class RoutePrefixPolicy : IHttpPolicy
{
private readonly WolverineHttpOptions _options;

public RoutePrefixPolicy(WolverineHttpOptions options)
{
_options = options;
}

public void Apply(IReadOnlyList<HttpChain> chains, GenerationRules rules, IServiceContainer container)
{
// Nothing to do if no prefixes are configured
if (_options.GlobalRoutePrefix == null && _options.NamespacePrefixes.Count == 0)
{
return;
}

foreach (var chain in chains)
{
if (chain.RoutePattern == null) continue;

var prefix = DeterminePrefix(chain);
if (prefix == null) continue;

PrependPrefix(chain, prefix);
}
}

internal string? DeterminePrefix(HttpChain chain)
{
// 1. Check for [RoutePrefix] attribute on the handler type (most specific)
if (chain.Method.HandlerType.TryGetAttribute<RoutePrefixAttribute>(out var attr))
{
return attr.Prefix;
}

// 2. Check for namespace-specific prefix
var handlerNamespace = chain.Method.HandlerType.Namespace;
if (handlerNamespace != null)
{
// Find the most specific (longest) matching namespace prefix
var match = _options.NamespacePrefixes
.Where(np => handlerNamespace == np.Namespace || handlerNamespace.StartsWith(np.Namespace + "."))
.OrderByDescending(np => np.Namespace.Length)
.FirstOrDefault();

if (match.Prefix != null)
{
return match.Prefix;
}
}

// 3. Fall back to global prefix
return _options.GlobalRoutePrefix;
}

internal static void PrependPrefix(HttpChain chain, string prefix)
{
var currentRoute = chain.RoutePattern!.RawText ?? string.Empty;
var trimmedRoute = currentRoute.TrimStart('/');
var newRoute = $"/{prefix}/{trimmedRoute}".TrimEnd('/');

// Re-parse the route pattern with the new prefix
chain.RoutePattern = RoutePatternFactory.Parse(newRoute);
}
}
28 changes: 28 additions & 0 deletions src/Http/Wolverine.Http/WolverineHttpOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,34 @@ public void UseDataAnnotationsValidationProblemDetailMiddleware()

public List<IResourceWriterPolicy> ResourceWriterPolicies { get; } = new();

internal string? GlobalRoutePrefix { get; set; }
internal List<(string Prefix, string Namespace)> NamespacePrefixes { get; } = new();

/// <summary>
/// Set a global route prefix that will be prepended to all Wolverine HTTP endpoint routes.
/// For example, RoutePrefix("api") will turn "/orders" into "/api/orders".
/// </summary>
/// <param name="prefix">The route prefix to prepend</param>
public void RoutePrefix(string prefix)
{
GlobalRoutePrefix = prefix?.Trim('/') ?? throw new ArgumentNullException(nameof(prefix));
}

/// <summary>
/// Set a route prefix for all Wolverine HTTP endpoints whose handler type
/// is in the specified namespace (or a child namespace).
/// For example, RoutePrefix("api/orders", forEndpointsInNamespace: "MyApp.Features.Orders")
/// will prefix all endpoints in that namespace.
/// </summary>
/// <param name="prefix">The route prefix to prepend</param>
/// <param name="forEndpointsInNamespace">The namespace to match against endpoint handler types</param>
public void RoutePrefix(string prefix, string forEndpointsInNamespace)
{
ArgumentNullException.ThrowIfNull(prefix);
ArgumentNullException.ThrowIfNull(forEndpointsInNamespace);
NamespacePrefixes.Add((prefix.Trim('/'), forEndpointsInNamespace));
}

/// <summary>
/// Configure built in tenant id detection strategies
/// </summary>
Expand Down
Loading