Skip to content
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Alba" Version="8.5.2" />
<PackageVersion Include="Asp.Versioning.Abstractions" Version="10.0.0" />
<PackageVersion Include="Asp.Versioning.Abstractions" Version="[10.0.0,11.0.0)" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.22.1" />
<PackageVersion Include="AWSSDK.SimpleNotificationService" Version="4.0.2.14" />
<PackageVersion Include="AWSSDK.SQS" Version="4.0.2.14" />
Expand Down
145 changes: 134 additions & 11 deletions docs/guide/http/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ versioning entirely through its own `IHttpPolicy` pipeline, so there is no confl
`AddApiVersioning()` registration, and no additional ASP.NET Core middleware is needed.

::: info
This release supports **URL-segment versioning** (e.g. `/v1/...`, `/v2/...`). Each endpoint declares a single
`[ApiVersion]`; multi-version handlers via `[MapToApiVersion]` are not supported.
This release supports **URL-segment versioning** (e.g. `/v1/...`, `/v2/...`) and multi-version handlers
via repeated `[ApiVersion]` attributes or `[MapToApiVersion]`. See [Multi-version handlers](#multi-version-handlers).
:::

## Quick Start
Expand Down Expand Up @@ -72,12 +72,86 @@ public static class OrdersV2Endpoint
}
```

### One version per endpoint
### Multi-version handlers

Declaring **multiple** `[ApiVersion]` attributes on the same handler method is not supported in v1. The resolver
picks the first attribute encountered and ignores any additional ones. If you have two incompatible response shapes
for the same resource, create separate endpoint classes — one per version — as shown in the sample app
(`OrdersV1Endpoint`, `OrdersV2Endpoint`, `OrdersV3PreviewEndpoint`).
A single handler method can serve multiple API versions by repeating `[ApiVersion]` attributes either at the
method or class level. Wolverine expands the chain at bootstrap into one HTTP endpoint per declared version,
with the URL-segment prefix, sunset / deprecation policies, OpenAPI group name, and `ApiVersionMetadata`
applied per clone. Duplicate-route detection runs across the *expanded* set so two handlers serving the same
`(verb, route, version)` triple still fail fast with a descriptive error.

#### Method-level multi-version

```csharp
public static class OrdersHandler
{
[WolverineGet("/orders")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public static OrdersResponse Get() => new(["a", "b"]);
}
```

This produces both `/v1/orders` and `/v2/orders` and registers both in the OpenAPI documents.

#### Class-level multi-version

```csharp
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
[ApiVersion("3.0")]
public static class CustomersEndpoint
{
[WolverineGet("/customers")]
public static CustomersResponse Get() => new(["alice", "bob"]);
}
```

Per-version deprecation propagates to the matching clone only: in this example `/v1/customers` carries the
`Deprecation` response header while `/v2/customers` and `/v3/customers` do not.

#### `[MapToApiVersion]` — opt a method into a subset of class versions

When a class declares many versions but a particular method only applies to a subset, decorate the method
with `[MapToApiVersion]` instead of redeclaring `[ApiVersion]`:

```csharp
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[ApiVersion("3.0")]
public static class CustomersEndpoint
{
// Lives at /v2/customers/v2-only — v1 and v3 are NOT registered for this method.
[WolverineGet("/customers/v2-only")]
[MapToApiVersion("2.0")]
public static CustomersResponse Get() => new(["v2-only"]);
}
```

Resolution rules:

- **Method-level `[ApiVersion]` overrides class-level entirely.** If both are present, only the method
attribute is read; class-level versions are ignored for that method.
- **Method-level `[MapToApiVersion]` filters class-level versions.** Every version listed must also appear
at class level, otherwise startup fails fast with an exception naming both the method and the class.
- **A method may not carry both `[ApiVersion]` and `[MapToApiVersion]`.** Pick one — `[ApiVersion]` declares
versions independently of the class, `[MapToApiVersion]` selects from them. Mixing the two on one method
triggers a startup exception.

#### Per-version metadata semantics

When a chain is expanded into clones, every per-version policy is applied to its respective clone:

| Per-clone state | Source |
|---|---|
| `RoutePattern` | Rewritten with `UrlSegmentPrefix` for that clone's version |
| `ApiVersionMetadata` | Built per clone: `DeclaredApiVersions` is the single clone version; `SupportedApiVersions` and `DeprecatedApiVersions` are the union of all sibling clones at the same `(verb, route-without-version-prefix)` |
| `OperationId` | Inherited from the source chain plus a `_v{sanitised-version-string}` suffix — every non-alphanumeric character is replaced with `_`, so `2.0` → `_v2_0` and date-based versions like `2024-01-01` → `_v2024_01_01` |
| `IEndpointGroupNameMetadata` | `OpenApi.DocumentNameStrategy(clone.ApiVersion)` |
| `DeprecationPolicy` | `[ApiVersion(..., Deprecated = true)]` for that version, or `Deprecate("X.Y")` from options |
| `SunsetPolicy` | `Sunset("X.Y")` from options |
| Response headers (`Deprecation`, `Sunset`, `Link`, `api-supported-versions`) | Per the policies attached to that clone |
| `Endpoint.Metadata` `[ApiVersion]` / `[MapToApiVersion]` | Filtered to only the clone's own version — sibling-version attributes are scrubbed so OpenAPI tooling reports each clone as implementing exactly one declared version |

### Marking a version as deprecated via attribute

Expand Down Expand Up @@ -301,6 +375,48 @@ When a versioned endpoint is matched, Wolverine emits response headers according
| `Sunset` | RFC 8594 | Endpoints with a sunset date configured |
| `Link` | RFC 8288 | Endpoints with link references on either policy |

Headers are emitted on **every framework-produced response**, regardless of HTTP status code. That
includes:

- 2xx success responses
- 4xx responses returned via `Results.NotFound()`, `Results.Unauthorized()`, etc.
- 400 validation `ProblemDetails` responses produced by FluentValidation or DataAnnotations middleware
- Middleware short-circuits that return an `IResult` (e.g. an authentication guard returning `Results.Unauthorized()`)

Internally, Wolverine registers the headers via `HttpResponse.OnStarting(...)` from the very first
frame of the chain's middleware list, so emission is correct even when a downstream frame returns
out of the generated handler before the success path completes.

::: warning Exception path is out of scope
Responses produced by the global ASP.NET Core exception handler (e.g. an unhandled exception caught
by `UseExceptionHandler` or `UseDeveloperExceptionPage`) bypass the chain's pipeline entirely, so the
versioning headers are **not** emitted on those responses. If you need them on 5xx, attach a small
ASP.NET Core middleware on the exception path that reads the matched endpoint's
`ApiVersionEndpointHeaderState` metadata and delegates header emission to the registered
`ApiVersionHeaderWriter` so the source of truth (RFC formatting, options toggles) stays in one place:

```csharp
using Microsoft.Extensions.DependencyInjection;

app.Use(async (ctx, next) =>
{
ctx.Response.OnStarting(static state =>
{
var c = (HttpContext)state;
if (c.Response.StatusCode < 500) return Task.CompletedTask;

var endpointState = c.GetEndpoint()?.Metadata.GetMetadata<ApiVersionEndpointHeaderState>();
if (endpointState is null) return Task.CompletedTask;

var writer = c.RequestServices.GetRequiredService<ApiVersionHeaderWriter>();
writer.WriteVersioningHeadersTo(c, endpointState);
return Task.CompletedTask;
}, ctx);
await next();
});
```
:::

Toggle the headers globally:

```csharp
Expand Down Expand Up @@ -417,7 +533,14 @@ assembly scan, must carry `[ApiVersion]`. Switch to `PassThrough` (the default)
remain unversioned, or add the attribute. The exception message lists the offending endpoint by its display name.

**Multiple `[ApiVersion]` attributes on the same method.**
Only the first attribute is used; subsequent attributes are silently ignored. If you need to expose a resource at
two different versions, create separate endpoint classes — one per version — sharing the same route template. The
duplicate-detection step in `ApiVersioningPolicy` will catch any `(verb, route, version)` triple that appears
more than once and throw a descriptive `InvalidOperationException` at startup.
Wolverine expands the chain at bootstrap into one HTTP endpoint per declared version. See
[Multi-version handlers](#multi-version-handlers). Duplicate detection still applies across the expanded set
and rejects any `(verb, route, version)` triple that appears more than once.

**`[MapToApiVersion]` lists a version that the class does not declare.**
Startup throws an `InvalidOperationException` naming both the method and the declaring class. Add the missing
version to the class-level `[ApiVersion]` list, or remove it from `[MapToApiVersion]`.

**Both `[ApiVersion]` and `[MapToApiVersion]` on the same method.**
Startup throws. Use only one: `[ApiVersion]` declares versions independently of the class; `[MapToApiVersion]`
selects from class-level versions.
Original file line number Diff line number Diff line change
@@ -1,29 +1,67 @@
using System.Globalization;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Shouldly;
using Wolverine.Http.ApiVersioning;

namespace Wolverine.Http.Tests.ApiVersioning;

public class ApiVersionHeaderWriterTests
{
// Helper: build a DefaultHttpContext that has the given state attached as endpoint metadata.
private static DefaultHttpContext ContextWithState(ApiVersionEndpointHeaderState state)
// Test response feature that captures OnStarting callbacks so we can fire them deterministically.
private sealed class CapturingResponseFeature : HttpResponseFeature
{
var ctx = new DefaultHttpContext();
var endpoint = new Endpoint(
_ => Task.CompletedTask,
new EndpointMetadataCollection(state),
"test");
ctx.SetEndpoint(endpoint);
public List<(Func<object, Task> Callback, object State)> StartingCallbacks { get; } = new();

public override void OnStarting(Func<object, Task> callback, object state)
=> StartingCallbacks.Add((callback, state));

public override void OnCompleted(Func<object, Task> callback, object state) { }
}

private static DefaultHttpContext BuildContext(
ApiVersionEndpointHeaderState? state,
ApiVersionHeaderWriter writer,
out CapturingResponseFeature feature)
{
_ = writer;
feature = new CapturingResponseFeature { Headers = new HeaderDictionary() };
var features = new FeatureCollection();
features.Set<IHttpResponseFeature>(feature);
features.Set<IHttpResponseBodyFeature>(new StreamResponseBodyFeature(Stream.Null));
features.Set<IHttpRequestFeature>(new HttpRequestFeature());

var ctx = new DefaultHttpContext(features);

if (state is not null)
{
var endpoint = new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(state), "test");
ctx.SetEndpoint(endpoint);
}
return ctx;
}

// Helper: build a DefaultHttpContext with NO endpoint state.
private static DefaultHttpContext ContextWithNoState()
private static DefaultHttpContext ContextWithState(ApiVersionEndpointHeaderState state, ApiVersionHeaderWriter writer)
=> BuildContext(state, writer, out _);

private static DefaultHttpContext ContextWithNoState(ApiVersionHeaderWriter writer)
=> BuildContext(null, writer, out _);

// Drain the captured OnStarting callbacks so the in-memory header dictionary reflects what would
// be flushed to the client. WriteAsync registers a callback rather than writing synchronously.
// Both overloads of OnStarting (callback-only and callback+state) route through the
// (Func<object, Task>, object) overload internally, so this drain handles both forms.
private static async Task FlushOnStartingAsync(HttpContext ctx)
{
return new DefaultHttpContext();
var feature = ctx.Features.Get<IHttpResponseFeature>();
if (feature is CapturingResponseFeature capturing)
{
foreach (var (callback, state) in capturing.StartingCallbacks)
{
await callback(state);
}
}
}

// 1 — no state → no headers emitted
Expand All @@ -32,9 +70,10 @@ public async Task no_state_metadata_emits_no_headers()
{
var opts = new WolverineApiVersioningOptions();
var writer = new ApiVersionHeaderWriter(opts);
var ctx = ContextWithNoState();
var ctx = ContextWithNoState(writer);

await writer.WriteAsync(ctx);
await FlushOnStartingAsync(ctx);

ctx.Response.Headers.ContainsKey("api-supported-versions").ShouldBeFalse();
ctx.Response.Headers.ContainsKey("Deprecation").ShouldBeFalse();
Expand All @@ -58,9 +97,10 @@ public async Task api_supported_versions_includes_sunset_and_deprecation_keys()
new ApiVersion(1, 0),
opts.SunsetPolicies[new ApiVersion(1, 0)],
null);
var ctx = ContextWithState(state);
var ctx = ContextWithState(state, writer);

await writer.WriteAsync(ctx);
await FlushOnStartingAsync(ctx);

ctx.Response.Headers["api-supported-versions"].ToString().ShouldBe("1.0, 2.0");
}
Expand All @@ -74,9 +114,10 @@ public async Task deprecation_with_date_uses_imf_fixdate()
var depPolicy = new DeprecationPolicy(depDate);
var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), null, depPolicy);
var writer = new ApiVersionHeaderWriter(opts);
var ctx = ContextWithState(state);
var ctx = ContextWithState(state, writer);

await writer.WriteAsync(ctx);
await FlushOnStartingAsync(ctx);

var expected = depDate.UtcDateTime.ToString("R", CultureInfo.InvariantCulture);
ctx.Response.Headers["Deprecation"].ToString().ShouldBe(expected);
Expand All @@ -90,9 +131,10 @@ public async Task deprecation_without_date_emits_true_token()
var depPolicy = new DeprecationPolicy();
var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), null, depPolicy);
var writer = new ApiVersionHeaderWriter(opts);
var ctx = ContextWithState(state);
var ctx = ContextWithState(state, writer);

await writer.WriteAsync(ctx);
await FlushOnStartingAsync(ctx);

ctx.Response.Headers["Deprecation"].ToString().ShouldBe("true");
}
Expand All @@ -106,9 +148,10 @@ public async Task sunset_emits_imf_fixdate()
var sunsetPolicy = new SunsetPolicy(sunsetDate);
var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), sunsetPolicy, null);
var writer = new ApiVersionHeaderWriter(opts);
var ctx = ContextWithState(state);
var ctx = ContextWithState(state, writer);

await writer.WriteAsync(ctx);
await FlushOnStartingAsync(ctx);

var expected = sunsetDate.UtcDateTime.ToString("R", CultureInfo.InvariantCulture);
ctx.Response.Headers["Sunset"].ToString().ShouldBe(expected);
Expand All @@ -125,9 +168,10 @@ public async Task single_link_with_sunset_rel()
var opts = new WolverineApiVersioningOptions();
var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), sunsetPolicy, null);
var writer = new ApiVersionHeaderWriter(opts);
var ctx = ContextWithState(state);
var ctx = ContextWithState(state, writer);

await writer.WriteAsync(ctx);
await FlushOnStartingAsync(ctx);

ctx.Response.Headers["Link"].ToString()
.ShouldBe("<https://example.com/info>; rel=\"sunset\"; title=\"Info\"; type=\"text/html\"");
Expand All @@ -146,9 +190,10 @@ public async Task multiple_links_are_comma_separated()
var opts = new WolverineApiVersioningOptions();
var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), sunsetPolicy, null);
var writer = new ApiVersionHeaderWriter(opts);
var ctx = ContextWithState(state);
var ctx = ContextWithState(state, writer);

await writer.WriteAsync(ctx);
await FlushOnStartingAsync(ctx);

var linkHeader = ctx.Response.Headers["Link"].ToString();
linkHeader.ShouldContain("<https://example.com/first>; rel=\"sunset\"");
Expand Down Expand Up @@ -176,9 +221,10 @@ public async Task disabled_emit_deprecation_headers_skips_deprecation_sunset_and

var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), sunsetPolicy, depPolicy);
var writer = new ApiVersionHeaderWriter(opts);
var ctx = ContextWithState(state);
var ctx = ContextWithState(state, writer);

await writer.WriteAsync(ctx);
await FlushOnStartingAsync(ctx);

// api-supported-versions should still be present
ctx.Response.Headers.ContainsKey("api-supported-versions").ShouldBeTrue();
Expand All @@ -203,12 +249,14 @@ public async Task disabled_emit_supported_versions_skips_supported_versions()
var depPolicy = opts.DeprecationPolicies[new ApiVersion(1, 0)];
var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), null, depPolicy);
var writer = new ApiVersionHeaderWriter(opts);
var ctx = ContextWithState(state);
var ctx = ContextWithState(state, writer);

await writer.WriteAsync(ctx);
await FlushOnStartingAsync(ctx);

ctx.Response.Headers.ContainsKey("api-supported-versions").ShouldBeFalse();
// Deprecation still fires
ctx.Response.Headers.ContainsKey("Deprecation").ShouldBeTrue();
}

}
Loading
Loading