diff --git a/docs/guide/http/versioning.md b/docs/guide/http/versioning.md index a982ba108..4ad84975c 100644 --- a/docs/guide/http/versioning.md +++ b/docs/guide/http/versioning.md @@ -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(); + if (endpointState is null) return Task.CompletedTask; + + var writer = c.RequestServices.GetRequiredService(); + writer.WriteVersioningHeadersTo(c, endpointState); + return Task.CompletedTask; + }, ctx); + await next(); +}); +``` +::: + Toggle the headers globally: ```csharp diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs index 6e1f3078d..7a9f03986 100644 --- a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs @@ -1,6 +1,8 @@ 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; @@ -8,22 +10,62 @@ 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 Callback, object State)> StartingCallbacks { get; } = new(); + + public override void OnStarting(Func callback, object state) + => StartingCallbacks.Add((callback, state)); + + public override void OnCompleted(Func 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(feature); + features.Set(new StreamResponseBodyFeature(Stream.Null)); + features.Set(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(); + if (feature is CapturingResponseFeature capturing) + { + foreach (var (callback, state) in capturing.StartingCallbacks) + { + await callback(state); + } + } } // 1 — no state → no headers emitted @@ -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(); @@ -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"); } @@ -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); @@ -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"); } @@ -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); @@ -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("; rel=\"sunset\"; title=\"Info\"; type=\"text/html\""); @@ -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("; rel=\"sunset\""); @@ -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(); @@ -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(feature); + features.Set(new StreamResponseBodyFeature(Stream.Null)); + features.Set(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(async () => + { + await FlushOnStartingAsync(ctx); + }); + } } diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs index ee8bee289..a6b2af66a 100644 --- a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Asp.Versioning; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; @@ -37,9 +38,10 @@ public class ApiVersioningPolicyHeaderWiringTests private static void Apply(ApiVersioningPolicy policy, params HttpChain[] chains) => policy.Apply(chains, new GenerationRules(), null!); - // 1 — chain with sunset policy gets ApiVersionHeaderWriter postprocessor + // 1 — chain with sunset policy is flagged as needing a header writer; the finalization policy + // is what actually inserts the writer call at index 0. [Fact] - public void chain_with_sunset_policy_gets_header_writer_postprocessor() + public void chain_with_sunset_policy_is_flagged_for_header_finalization() { var date = new DateTimeOffset(2027, 6, 1, 0, 0, 0, TimeSpan.Zero); var opts = new WolverineApiVersioningOptions(); @@ -50,15 +52,26 @@ public void chain_with_sunset_policy_gets_header_writer_postprocessor() Apply(policy, chain); - chain.Postprocessors + policy.ChainsRequiringHeaderEmission.ShouldContain(chain); + + // The writer is *not* yet inserted by ApiVersioningPolicy itself — that is the job of + // ApiVersionHeaderFinalizationPolicy, registered after every user policy in MapWolverineEndpoints. + chain.Middleware .OfType() .Any(c => c.HandlerType == typeof(ApiVersionHeaderWriter)) - .ShouldBeTrue(); + .ShouldBeFalse(); + + // Driving the finalization policy directly puts the writer at index 0. + var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderEmission); + finalization.Apply(new[] { chain }, new GenerationRules(), null!); + + chain.Middleware.OfType().First() + .HandlerType.ShouldBe(typeof(ApiVersionHeaderWriter)); } - // 2 — chain with all header emit flags disabled and no deprecation/sunset gets no postprocessor + // 2 — chain with all header emit flags disabled and no deprecation/sunset is not flagged. [Fact] - public void chain_with_no_policies_and_emit_supported_disabled_gets_no_postprocessor() + public void chain_with_no_policies_and_emit_supported_disabled_is_not_flagged() { var opts = new WolverineApiVersioningOptions { @@ -71,7 +84,12 @@ public void chain_with_no_policies_and_emit_supported_disabled_gets_no_postproce Apply(policy, chain); - chain.Postprocessors + policy.ChainsRequiringHeaderEmission.ShouldNotContain(chain); + + var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderEmission); + finalization.Apply(new[] { chain }, new GenerationRules(), null!); + + chain.Middleware .OfType() .Any(c => c.HandlerType == typeof(ApiVersionHeaderWriter)) .ShouldBeFalse(); @@ -99,9 +117,9 @@ public void chain_with_state_metadata_attached() state.Sunset!.Date.ShouldBe(date); } - // 4 — apply twice does not add duplicate header postprocessor (idempotency guard) + // 4 — finalization is idempotent: re-running does not duplicate the writer when it is already at index 0. [Fact] - public void apply_twice_does_not_add_duplicate_header_postprocessor() + public void finalization_is_idempotent_when_writer_already_at_head() { var opts = new WolverineApiVersioningOptions(); opts.Sunset("1.0").On(DateTimeOffset.UtcNow.AddDays(30)); @@ -109,11 +127,81 @@ public void apply_twice_does_not_add_duplicate_header_postprocessor() var chain = HttpChain.ChainFor(x => x.Get()); Apply(policy, chain); - var firstCount = chain.Postprocessors.OfType().Count(c => c.HandlerType == typeof(ApiVersionHeaderWriter)); + + var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderEmission); + finalization.Apply(new[] { chain }, new GenerationRules(), null!); + var firstCount = chain.Middleware.OfType().Count(c => c.HandlerType == typeof(ApiVersionHeaderWriter)); + + finalization.Apply(new[] { chain }, new GenerationRules(), null!); + var secondCount = chain.Middleware.OfType().Count(c => c.HandlerType == typeof(ApiVersionHeaderWriter)); + + firstCount.ShouldBe(1); + secondCount.ShouldBe(1); + chain.Middleware.OfType().First() + .HandlerType.ShouldBe(typeof(ApiVersionHeaderWriter)); + } + +#if DEBUG + // 5 — DEBUG-only: writer displaced from index 0 trips the Debug.Assert invariant on the + // second Apply. Pins the contract that no other policy is permitted to push the writer below + // index 0 once finalization has positioned it. RELEASE builds compile out the assert, so the + // test only runs under DEBUG (the catch-all #if guard makes the whole test invisible to xunit + // in RELEASE — silent skip is acceptable since the assert itself is also no-op). + [Fact] + public void finalization_assert_fires_when_writer_was_displaced() + { + var opts = new WolverineApiVersioningOptions(); + opts.Sunset("1.0").On(DateTimeOffset.UtcNow.AddDays(30)); + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); Apply(policy, chain); - var secondCount = chain.Postprocessors.OfType().Count(c => c.HandlerType == typeof(ApiVersionHeaderWriter)); - secondCount.ShouldBe(firstCount); + var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderEmission); + finalization.Apply(new[] { chain }, new GenerationRules(), null!); + + // Manually displace the writer: insert any other frame at index 0 so the writer drifts to index 1. + // The intentional precondition violation that the second Apply call must catch. + var displacer = MethodCall.For(x => x.Get()); + chain.Middleware.Insert(0, displacer); + chain.Middleware.IndexOf(chain.Middleware.OfType().First(c => c.HandlerType == typeof(ApiVersionHeaderWriter))) + .ShouldBe(1); + + // Swap in a throwing trace listener so Debug.Assert(false) raises instead of popping a UI dialog + // or silently writing to the trace stream. + var originalListeners = new TraceListener[Trace.Listeners.Count]; + Trace.Listeners.CopyTo(originalListeners, 0); + Trace.Listeners.Clear(); + Trace.Listeners.Add(new ThrowingTraceListener()); + + try + { + Should.Throw(() => + finalization.Apply(new[] { chain }, new GenerationRules(), null!)); + } + finally + { + Trace.Listeners.Clear(); + foreach (var listener in originalListeners) + Trace.Listeners.Add(listener); + } + } + + private sealed class DebugAssertException : Exception + { + public DebugAssertException(string message) : base(message) { } + } + + private sealed class ThrowingTraceListener : TraceListener + { + public override void Write(string? message) { } + public override void WriteLine(string? message) { } + + public override void Fail(string? message) + => throw new DebugAssertException(message ?? "Debug.Assert failed"); + + public override void Fail(string? message, string? detailMessage) + => throw new DebugAssertException($"{message} :: {detailMessage}"); } +#endif } diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_error_path_header_tests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_error_path_header_tests.cs new file mode 100644 index 000000000..836bd4ca4 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_error_path_header_tests.cs @@ -0,0 +1,102 @@ +using Alba; +using Shouldly; + +namespace Wolverine.Http.Tests.ApiVersioning; + +[Collection("integration")] +public class api_versioning_error_path_header_tests : IntegrationContext +{ + // Mirrors the sunset/deprecation policies registered in WolverineWebApi/Program.cs:303-306 + // (Sunset("3.0") + Deprecate("1.0")). Centralised so config drift in Program.cs surfaces here + // as a clear single failure rather than four near-identical ones. + private const string ExpectedSupportedVersions = "1.0, 3.0"; + + public api_versioning_error_path_header_tests(AppFixture fixture) : base(fixture) + { + } + + // Versioned chain returning Results.NotFound() — IResult exit path. + [Fact] + public async Task headers_emit_on_iresult_not_found() + { + var result = await Scenario(x => + { + x.Get.Url("/v1/orders/missing"); + x.StatusCodeShouldBe(404); + }); + + // The header must list both versions discovered from sunset/deprecation policies in Program.cs: + // Deprecate("1.0") and Sunset("3.0"), sorted ascending. + result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe(ExpectedSupportedVersions); + result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); + result.Context.Response.Headers["Link"].FirstOrDefault().ShouldContain("rel=\"deprecation\""); + } + + // Versioned chain hitting fluent validation failure — ProblemDetails exit path; codegen short-circuits with return. + [Fact] + public async Task headers_emit_on_validation_problem_details() + { + var result = await Scenario(x => + { + x.Post.Json(new { Sku = "", Quantity = 0 }).ToUrl("/v1/orders"); + x.StatusCodeShouldBe(400); + }); + + result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe(ExpectedSupportedVersions); + result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); + } + + // Versioned chain whose Before() middleware short-circuits with 401 — middleware-IResult exit path. + [Fact] + public async Task headers_emit_on_middleware_short_circuit_unauthorized() + { + var result = await Scenario(x => + { + x.Get.Url("/v1/orders/restricted"); + x.StatusCodeShouldBe(401); + }); + + result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe(ExpectedSupportedVersions); + result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); + } + + // Sanity: success path on a chain with Before() middleware. + [Fact] + public async Task headers_still_emit_on_success_path() + { + var result = await Scenario(x => + { + x.WithRequestHeader("X-Test-Auth", "yes"); + x.Get.Url("/v1/orders/restricted"); + x.StatusCodeShouldBeOk(); + }); + + result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe(ExpectedSupportedVersions); + result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); + } + + // Regression: 5xx responses produced by the global ASP.NET Core exception handler bypass + // the chain's middleware pipeline and therefore must NOT receive versioning headers. + // This is a documented out-of-scope (see docs/guide/http/versioning.md). UseExceptionHandler + // is scoped to /v1/orders/throws inside Program.cs so other tests are unaffected. + [Fact] + public async Task headers_do_not_emit_on_global_exception_handler_response() + { + var result = await Scenario(x => + { + x.Get.Url("/v1/orders/throws"); + x.StatusCodeShouldBe(500); + }); + + // Body marker proves the response really came from the scoped UseExceptionHandler in + // Program.cs (not from Wolverine's own ProblemDetails OnException middleware). Without + // this, a future change that lets Wolverine itself answer the throws endpoint with a 500 + // would silently turn the absent-headers assertion into a tautology. + (await result.ReadAsTextAsync()).ShouldContain("global-exception-handler"); + + result.Context.Response.Headers.ContainsKey("api-supported-versions").ShouldBeFalse(); + result.Context.Response.Headers.ContainsKey("Deprecation").ShouldBeFalse(); + result.Context.Response.Headers.ContainsKey("Sunset").ShouldBeFalse(); + result.Context.Response.Headers.ContainsKey("Link").ShouldBeFalse(); + } +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderFinalizationPolicy.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderFinalizationPolicy.cs new file mode 100644 index 000000000..c2f576a0c --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderFinalizationPolicy.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; + +namespace Wolverine.Http.ApiVersioning; + +/// Inserts the call as the first frame of every chain that flagged as needing header emission. +/// +/// Registered by MapWolverineEndpoints after configure has run, so it executes after every +/// user-supplied policy — including HttpChainFluentValidationPolicy and +/// HttpChainDataAnnotationsValidationPolicy, both of which insert short-circuiting frames at index 0. +/// Running last guarantees the writer's Response.OnStarting callback registers before any frame can +/// return; out of the generated handler — which is what gives validation 4xx, middleware short-circuits, +/// and IResult handler exits the same versioning headers as the success path. +/// +internal sealed class ApiVersionHeaderFinalizationPolicy : IHttpPolicy +{ + private readonly IReadOnlySet _chainsRequiringWriter; + + public ApiVersionHeaderFinalizationPolicy(IReadOnlySet chainsRequiringWriter) + { + _chainsRequiringWriter = chainsRequiringWriter; + } + + public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceContainer container) + { + foreach (var chain in chains) + { + if (!_chainsRequiringWriter.Contains(chain)) + continue; + + // Idempotency: if a writer call is already at index 0, leave it alone. + var existing = chain.Middleware.OfType() + .FirstOrDefault(c => c.HandlerType == typeof(ApiVersionHeaderWriter)); + + if (existing is not null) + { + Debug.Assert(chain.Middleware.IndexOf(existing) == 0, + "ApiVersionHeaderWriter must be at index 0 once inserted; no other policy is expected to displace it."); + continue; + } + + chain.Middleware.Insert(0, MethodCall.For(x => x.WriteAsync(null!))); + } + } +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs index 2780f1fb8..60f74e9eb 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs @@ -1,6 +1,8 @@ +using System.ComponentModel; using System.Globalization; using Asp.Versioning; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Wolverine.Http.ApiVersioning; @@ -24,9 +26,19 @@ public sealed record ApiVersionEndpointHeaderState( /// singleton with no per-chain constructor arguments. /// /// +/// /// Must remain public: Wolverine's dynamic code generation emits handler code at runtime that references /// this type by name for postprocessor wiring. The generated code is in a separate assembly without /// InternalsVisibleTo access to Wolverine.Http, so internal types are not accessible. +/// +/// +/// The class exposes two intentionally asymmetric entry points: +/// is the chain-pipeline frame Wolverine's codegen calls automatically +/// for every versioned endpoint — its name is locked by codegen and its signature is locked to the +/// HttpContext-only convention. +/// is a synchronous helper for advanced scenarios such as exception-handler middleware that +/// needs to emit the same RFC headers on the 5xx exception path (where the chain pipeline has been bypassed). +/// /// public sealed class ApiVersionHeaderWriter { @@ -49,17 +61,59 @@ public ApiVersionHeaderWriter(WolverineApiVersioningOptions options) } /// - /// Writes the applicable versioning response headers to . - /// Reads per-chain state from stored in the - /// matched endpoint's metadata. If no state is present the method returns immediately. + /// Registers a callback that writes the applicable + /// versioning response headers immediately before the response headers are flushed to the client. + /// Headers are emitted for every framework-produced response regardless of status code (2xx, 4xx, + /// validation ProblemDetails, middleware short-circuits returning IResult). Responses + /// produced by the global exception handler bypass the chain pipeline entirely and therefore never + /// invoke this callback — wire deprecation headers on the exception path via separate middleware. /// + /// + /// The method name remains WriteAsync because Wolverine's runtime code generation references + /// it by name. It is invoked once per request as the first frame of the chain, before any + /// status-branch divergence in the generated code. + /// /// The current HTTP context. public Task WriteAsync(HttpContext context) { + // Early-exit gate: skip the OnStarting registration entirely on chains with no header state. var state = context.GetEndpoint()?.Metadata.GetMetadata(); if (state is null) return Task.CompletedTask; + context.Response.OnStarting(static stateObj => + { + var ctx = (HttpContext)stateObj; + // Re-fetch inside OnStarting because the endpoint can be re-routed by middleware between this frame and header-flush time. + var endpoint = ctx.GetEndpoint(); + var hdrState = endpoint?.Metadata.GetMetadata(); + if (hdrState is null) + return Task.CompletedTask; + + var writer = ctx.RequestServices.GetRequiredService(); + writer.ApplyHeaders(ctx, hdrState); + return Task.CompletedTask; + }, context); + + return Task.CompletedTask; + } + + /// + /// Writes the applicable RFC 9745 / RFC 8594 / RFC 8288 response headers (Deprecation, + /// Sunset, Link, api-supported-versions) to . + /// based on the supplied per-endpoint . Public so application code on the + /// exception path (e.g. a custom UseExceptionHandler middleware) can emit the same headers + /// the chain pipeline would have written for non-exception responses. + /// + /// The current HTTP context whose response headers will be written. + /// The per-endpoint header state, typically read from + /// context.GetEndpoint()?.Metadata.GetMetadata<ApiVersionEndpointHeaderState>(). + [EditorBrowsable(EditorBrowsableState.Advanced)] + public void WriteVersioningHeadersTo(HttpContext context, ApiVersionEndpointHeaderState state) + => ApplyHeaders(context, state); + + private void ApplyHeaders(HttpContext context, ApiVersionEndpointHeaderState state) + { var headers = context.Response.Headers; if (_options.EmitApiSupportedVersionsHeader && _supportedVersionsHeader.Value.Length > 0) @@ -81,8 +135,6 @@ public Task WriteAsync(HttpContext context) if (links.Length > 0) headers["Link"] = links; } - - return Task.CompletedTask; } private static string BuildSupportedVersionsHeader(WolverineApiVersioningOptions options) diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs index a84267a85..dacc38da8 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs @@ -18,14 +18,21 @@ namespace Wolverine.Http.ApiVersioning; /// D — Reject duplicate (verb, route, version) triples. /// E — Rewrite route patterns with the URL-segment version prefix. /// F — Attach group-name and Asp.Versioning.ApiVersionMetadata to the endpoint. -/// G — Wire the response-header postprocessor on chains that need it. +/// G — Attach the per-chain header state metadata read by the writer at request time. /// /// internal sealed class ApiVersioningPolicy : IHttpPolicy { private readonly WolverineApiVersioningOptions _options; private readonly HashSet _processedChains = new(); - private readonly HashSet _headerProcessedChains = new(); + private readonly HashSet _headerStateChains = new(); + + /// + /// Chains for which Step G attached metadata, exposed + /// for to position the writer call at index 0 + /// after all other user-supplied policies have run. + /// + internal IReadOnlySet ChainsRequiringHeaderEmission => _headerStateChains; /// Initializes a new instance of . /// The API versioning options that drive this policy's behaviour. @@ -43,7 +50,7 @@ public void Apply(IReadOnlyList chains, GenerationRules rules, IServi DetectDuplicateRoutes(chains); RewriteRoutes(chains); AttachMetadata(chains); - WireHeaderPostprocessors(chains); + AttachHeaderState(chains); } /// Step A — read [ApiVersion] from the handler method and propagate to the chain. @@ -206,28 +213,32 @@ private void AttachMetadata(IReadOnlyList chains) } } - /// Step G — register the response-header postprocessor for chains that emit headers. - private void WireHeaderPostprocessors(IReadOnlyList chains) + /// + /// Step G — attach the per-chain metadata that the + /// writer reads at request time. The actual chain.Middleware.Insert(0, …) for the writer + /// itself is deferred to , which is registered + /// at the end of MapWolverineEndpoints so it executes after every user-supplied policy + /// (notably FluentValidation, which itself inserts a short-circuiting frame at index 0). Doing + /// the insert here would leave the writer below those frames and the OnStarting hook would not + /// register before return; on the validation-fail path. + /// + private void AttachHeaderState(IReadOnlyList chains) { foreach (var chain in chains) { - if (chain.ApiVersion is null || !RequiresHeaderWriter(chain)) + if (chain.ApiVersion is null || !RequiresHeaderEmission(chain)) continue; - if (!_headerProcessedChains.Add(chain)) + if (!_headerStateChains.Add(chain)) continue; // Per-chain state lives on endpoint metadata so the singleton writer can read it at request time. var state = new ApiVersionEndpointHeaderState(chain.ApiVersion, chain.SunsetPolicy, chain.DeprecationPolicy); chain.Metadata.WithMetadata(state); - - // MethodCall has no .Target — Wolverine codegen resolves ApiVersionHeaderWriter from DI - // at request time, then satisfies HttpContext from the request scope. - chain.Postprocessors.Add(MethodCall.For(x => x.WriteAsync(null!))); } } - private bool RequiresHeaderWriter(HttpChain chain) => + private bool RequiresHeaderEmission(HttpChain chain) => chain.SunsetPolicy is not null || chain.DeprecationPolicy is not null || _options.EmitApiSupportedVersionsHeader; diff --git a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs index 249d79e22..5bc86eb26 100644 --- a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs @@ -217,8 +217,19 @@ public static void MapWolverineEndpoints(this IEndpointRouteBuilder endpoints, options.Endpoints = new HttpGraph(runtime.Options, serviceProvider.GetRequiredService()); configure?.Invoke(options); - + options.Policies.Add(new ProblemDetailsFromMiddleware()); + + // If ApiVersioning is enabled, append a finalization policy that re-positions the header + // writer to index 0 of every relevant chain after every user-supplied policy has run. + // FluentValidation / DataAnnotations / RequestId / TenantId middleware all also insert at + // index 0; the writer must outrank them so its OnStarting callback registers before any + // short-circuit return. See ApiVersionHeaderFinalizationPolicy. + var versioningPolicy = options.Policies.OfType().FirstOrDefault(); + if (versioningPolicy is not null) + { + options.Policies.Add(new ApiVersioning.ApiVersionHeaderFinalizationPolicy(versioningPolicy.ChainsRequiringHeaderEmission)); + } if (DynamicCodeBuilder.WithinCodegenCommand) { diff --git a/src/Http/WolverineWebApi/ApiVersioning/OrdersV1CreateEndpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1CreateEndpoint.cs new file mode 100644 index 000000000..b5a890994 --- /dev/null +++ b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1CreateEndpoint.cs @@ -0,0 +1,24 @@ +using Asp.Versioning; +using FluentValidation; +using Wolverine.Http; + +namespace WolverineWebApi.ApiVersioning; + +public record CreateOrderV1Request(string Sku, int Quantity) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Sku).NotEmpty(); + RuleFor(x => x.Quantity).GreaterThan(0); + } + } +} + +[ApiVersion("1.0")] +public static class OrdersV1CreateEndpoint +{ + [WolverinePost("/orders", OperationId = "OrdersV1CreateEndpoint.Create")] + public static string Create(CreateOrderV1Request request) => "created"; +} diff --git a/src/Http/WolverineWebApi/ApiVersioning/OrdersV1NotFoundEndpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1NotFoundEndpoint.cs new file mode 100644 index 000000000..844db0332 --- /dev/null +++ b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1NotFoundEndpoint.cs @@ -0,0 +1,12 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Wolverine.Http; + +namespace WolverineWebApi.ApiVersioning; + +[ApiVersion("1.0")] +public static class OrdersV1NotFoundEndpoint +{ + [WolverineGet("/orders/{id}", OperationId = "OrdersV1NotFoundEndpoint.GetById")] + public static IResult GetById(string id) => Results.NotFound(new { id }); +} diff --git a/src/Http/WolverineWebApi/ApiVersioning/OrdersV1RestrictedEndpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1RestrictedEndpoint.cs new file mode 100644 index 000000000..c6faf885b --- /dev/null +++ b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1RestrictedEndpoint.cs @@ -0,0 +1,22 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Wolverine.Http; + +namespace WolverineWebApi.ApiVersioning; + +[ApiVersion("1.0")] +public static class OrdersV1RestrictedEndpoint +{ + public static IResult Before(HttpContext context) + { + if (!context.Request.Headers.ContainsKey("X-Test-Auth")) + { + return Results.Unauthorized(); + } + + return WolverineContinue.Result(); + } + + [WolverineGet("/orders/restricted", OperationId = "OrdersV1RestrictedEndpoint.Get")] + public static string Get() => "restricted-ok"; +} diff --git a/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ThrowsEndpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ThrowsEndpoint.cs new file mode 100644 index 000000000..b3c53c0a8 --- /dev/null +++ b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ThrowsEndpoint.cs @@ -0,0 +1,12 @@ +using Asp.Versioning; +using Wolverine.Http; + +namespace WolverineWebApi.ApiVersioning; + +[ApiVersion("1.0")] +public static class OrdersV1ThrowsEndpoint +{ + [WolverineGet("/orders/throws", OperationId = "OrdersV1ThrowsEndpoint.Get")] + public static string Get() + => throw new InvalidOperationException("OrdersV1ThrowsEndpoint: intentional regression-test failure — IGNORE"); +} diff --git a/src/Http/WolverineWebApi/Program.cs b/src/Http/WolverineWebApi/Program.cs index 56b4eddf2..a5be7515d 100644 --- a/src/Http/WolverineWebApi/Program.cs +++ b/src/Http/WolverineWebApi/Program.cs @@ -216,6 +216,21 @@ public static async Task Main(string[] args) app.UseRequestLocalization(localizationOptions); + // Scoped UseExceptionHandler — only on the dedicated regression-test path. Pinning the + // documented out-of-scope: 5xx responses produced by the global ASP.NET Core exception + // handler bypass the chain pipeline and therefore must NOT carry versioning headers. + // Restricted to /v1/orders/throws so other tests that intentionally produce 5xx via + // Wolverine's own ProblemDetails OnException middleware are unaffected. + // Must be registered before MapWolverineEndpoints so it wraps the chain pipeline. + app.UseWhen( + ctx => ctx.Request.Path.StartsWithSegments("/v1/orders/throws"), + branch => branch.UseExceptionHandler(errorApp => errorApp.Run(async ctx => + { + ctx.Response.StatusCode = 500; + ctx.Response.ContentType = "text/plain"; + await ctx.Response.WriteAsync("global-exception-handler"); + }))); + // Configure the HTTP request pipeline. app.UseSwagger(); app.UseSwaggerUI(c =>