Skip to content
Open
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
42 changes: 42 additions & 0 deletions docs/guide/http/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,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
Original file line number Diff line number Diff line change
@@ -1,29 +1,71 @@
using System.Globalization;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection;
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)
{
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);
// The OnStarting callback inside WriteAsync re-resolves the writer from RequestServices
// (so the lambda can stay static and avoid per-request boxing). The test container must
// therefore expose the same singleton instance the production container would.
var services = new ServiceCollection();
services.AddSingleton(writer);
ctx.RequestServices = services.BuildServiceProvider();

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.
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 +74,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 +101,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 +118,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 +135,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 +152,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 +172,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 +194,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 +225,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 +253,49 @@ 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();
}

// 10 — fail-fast when ApiVersionHeaderWriter is absent from RequestServices.
// Bootstrap registers the writer as a singleton, so a missing registration is a programmer
// error (e.g. a custom IServiceProviderFactory that did not propagate it). The OnStarting
// callback re-resolves the writer via GetRequiredService — null was historically swallowed,
// making lost headers invisible. Pinning the throw prevents regression to silent failure.
[Fact]
public async Task missing_writer_in_request_services_throws()
{
var opts = new WolverineApiVersioningOptions();
opts.Sunset("1.0").On(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));

var writer = new ApiVersionHeaderWriter(opts);
var state = new ApiVersionEndpointHeaderState(
new ApiVersion(1, 0),
opts.SunsetPolicies[new ApiVersion(1, 0)],
null);

// Build a context whose RequestServices does NOT contain the writer.
var 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);
ctx.RequestServices = new ServiceCollection().BuildServiceProvider(); // empty container
var endpoint = new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(state), "test");
ctx.SetEndpoint(endpoint);

await writer.WriteAsync(ctx); // schedules OnStarting; should not throw here.

await Should.ThrowAsync<InvalidOperationException>(async () =>
{
await FlushOnStartingAsync(ctx);
});
}
}
Loading
Loading