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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

## Unreleased

### WolverineFx.Http

- Added native API versioning support via `Asp.Versioning.Abstractions` 10.x. Supports URL-segment versioning
(`/v1/...`, `/v2/...`), sunset/deprecation policies with RFC 9745/8594/8288 response headers, and automatic
OpenAPI document partitioning with Swashbuckle/Scalar/Microsoft.AspNetCore.OpenApi. No dependency on
`Asp.Versioning.Http` — versioning is driven entirely via `IHttpPolicy`.
See [versioning guide](docs/guide/http/versioning.md).
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Alba" Version="8.5.2" />
<PackageVersion Include="Asp.Versioning.Abstractions" Version="10.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 Expand Up @@ -83,6 +84,7 @@
<PackageVersion Include="RavenDB.TestDriver" Version="7.0.2" />
<PackageVersion Include="Refit" Version="8.0.0" />
<PackageVersion Include="Refit.HttpClientFactory" Version="8.0.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.14.8" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="Spectre.Console" Version="0.55.0" />
<PackageVersion Include="StackExchange.Redis" Version="2.9.17" />
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ const config: UserConfig<DefaultTheme.Config> = {
{text: 'Exception Handling', link: '/guide/http/exception-handling'},
{text: 'Policies', link: '/guide/http/policies.md'},
{text: 'OpenAPI Metadata', link: '/guide/http/metadata'},
{text: 'API Versioning', link: '/guide/http/versioning'},
{text: 'Using as Mediator', link: '/guide/http/mediator'},
{text: 'Multi-Tenancy and ASP.Net Core', link: '/guide/http/multi-tenancy'},
{text: 'Publishing Messages', link: '/guide/http/messaging'},
Expand Down
6 changes: 6 additions & 0 deletions docs/guide/http/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,4 +273,10 @@ leads to code that is hard to reason about and hence, potentially buggy in real
need this functionality in the real world, so here you go.
:::

## API Versioning <Badge type="tip" text="5.36" />

Wolverine.Http has native support for versioning your HTTP APIs over time using URL-segment strategies (e.g.
`/v1/orders`, `/v2/orders`), per-version sunset and deprecation policies with RFC 9745/8594 response headers,
and automatic OpenAPI document partitioning. See the [HTTP API Versioning guide](./versioning.md) for full details.


6 changes: 6 additions & 0 deletions docs/guide/http/metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ public static void Configure(HttpChain chain)
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/Wolverine.Http/Runtime/PublishingEndpoint.cs#L15-L33' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_programmatic_one_off_openapi_metadata' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

::: tip
For HTTP API versioning that partitions the OpenAPI output into one document per version, see the
[Versioning guide](./versioning.md). It covers the multi-document `SwaggerDoc` setup, `DocInclusionPredicate`,
`DescribeWolverineApiVersions()`, and Scalar integration.
:::

## Swashbuckle and Wolverine

[Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) is de facto the OpenAPI tooling for ASP.Net Core
Expand Down
319 changes: 319 additions & 0 deletions docs/guide/http/versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
# HTTP API Versioning <Badge type="tip" text="5.36" />

Wolverine.Http provides native API versioning support that lets you evolve your HTTP services over time without
breaking existing clients. Versioned endpoints coexist at separate URL prefixes (e.g. `/v1/orders` and `/v2/orders`),
carry the correct OpenAPI metadata, and can emit RFC 9745 `Deprecation` and RFC 8594 `Sunset` response headers to
signal planned end-of-life to callers.

The feature depends on the `Asp.Versioning.Abstractions` 10.x NuGet package — the thin, framework-neutral abstraction
layer. It does **not** require `Asp.Versioning.Http` (the ASP.NET Core-specific middleware pack). Wolverine drives
versioning entirely through its own `IHttpPolicy` pipeline, so there is no conflict with an existing
`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.
:::

## Quick Start

**1. Add the package** (if not already present via `WolverineFx.Http`):

```bash
dotnet add package Asp.Versioning.Abstractions
```

**2. Decorate your endpoint class or method:**

```csharp
using Asp.Versioning;
using Wolverine.Http;

[ApiVersion("1.0")]
public static class OrdersV1Endpoint
{
[WolverineGet("/orders", OperationId = "OrdersV1Endpoint.Get")]
public static OrdersV1Response Get() => new(["order-a", "order-b"]);
}

public record OrdersV1Response(IReadOnlyList<string> Orders);
```

**3. Enable versioning inside `MapWolverineEndpoints`:**

```csharp
app.MapWolverineEndpoints(opts =>
{
opts.UseApiVersioning(v =>
{
// All options are optional — the defaults work out of the box.
v.UnversionedPolicy = UnversionedPolicy.PassThrough;
});
});
```

With these two pieces in place Wolverine will rewrite the route to `/v1/orders` and attach the appropriate
`ApiVersionMetadata` and OpenAPI group name automatically.

## Declaring Versions

### Attribute placement

Apply `[ApiVersion]` to the endpoint class or to the handler method. **Method-level wins over class-level:**
if both declare a version, the method attribute is used.

```csharp
// Class-level — all methods in this class are v2.
[ApiVersion("2.0")]
public static class OrdersV2Endpoint
{
[WolverineGet("/orders")]
public static OrdersV2Response Get() => new("ok", ["v2-a", "v2-b"]);
}
```

### One version per endpoint

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`).

### Marking a version as deprecated via attribute

The `Deprecated` property on `[ApiVersion]` is honoured and will automatically attach a deprecation policy to
the chain:

```csharp
[ApiVersion("1.0", Deprecated = true)]
public static class LegacyOrdersEndpoint
{
[WolverineGet("/orders")]
public static OrdersV1Response Get() => new([]);
}
```

When this attribute is present, Wolverine emits a `Deprecation: true` response header (RFC 9745) and marks the
OpenAPI operation as deprecated without any additional configuration.

## URL-Segment Versioning

### Default behaviour

By default, Wolverine prepends `v{major}` to every versioned route. Given `[ApiVersion("2.0")]` and the route
`/orders`, the live URL becomes `/v2/orders`.

### Customising the URL segment prefix

The prefix template is controlled by `UrlSegmentPrefix` (default `"v{version}"`). The `{version}` token is
**required** — omitting it causes a startup exception (see [Troubleshooting](#troubleshooting)):

```csharp
opts.UseApiVersioning(v =>
{
// Changes /v2/orders → /api/v2/orders
v.UrlSegmentPrefix = "api/v{version}";
});
```

Set `UrlSegmentPrefix = null` to disable URL-segment versioning entirely. In that case the original route is
kept unchanged and no prefix is injected.

### Version format in the URL

The `UrlSegmentVersionFormatter` callback controls how an `ApiVersion` is turned into the string substituted
into the prefix. The default formats `ApiVersion(2, 0)` as `"2"` (major-only), giving clean URLs like `/v2/`.

Override it to emit `major.minor` format:

```csharp
opts.UseApiVersioning(v =>
{
// Produces /v2.0/orders instead of /v2/orders
v.UrlSegmentVersionFormatter = apiVersion =>
apiVersion.MajorVersion.HasValue
? $"{apiVersion.MajorVersion}.{apiVersion.MinorVersion ?? 0}"
: apiVersion.ToString();
});
```

For date-based versions where `MajorVersion` is `null`, the default formatter falls back to `ApiVersion.ToString()`,
which may include hyphens (e.g. `2024-01-01`). Override the formatter if your URL scheme needs a different shape.

## Unversioned-Endpoint Policy

When `UseApiVersioning` is active, every endpoint that lacks an `[ApiVersion]` attribute is subject to the
`UnversionedPolicy`. Three behaviours are available:

| Policy | Behaviour |
|---|---|
| `PassThrough` *(default)* | Unversioned endpoints stay at their declared route with no version metadata. They coexist alongside versioned endpoints. |
| `RequireExplicit` | Bootstrap throws an `InvalidOperationException` if any endpoint is missing `[ApiVersion]`. Recommended for greenfield APIs that should be fully versioned from day one. |
| `AssignDefault` | All unversioned endpoints are silently promoted to `DefaultVersion`. Set `DefaultVersion` when using this mode. |

```csharp
// RequireExplicit — every endpoint must carry [ApiVersion]
opts.UseApiVersioning(v =>
{
v.UnversionedPolicy = UnversionedPolicy.RequireExplicit;
});

// AssignDefault — migrate an existing unversioned API to a v1 baseline
opts.UseApiVersioning(v =>
{
v.UnversionedPolicy = UnversionedPolicy.AssignDefault;
v.DefaultVersion = new ApiVersion(1, 0);
});
```

## Sunset and Deprecation Policies

Beyond the attribute-driven `Deprecated = true` flag, you can configure per-version sunset and deprecation
policies with dates and RFC 8288 link references directly in the options:

```csharp
opts.UseApiVersioning(v =>
{
// Announce that v1.0 is deprecated as of a specific date with a migration link.
v.Deprecate("1.0")
.On(DateTimeOffset.Parse("2026-12-31T00:00:00Z"))
.WithLink(new Uri("https://example.com/migration-guide"));

// Announce that v3.0 will sunset (be removed) on a future date.
v.Sunset("3.0")
.On(DateTimeOffset.Parse("2027-01-01T00:00:00Z"))
.WithLink(new Uri("https://example.com/migrate-from-v3"), "Migration guide", "text/html");
});
```

### Response headers

When a versioned endpoint is matched, Wolverine emits response headers according to the applicable RFCs:

| Header | RFC | When emitted |
|---|---|---|
| `api-supported-versions` | — | All versioned endpoints (toggleable via `EmitApiSupportedVersionsHeader`) |
| `Deprecation` | RFC 9745 | Endpoints with a deprecation policy |
| `Sunset` | RFC 8594 | Endpoints with a sunset date configured |
| `Link` | RFC 8288 | Endpoints with link references on either policy |

Toggle the headers globally:

```csharp
opts.UseApiVersioning(v =>
{
v.EmitApiSupportedVersionsHeader = false; // suppress the supported-versions header
v.EmitDeprecationHeaders = false; // suppress Deprecation/Sunset/Link headers
});
```

## OpenAPI Integration

Wolverine attaches two pieces of metadata to every versioned chain during bootstrapping:

- `IEndpointGroupNameMetadata` — the document name string (e.g. `"v1"`, `"v2"`) used by Swashbuckle, Scalar,
and Microsoft.AspNetCore.OpenApi to partition the OpenAPI output.
- `Asp.Versioning.ApiVersionMetadata` — the typed version model consumed by `Asp.Versioning.Http` tooling if
it is also present in the application.

### Customising the document name strategy

The default strategy maps `ApiVersion(2, 0)` → `"v2"`. Override it via:

```csharp
opts.UseApiVersioning(v =>
{
// Use "api-v2" as the Swashbuckle document name for version 2.x
v.OpenApi.DocumentNameStrategy = apiVersion =>
apiVersion.MajorVersion.HasValue
? $"api-v{apiVersion.MajorVersion}"
: apiVersion.ToString();
});
```

### Swashbuckle multi-document setup

Register one `SwaggerDoc` per version. Use `DocInclusionPredicate` to route each endpoint to the correct document
via the group name Wolverine has set. Register `WolverineApiVersioningSwaggerOperationFilter` to surface sunset
and deprecation state in the OpenAPI output:

```csharp
builder.Services.AddSwaggerGen(x =>
{
x.SwaggerDoc("v1", new OpenApiInfo { Title = "API v1", Version = "v1" });
x.SwaggerDoc("v2", new OpenApiInfo { Title = "API v2", Version = "v2" });

// Route each endpoint to the document whose name matches the endpoint group name.
x.DocInclusionPredicate((doc, api) => api.GroupName == doc);

// Surfaces deprecation / sunset state in the OpenAPI output.
x.OperationFilter<WolverineApiVersioningSwaggerOperationFilter>();
});
```

`WolverineApiVersioningSwaggerOperationFilter` is **not distributed as a NuGet library**. Copy it from the sample
app (`src/Http/WolverineWebApi/ApiVersioning/WolverineApiVersioningSwaggerOperationFilter.cs`) into your own
project and register it as shown above.

### SwaggerUI version dropdown

Use `DescribeWolverineApiVersions()` to enumerate the discovered versions and wire each into the SwaggerUI
endpoint list:

```csharp
app.UseSwagger();
app.UseSwaggerUI(c =>
{
// Wire in non-versioned endpoints under a "default" document if you have them.
c.SwaggerEndpoint("/swagger/default/swagger.json", "default");

foreach (var description in app.DescribeWolverineApiVersions())
{
c.SwaggerEndpoint(
$"/swagger/{description.DocumentName}/swagger.json",
description.DisplayName);
}
});
```

`DescribeWolverineApiVersions()` returns an empty list when versioning is not configured or no versioned
endpoints have been discovered, so the loop is always safe to call.

### Scalar integration

Scalar follows the same pattern. Register one document per version, then iterate `DescribeWolverineApiVersions()`:

```csharp
app.MapScalarApiReference(opts =>
{
opts.Title = "My API";
foreach (var description in app.DescribeWolverineApiVersions())
opts.AddDocument(description.DocumentName, description.DisplayName);
});
```

### Microsoft.AspNetCore.OpenApi

When using the built-in `Microsoft.AspNetCore.OpenApi` package, Wolverine's group name is surfaced as
`ApiDescription.GroupName` in `ApiDescriptionGroupCollection`. The standard ASP.NET Core document filter
mechanism can be used to partition endpoints by group name in exactly the same way as Swashbuckle's
`DocInclusionPredicate`.

## Troubleshooting

**`UrlSegmentPrefix` is set but the route is unchanged.**
Check that the prefix string contains the literal `{version}` token. If no `{version}` is present and there are
versioned endpoints, Wolverine throws at startup:

> `WolverineApiVersioningOptions.UrlSegmentPrefix is set to 'myprefix' which does not contain the required '{version}' token. ... Set UrlSegmentPrefix to null to disable URL-segment versioning, or include '{version}' in the prefix template.`

**All my existing endpoints stopped resolving after I turned on `RequireExplicit`.**
`RequireExplicit` means every endpoint, including infrastructure or health-check endpoints discovered by Wolverine's
assembly scan, must carry `[ApiVersion]`. Switch to `PassThrough` (the default) for any endpoints that should
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.
Loading
Loading