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
///