diff --git a/docs/guide/http/routing.md b/docs/guide/http/routing.md index 2a4a659ac..645e4432f 100644 --- a/docs/guide/http/routing.md +++ b/docs/guide/http/routing.md @@ -110,6 +110,69 @@ public static StrongLetterAggregate Handle( snippet source | anchor +## Route Prefixes + +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: diff --git a/src/Http/Wolverine.Http.Tests/RoutePrefixTests.cs b/src/Http/Wolverine.Http.Tests/RoutePrefixTests.cs new file mode 100644 index 000000000..1a424c327 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/RoutePrefixTests.cs @@ -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(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(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(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(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(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(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(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(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(() => 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(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"; +} diff --git a/src/Http/Wolverine.Http/HttpChain.cs b/src/Http/Wolverine.Http/HttpChain.cs index 4b094de06..504d8117a 100644 --- a/src/Http/Wolverine.Http/HttpChain.cs +++ b/src/Http/Wolverine.Http/HttpChain.cs @@ -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 { diff --git a/src/Http/Wolverine.Http/HttpGraph.cs b/src/Http/Wolverine.Http/HttpGraph.cs index 1e497c3a2..88af58f06 100644 --- a/src/Http/Wolverine.Http/HttpGraph.cs +++ b/src/Http/Wolverine.Http/HttpGraph.cs @@ -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(); foreach (var policy in policies) policy.Apply(_chains, Rules, Container); diff --git a/src/Http/Wolverine.Http/RoutePrefixAttribute.cs b/src/Http/Wolverine.Http/RoutePrefixAttribute.cs new file mode 100644 index 000000000..276f9a956 --- /dev/null +++ b/src/Http/Wolverine.Http/RoutePrefixAttribute.cs @@ -0,0 +1,16 @@ +namespace Wolverine.Http; + +/// +/// 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. +/// +[AttributeUsage(AttributeTargets.Class)] +public class RoutePrefixAttribute : Attribute +{ + public string Prefix { get; } + + public RoutePrefixAttribute(string prefix) + { + Prefix = prefix?.Trim('/') ?? throw new ArgumentNullException(nameof(prefix)); + } +} diff --git a/src/Http/Wolverine.Http/RoutePrefixPolicy.cs b/src/Http/Wolverine.Http/RoutePrefixPolicy.cs new file mode 100644 index 000000000..770defe11 --- /dev/null +++ b/src/Http/Wolverine.Http/RoutePrefixPolicy.cs @@ -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 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(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); + } +} diff --git a/src/Http/Wolverine.Http/WolverineHttpOptions.cs b/src/Http/Wolverine.Http/WolverineHttpOptions.cs index 214fc137e..8564515a9 100644 --- a/src/Http/Wolverine.Http/WolverineHttpOptions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpOptions.cs @@ -191,6 +191,34 @@ public void UseDataAnnotationsValidationProblemDetailMiddleware() public List ResourceWriterPolicies { get; } = new(); + internal string? GlobalRoutePrefix { get; set; } + internal List<(string Prefix, string Namespace)> NamespacePrefixes { get; } = new(); + + /// + /// 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". + /// + /// The route prefix to prepend + public void RoutePrefix(string prefix) + { + GlobalRoutePrefix = prefix?.Trim('/') ?? throw new ArgumentNullException(nameof(prefix)); + } + + /// + /// 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. + /// + /// The route prefix to prepend + /// The namespace to match against endpoint handler types + public void RoutePrefix(string prefix, string forEndpointsInNamespace) + { + ArgumentNullException.ThrowIfNull(prefix); + ArgumentNullException.ThrowIfNull(forEndpointsInNamespace); + NamespacePrefixes.Add((prefix.Trim('/'), forEndpointsInNamespace)); + } + /// /// Configure built in tenant id detection strategies ///