Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions .github/workflows/http.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ jobs:
- name: Run HTTP Tests
run: ./build.sh CIHttp --framework net9.0

# Asp.Versioning tests are pinned to net10.0
- name: Run HTTP Asp.Versioning Tests
run: ./build.sh CIHttpAspVersioning

- name: Stop containers
if: always()
run: docker compose down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<ItemGroup>
<PackageVersion Include="Alba" Version="8.5.2" />
<PackageVersion Include="Asp.Versioning.Abstractions" Version="[10.0.0,11.0.0)" />
<PackageVersion Include="Asp.Versioning.Http" Version="[10.0.0,11.0.0)" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" 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
10 changes: 10 additions & 0 deletions build/CITargets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,16 @@ void BuildTestProjectsWithFramework(string frameworkOverride, params AbsolutePat
.EnableNoBuild());
});

Target CIHttpAspVersioning => _ => _
.Executes(() =>
{
var tests = RootDirectory / "src" / "Http" / "Wolverine.Http.AspVersioning.Tests" / "Wolverine.Http.AspVersioning.Tests.csproj";

BuildTestProjectsWithFramework("net10.0", tests);

RunTestProject(tests, frameworkOverride: "net10.0");
});

Target CIRabbitMQ => _ => _
.ProceedAfterFailure()
.Executes(() =>
Expand Down
1 change: 1 addition & 0 deletions build/build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ partial class Build : NukeBuild
Solution.Extensions.Wolverine_Newtonsoft,
Solution.Extensions.Wolverine_Protobuf,
Solution.Http.Wolverine_Http,
Solution.Http.Wolverine_Http_AspVersioning,
Solution.Http.Wolverine_Http_Newtonsoft,
Solution.Http.Wolverine_Http_FluentValidation,
Solution.Http.Wolverine_Http_Marten,
Expand Down
146 changes: 146 additions & 0 deletions docs/guide/http/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ This release supports **URL-segment versioning** (e.g. `/v1/...`, `/v2/...`) and
via repeated `[ApiVersion]` attributes or `[MapToApiVersion]`. See [Multi-version handlers](#multi-version-handlers).
:::

If you need the full `Asp.Versioning.Http` feature set — its request-time matcher, version reporter, and
API explorer — or you are migrating an existing Asp.Versioning integration to Wolverine, use the
`WolverineFx.Http.AspVersioning` bridge instead. See
[Asp.Versioning.Http Package Support](#asp-versioning-http-package-support).

## Quick Start

**1. Add the package** (if not already present via `WolverineFx.Http`):
Expand Down Expand Up @@ -544,3 +549,144 @@ version to the class-level `[ApiVersion]` list, or remove it from `[MapToApiVers
**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.

## Asp.Versioning.Http Package Support <Badge type="tip" text="6.17" />

Everything above is Wolverine's **native** versioning, which depends only on `Asp.Versioning.Abstractions`
and supports URL-segment versioning with minimal runtime overhead. If you'd prefer to version your
endpoints using [`Asp.Versioning.Http`](https://github.com/dotnet/aspnet-api-versioning) or are migrating
an existing Asp.Versioning codebase and want identical behavior, Wolverine offers a dedicated
`WolverineFx.Http.AspVersioning` bridge package to support that integration.

::: warning
This integration currently requires **.NET 10 or later** — the `WolverineFx.Http.AspVersioning` package
targets `net10.0` only.
:::

::: tip Prefer native versioning unless you need the full Asp.Versioning feature set
`Asp.Versioning.Http` registers a request-time `ApiVersionMatcherPolicy` and wraps every matched
endpoint's delegate with response decorators, so it carries real per-request cost. Wolverine's native
[`UseApiVersioning`](#quick-start) does not register the `Asp.Versioning.Http` runtime stack, so it
generally performs better and should be favored by teams that do not require full Asp.Versioning
functionality.
:::

### Decision Matrix

| | Native `UseApiVersioning()` | Bridge `UseAspVersioning()` |
|---------------------|-------------------------------------------------|------------------------------------------------------|
| Dependency | `Asp.Versioning.Abstractions` only | full `Asp.Versioning.Http` |
| Target frameworks | net9.0 / net10.0 | **net10.0 only** |
| Version readers | URL-segment only | query, header, media-type, URL-segment, combined |
| URL-segment routes | auto-rewrites `/orders` → `/v1/orders` | you author `{version:apiVersion}` (no auto prefix) |
| Runtime cost | no request-time matcher or response decorators | Asp.Versioning matcher policy + response decorators |
| Configured via | `opts.UseApiVersioning(v => …)` | your own `AddApiVersioning(…)` |

In short: prefer native for URL-segment versioning at the lowest overhead (or on .NET 9); reach for
the bridge when you need header/query/media-type readers, the rest of the Asp.Versioning feature set,
or a behavior-identical migration of an existing Asp.Versioning app.

### Basic Usage

**1. Install the package:**

```bash
dotnet add package WolverineFx.Http.AspVersioning
```

**2. Register Asp.Versioning and enable the bridge.** Unlike native versioning, you register
`AddApiVersioning(...)` yourself — the bridge delegates entirely to *your* Asp.Versioning configuration
(version reader, reporting, sunset policies, and so on):

```csharp
using Wolverine.Http.AspVersioning;

// Your own Asp.Versioning configuration — reader, reporting, defaults, etc.
builder.Services.AddApiVersioning(options =>
{
options.ReportApiVersions = true;
});

builder.Services.AddWolverineHttp();

// ...

app.MapWolverineEndpoints(opts => opts.UseAspVersioning());
```

::: warning
Wolverine will throw an exception if `UseApiVersioning()` and `UseAspVersioning()` are both used in
the same application — choose one or the other.
:::

**3. Version your endpoints with the usual attributes.** The bridge understands `[ApiVersion]`,
`[MapToApiVersion]`, `[AdvertiseApiVersions]`, and `[ApiVersionNeutral]` with the same
method-overrides-class precedence as native versioning:

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

[ApiVersion("2.0")]
public static class OrdersV2Endpoint
{
// Same route as v1 — Asp.Versioning selects between them by the requested version.
[WolverineGet("/orders")]
public static OrdersV2Response Get() => new("ok", ["v2-a", "v2-b"]);
}
```

With this configuration `GET /orders?api-version=1.0` reaches the v1 endpoint and
`GET /orders?api-version=2.0` reaches the v2 endpoint.

### URL-segment versioning

URL-segment versioning works exactly as it does in a vanilla Asp.Versioning minimal API: author the
version as a `{version:apiVersion}` route parameter and configure a URL-segment (or combined) reader via
`AddApiVersioning`. Unlike native versioning, the bridge does **not** synthesize a `/v1/` prefix — you
write the segment yourself, and endpoints declaring different versions share the one template:

```csharp
[ApiVersion("1.0")]
public static class OrdersV1Endpoint
{
[WolverineGet("/v{version:apiVersion}/orders")]
public static OrdersV1Response Get() => new(["order-a", "order-b"]);
}

[ApiVersion("2.0")]
public static class OrdersV2Endpoint
{
[WolverineGet("/v{version:apiVersion}/orders")]
public static OrdersV2Response Get() => new("ok", ["v2-a", "v2-b"]);
}
```

`GET /v1/orders` reaches the v1 endpoint and `GET /v2/orders` the v2 endpoint. Set
`AddApiExplorer(o => o.SubstituteApiVersionInUrl = true)` to render concrete versions (`/v1/orders`) in
the generated OpenAPI documents.

### OpenAPI

Asp.Versioning's API explorer is offered as a separate package (`Asp.Versioning.Mvc.ApiExplorer`)
and is not included as a dependency of `WolverineFx.Http.AspVersioning`. Once added to your project,
it can be configured exactly as if you were building a minimal API. For example:

```csharp
builder.Services
.AddApiVersioning(options => options.ReportApiVersions = true)
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = false;
});
```

::: warning
Do not mix in the native [document-name strategy](#openapi-integration) or `DescribeWolverineApiVersions()`;
those belong to the native versioning path described above.
:::
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System.Reflection;
using Asp.Versioning;
using Shouldly;

namespace Wolverine.Http.AspVersioning.Tests;

internal class NoAdvertisedHandler
{
public void Handle() { }
}

[AdvertiseApiVersions("1.0")]
internal class ClassOnlyAdvertisedHandler
{
public void Handle() { }
}

internal class MethodOnlyAdvertisedHandler
{
[AdvertiseApiVersions("2.0")]
public void Handle() { }
}

[AdvertiseApiVersions("4.0", "5.0")]
internal class MultipleAdvertisedHandler
{
[AdvertiseApiVersions("1.0", "2.0")]
[AdvertiseApiVersions("3.0")]
public void Handle() { }
}

[AdvertiseApiVersions("1.0", Deprecated = true)]
[AdvertiseApiVersions("2.0")]
[AdvertiseApiVersions("4.0", Deprecated = true)]
internal class DeprecatedAdvertisedHandler
{
[AdvertiseApiVersions("1.0")]
[AdvertiseApiVersions("2.0", Deprecated = true)]
[AdvertiseApiVersions("3.0")]
public void Handle() { }
}

public class AdvertisedVersionResolverTests
{
private static MethodInfo MethodOf<T>(string name = "Handle") =>
typeof(T).GetMethod(name, BindingFlags.Public | BindingFlags.Instance)!;

[Fact]
public void no_attribute_returns_empty()
{
var method = MethodOf<NoAdvertisedHandler>(nameof(NoAdvertisedHandler.Handle));
AdvertisedVersionResolver.ResolveAdvertised(method).ShouldBeEmpty();
}

[Fact]
public void class_attribute_resolves()
{
var method = MethodOf<ClassOnlyAdvertisedHandler>(
nameof(ClassOnlyAdvertisedHandler.Handle)
);
var result = AdvertisedVersionResolver.ResolveAdvertised(method);
result.ShouldHaveSingleItem();
result[0].Version.ShouldBe(new ApiVersion(1, 0));
result[0].IsDeprecated.ShouldBeFalse();
}

[Fact]
public void method_attribute_resolves()
{
var method = MethodOf<MethodOnlyAdvertisedHandler>(
nameof(MethodOnlyAdvertisedHandler.Handle)
);
var result = AdvertisedVersionResolver.ResolveAdvertised(method);
result.ShouldHaveSingleItem();
result[0].Version.ShouldBe(new ApiVersion(2, 0));
result[0].IsDeprecated.ShouldBeFalse();
}

[Fact]
public void multiple_attributes_merge()
{
var method = MethodOf<MultipleAdvertisedHandler>(nameof(MultipleAdvertisedHandler.Handle));
var result = AdvertisedVersionResolver.ResolveAdvertised(method);
result
.Select(r => r.Version)
.ShouldBe(Enumerable.Range(1, 5).Select(i => new ApiVersion(i, 0)));
}

[Fact]
public void deprecations_merge_and_propagate()
{
var method = MethodOf<DeprecatedAdvertisedHandler>(
nameof(DeprecatedAdvertisedHandler.Handle)
);
var result = AdvertisedVersionResolver.ResolveAdvertised(method);
result
.Select(r => r.Version)
.ShouldBe(Enumerable.Range(1, 4).Select(i => new ApiVersion(i, 0)));
result.Single(r => r.Version == new ApiVersion(1, 0)).IsDeprecated.ShouldBeTrue();
result.Single(r => r.Version == new ApiVersion(2, 0)).IsDeprecated.ShouldBeTrue();
result.Single(r => r.Version == new ApiVersion(3, 0)).IsDeprecated.ShouldBeFalse();
result.Single(r => r.Version == new ApiVersion(4, 0)).IsDeprecated.ShouldBeTrue();
}
}
Loading