From 4e689b93546d2802b47befabe838fafdd3daa660 Mon Sep 17 00:00:00 2001 From: Geoffrey MARC Date: Fri, 1 May 2026 21:41:44 +0200 Subject: [PATCH 1/3] fix(http): emit api-versioning headers on non-2xx responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiVersionHeaderWriter now registers headers via Response.OnStarting from the very first frame of every relevant chain, so versioning headers (Deprecation, Sunset, Link, api-supported-versions) are emitted on all framework-produced responses regardless of status code: 2xx, IResult-returning short-circuits, validation ProblemDetails, and middleware-IResult exits. Previously the writer ran as a Postprocessor, which Wolverine codegen skips when MaybeEndWithResultFrame or MaybeEndWithProblemDetailsFrame returns out of the generated handler — leaving 4xx error paths without the headers. A new ApiVersionHeaderFinalizationPolicy is registered in MapWolverineEndpoints after configure(); it runs last and re-positions the writer call to chain.Middleware index 0, outranking FluentValidation / DataAnnotations / RequestId / TenantId frames that also insert at index 0. Out of scope: responses produced by the global ASP.NET Core exception handler bypass the chain pipeline; users wanting headers on 5xx wire them via separate middleware (documented in versioning.md). Tests: 4 new integration tests in api_versioning_error_path_header_tests exercise the four exit paths (404 IResult, 400 validation, 401 middleware short-circuit, success). Sample endpoints added in WolverineWebApi/ApiVersioning/OrdersV1ErrorPathsEndpoint.cs. --- docs/guide/http/versioning.md | 36 ++++++++++ .../ApiVersionHeaderWriterTests.cs | 60 +++++++++++++--- .../ApiVersioningPolicyHeaderWiringTests.cs | 50 +++++++++---- .../api_versioning_error_path_header_tests.cs | 70 +++++++++++++++++++ .../ApiVersionHeaderFinalizationPolicy.cs | 49 +++++++++++++ .../ApiVersioning/ApiVersionHeaderWriter.cs | 28 ++++++-- .../ApiVersioning/ApiVersioningPolicy.cs | 21 ++++-- ...erineHttpEndpointRouteBuilderExtensions.cs | 13 +++- .../OrdersV1ErrorPathsEndpoint.cs | 49 +++++++++++++ 9 files changed, 342 insertions(+), 34 deletions(-) create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_error_path_header_tests.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderFinalizationPolicy.cs create mode 100644 src/Http/WolverineWebApi/ApiVersioning/OrdersV1ErrorPathsEndpoint.cs diff --git a/docs/guide/http/versioning.md b/docs/guide/http/versioning.md index a982ba108..2674c2dea 100644 --- a/docs/guide/http/versioning.md +++ b/docs/guide/http/versioning.md @@ -197,6 +197,42 @@ 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 writes the headers itself: + +```csharp +app.Use(async (ctx, next) => +{ + ctx.Response.OnStarting(static state => + { + var c = (HttpContext)state; + if (c.Response.StatusCode < 500) return Task.CompletedTask; + var s = c.GetEndpoint()?.Metadata.GetMetadata(); + if (s?.Deprecation is not null) c.Response.Headers["Deprecation"] = "true"; + // ...emit Sunset / Link / api-supported-versions as needed + 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..c5e8c38c8 100644 --- a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs @@ -1,6 +1,7 @@ using System.Globalization; using Asp.Versioning; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Shouldly; using Wolverine.Http.ApiVersioning; @@ -8,22 +9,52 @@ 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 + { + 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, out CapturingResponseFeature feature) { - var ctx = new DefaultHttpContext(); - var endpoint = new Endpoint( - _ => Task.CompletedTask, - new EndpointMetadataCollection(state), - "test"); - ctx.SetEndpoint(endpoint); + 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); + 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 ContextWithState(ApiVersionEndpointHeaderState state) + => BuildContext(state, out _); + private static DefaultHttpContext ContextWithNoState() + => BuildContext(null, 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 @@ -35,6 +66,7 @@ public async Task no_state_metadata_emits_no_headers() var ctx = ContextWithNoState(); await writer.WriteAsync(ctx); + await FlushOnStartingAsync(ctx); ctx.Response.Headers.ContainsKey("api-supported-versions").ShouldBeFalse(); ctx.Response.Headers.ContainsKey("Deprecation").ShouldBeFalse(); @@ -61,6 +93,7 @@ opts.SunsetPolicies[new ApiVersion(1, 0)], var ctx = ContextWithState(state); await writer.WriteAsync(ctx); + await FlushOnStartingAsync(ctx); ctx.Response.Headers["api-supported-versions"].ToString().ShouldBe("1.0, 2.0"); } @@ -77,6 +110,7 @@ public async Task deprecation_with_date_uses_imf_fixdate() var ctx = ContextWithState(state); await writer.WriteAsync(ctx); + await FlushOnStartingAsync(ctx); var expected = depDate.UtcDateTime.ToString("R", CultureInfo.InvariantCulture); ctx.Response.Headers["Deprecation"].ToString().ShouldBe(expected); @@ -93,6 +127,7 @@ public async Task deprecation_without_date_emits_true_token() var ctx = ContextWithState(state); await writer.WriteAsync(ctx); + await FlushOnStartingAsync(ctx); ctx.Response.Headers["Deprecation"].ToString().ShouldBe("true"); } @@ -109,6 +144,7 @@ public async Task sunset_emits_imf_fixdate() var ctx = ContextWithState(state); await writer.WriteAsync(ctx); + await FlushOnStartingAsync(ctx); var expected = sunsetDate.UtcDateTime.ToString("R", CultureInfo.InvariantCulture); ctx.Response.Headers["Sunset"].ToString().ShouldBe(expected); @@ -128,6 +164,7 @@ public async Task single_link_with_sunset_rel() var ctx = ContextWithState(state); await writer.WriteAsync(ctx); + await FlushOnStartingAsync(ctx); ctx.Response.Headers["Link"].ToString() .ShouldBe("; rel=\"sunset\"; title=\"Info\"; type=\"text/html\""); @@ -149,6 +186,7 @@ public async Task multiple_links_are_comma_separated() var ctx = ContextWithState(state); await writer.WriteAsync(ctx); + await FlushOnStartingAsync(ctx); var linkHeader = ctx.Response.Headers["Link"].ToString(); linkHeader.ShouldContain("; rel=\"sunset\""); @@ -179,6 +217,7 @@ public async Task disabled_emit_deprecation_headers_skips_deprecation_sunset_and var ctx = ContextWithState(state); await writer.WriteAsync(ctx); + await FlushOnStartingAsync(ctx); // api-supported-versions should still be present ctx.Response.Headers.ContainsKey("api-supported-versions").ShouldBeTrue(); @@ -206,6 +245,7 @@ public async Task disabled_emit_supported_versions_skips_supported_versions() var ctx = ContextWithState(state); await writer.WriteAsync(ctx); + await FlushOnStartingAsync(ctx); ctx.Response.Headers.ContainsKey("api-supported-versions").ShouldBeFalse(); // Deprecation still fires diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs index ee8bee289..d3ac523e4 100644 --- a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs @@ -37,9 +37,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 +51,26 @@ public void chain_with_sunset_policy_gets_header_writer_postprocessor() Apply(policy, chain); - chain.Postprocessors + policy.ChainsRequiringHeaderWriter.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.ChainsRequiringHeaderWriter); + 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 +83,12 @@ public void chain_with_no_policies_and_emit_supported_disabled_gets_no_postproce Apply(policy, chain); - chain.Postprocessors + policy.ChainsRequiringHeaderWriter.ShouldNotContain(chain); + + var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderWriter); + finalization.Apply(new[] { chain }, new GenerationRules(), null!); + + chain.Middleware .OfType() .Any(c => c.HandlerType == typeof(ApiVersionHeaderWriter)) .ShouldBeFalse(); @@ -99,9 +116,10 @@ public void chain_with_state_metadata_attached() state.Sunset!.Date.ShouldBe(date); } - // 4 — apply twice does not add duplicate header postprocessor (idempotency guard) + // 4 — apply finalization twice does not add a duplicate header writer (idempotency guard); + // covers the case where another policy inserts at index 0 between the two finalization runs. [Fact] - public void apply_twice_does_not_add_duplicate_header_postprocessor() + public void finalization_twice_does_not_duplicate_and_keeps_writer_at_head() { var opts = new WolverineApiVersioningOptions(); opts.Sunset("1.0").On(DateTimeOffset.UtcNow.AddDays(30)); @@ -109,11 +127,17 @@ 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)); - Apply(policy, chain); - var secondCount = chain.Postprocessors.OfType().Count(c => c.HandlerType == typeof(ApiVersionHeaderWriter)); + var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderWriter); + 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)); - secondCount.ShouldBe(firstCount); + firstCount.ShouldBe(1); + secondCount.ShouldBe(1); + chain.Middleware.OfType().First() + .HandlerType.ShouldBe(typeof(ApiVersionHeaderWriter)); } } 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..52a78b736 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_error_path_header_tests.cs @@ -0,0 +1,70 @@ +using Alba; +using Shouldly; + +namespace Wolverine.Http.Tests.ApiVersioning; + +[Collection("integration")] +public class api_versioning_error_path_header_tests : IntegrationContext +{ + 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); + }); + + result.Context.Response.Headers.ContainsKey("api-supported-versions").ShouldBeTrue(); + 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.ContainsKey("api-supported-versions").ShouldBeTrue(); + 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.ContainsKey("api-supported-versions").ShouldBeTrue(); + 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.ContainsKey("api-supported-versions").ShouldBeTrue(); + result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); + } +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderFinalizationPolicy.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderFinalizationPolicy.cs new file mode 100644 index 000000000..a7b4f9729 --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderFinalizationPolicy.cs @@ -0,0 +1,49 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; + +namespace Wolverine.Http.ApiVersioning; + +/// +/// Inserts the call as the very first frame of every chain that +/// previously 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 themselves insert short-circuiting +/// frames at index 0. Running last guarantees the writer is the first frame at request time, so its +/// Response.OnStarting callback is registered 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 IReadOnlyCollection _chainsRequiringWriter; + + public ApiVersionHeaderFinalizationPolicy(IReadOnlyCollection chainsRequiringWriter) + { + _chainsRequiringWriter = chainsRequiringWriter; + } + + public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceContainer container) + { + foreach (var chain in chains) + { + if (!_chainsRequiringWriter.Contains(chain)) + continue; + + // Idempotency: leave a writer call already at index 0 alone; otherwise remove any stray + // copy and re-insert at 0. Other policies inserting at index 0 between this run and the + // initial step push the writer down, so we have to re-position. + var existing = chain.Middleware.OfType() + .FirstOrDefault(c => c.HandlerType == typeof(ApiVersionHeaderWriter)); + + if (existing is not null) + { + if (chain.Middleware.IndexOf(existing) == 0) continue; + chain.Middleware.Remove(existing); + } + + 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..5746e393c 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs @@ -49,10 +49,18 @@ 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, near the head of the chain's middleware list, before + /// any status-branch divergence in the generated code. + /// /// The current HTTP context. public Task WriteAsync(HttpContext context) { @@ -60,6 +68,18 @@ public Task WriteAsync(HttpContext context) if (state is null) return Task.CompletedTask; + context.Response.OnStarting(static stateObj => + { + var (writer, ctx, hdrState) = ((ApiVersionHeaderWriter, HttpContext, ApiVersionEndpointHeaderState))stateObj; + writer.ApplyHeaders(ctx, hdrState); + return Task.CompletedTask; + }, (this, context, state)); + + return Task.CompletedTask; + } + + private void ApplyHeaders(HttpContext context, ApiVersionEndpointHeaderState state) + { var headers = context.Response.Headers; if (_options.EmitApiSupportedVersionsHeader && _supportedVersionsHeader.Value.Length > 0) @@ -81,8 +101,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..4b3562a2f 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs @@ -27,6 +27,13 @@ internal sealed class ApiVersioningPolicy : IHttpPolicy private readonly HashSet _processedChains = new(); private readonly HashSet _headerProcessedChains = 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 IReadOnlyCollection ChainsRequiringHeaderWriter => _headerProcessedChains; + /// Initializes a new instance of . /// The API versioning options that drive this policy's behaviour. public ApiVersioningPolicy(WolverineApiVersioningOptions options) @@ -206,7 +213,15 @@ private void AttachMetadata(IReadOnlyList chains) } } - /// Step G — register the response-header postprocessor for chains that emit headers. + /// + /// 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 WireHeaderPostprocessors(IReadOnlyList chains) { foreach (var chain in chains) @@ -220,10 +235,6 @@ private void WireHeaderPostprocessors(IReadOnlyList chains) // 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!))); } } diff --git a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs index 249d79e22..d327a462b 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.ChainsRequiringHeaderWriter)); + } if (DynamicCodeBuilder.WithinCodegenCommand) { diff --git a/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ErrorPathsEndpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ErrorPathsEndpoint.cs new file mode 100644 index 000000000..ab020f52e --- /dev/null +++ b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ErrorPathsEndpoint.cs @@ -0,0 +1,49 @@ +using Asp.Versioning; +using FluentValidation; +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 }); +} + +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"; +} + +[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"; +} From 9e02b943a857eb888aeb6073c708b471553b29f1 Mon Sep 17 00:00:00 2001 From: Geoffrey MARC Date: Sat, 2 May 2026 12:43:16 +0200 Subject: [PATCH 2/3] refactor(http): apply review feedback on api-versioning header path - ApiVersionHeaderFinalizationPolicy: switch to IReadOnlySet for O(1) Contains, drop the unreachable re-position branch in favor of a Debug.Assert idempotency invariant. - ApiVersionHeaderWriter: drop per-request tuple boxing in OnStarting by re-fetching endpoint metadata + writer from RequestServices in a static callback; expose WriteVersioningHeadersTo as a public helper so exception-path middleware can reuse the same RFC formatting. - ApiVersioningPolicy: rename WireHeaderPostprocessors to AttachHeaderState, RequiresHeaderWriter to RequiresHeaderEmission, _headerProcessedChains to _headerStateChains. - WolverineWebApi: split OrdersV1ErrorPathsEndpoint.cs into one file per type; add OrdersV1ThrowsEndpoint plus a scoped UseExceptionHandler on /v1/orders/throws to back the new regression test. - api_versioning_error_path_header_tests: pin the documented out-of-scope (5xx via global exception handler emits no versioning headers) and tighten api-supported-versions assertions to the exact expected value rather than ContainsKey. - versioning.md: tighten the 5xx middleware snippet to delegate header emission to ApiVersionHeaderWriter.WriteVersioningHeadersTo so the source of truth lives in one place. --- docs/guide/http/versioning.md | 12 +++-- .../ApiVersionHeaderWriterTests.cs | 39 +++++++++------ .../ApiVersioningPolicyHeaderWiringTests.cs | 5 +- .../api_versioning_error_path_header_tests.cs | 29 +++++++++-- .../ApiVersionHeaderFinalizationPolicy.cs | 32 ++++++------ .../ApiVersioning/ApiVersionHeaderWriter.cs | 30 ++++++++++-- .../ApiVersioning/ApiVersioningPolicy.cs | 16 +++--- .../ApiVersioning/OrdersV1CreateEndpoint.cs | 24 +++++++++ .../OrdersV1ErrorPathsEndpoint.cs | 49 ------------------- .../ApiVersioning/OrdersV1NotFoundEndpoint.cs | 12 +++++ .../OrdersV1RestrictedEndpoint.cs | 22 +++++++++ .../ApiVersioning/OrdersV1ThrowsEndpoint.cs | 12 +++++ src/Http/WolverineWebApi/Program.cs | 14 ++++++ 13 files changed, 192 insertions(+), 104 deletions(-) create mode 100644 src/Http/WolverineWebApi/ApiVersioning/OrdersV1CreateEndpoint.cs delete mode 100644 src/Http/WolverineWebApi/ApiVersioning/OrdersV1ErrorPathsEndpoint.cs create mode 100644 src/Http/WolverineWebApi/ApiVersioning/OrdersV1NotFoundEndpoint.cs create mode 100644 src/Http/WolverineWebApi/ApiVersioning/OrdersV1RestrictedEndpoint.cs create mode 100644 src/Http/WolverineWebApi/ApiVersioning/OrdersV1ThrowsEndpoint.cs diff --git a/docs/guide/http/versioning.md b/docs/guide/http/versioning.md index 2674c2dea..11023b32b 100644 --- a/docs/guide/http/versioning.md +++ b/docs/guide/http/versioning.md @@ -214,7 +214,8 @@ Responses produced by the global ASP.NET Core exception handler (e.g. an unhandl 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 writes the headers itself: +`ApiVersionEndpointHeaderState` metadata and delegates header emission to the registered +`ApiVersionHeaderWriter` so the source of truth (RFC formatting, options toggles) stays in one place: ```csharp app.Use(async (ctx, next) => @@ -223,9 +224,12 @@ app.Use(async (ctx, next) => { var c = (HttpContext)state; if (c.Response.StatusCode < 500) return Task.CompletedTask; - var s = c.GetEndpoint()?.Metadata.GetMetadata(); - if (s?.Deprecation is not null) c.Response.Headers["Deprecation"] = "true"; - // ...emit Sunset / Link / api-supported-versions as needed + + var endpointState = c.GetEndpoint()?.Metadata.GetMetadata(); + if (endpointState is null) return Task.CompletedTask; + + var writer = c.RequestServices.GetService(); + writer?.WriteVersioningHeadersTo(c, endpointState); return Task.CompletedTask; }, ctx); await next(); diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs index c5e8c38c8..31e4283f2 100644 --- a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs @@ -2,6 +2,7 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using Wolverine.Http.ApiVersioning; @@ -20,7 +21,10 @@ public override void OnStarting(Func callback, object state) public override void OnCompleted(Func callback, object state) { } } - private static DefaultHttpContext BuildContext(ApiVersionEndpointHeaderState? state, out CapturingResponseFeature feature) + private static DefaultHttpContext BuildContext( + ApiVersionEndpointHeaderState? state, + ApiVersionHeaderWriter writer, + out CapturingResponseFeature feature) { feature = new CapturingResponseFeature { Headers = new HeaderDictionary() }; var features = new FeatureCollection(); @@ -29,6 +33,13 @@ private static DefaultHttpContext BuildContext(ApiVersionEndpointHeaderState? st 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"); @@ -37,11 +48,11 @@ private static DefaultHttpContext BuildContext(ApiVersionEndpointHeaderState? st return ctx; } - private static DefaultHttpContext ContextWithState(ApiVersionEndpointHeaderState state) - => BuildContext(state, out _); + private static DefaultHttpContext ContextWithState(ApiVersionEndpointHeaderState state, ApiVersionHeaderWriter writer) + => BuildContext(state, writer, out _); - private static DefaultHttpContext ContextWithNoState() - => BuildContext(null, 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. @@ -63,7 +74,7 @@ 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); @@ -90,7 +101,7 @@ 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); @@ -107,7 +118,7 @@ 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); @@ -124,7 +135,7 @@ 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); @@ -141,7 +152,7 @@ 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); @@ -161,7 +172,7 @@ 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); @@ -183,7 +194,7 @@ 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); @@ -214,7 +225,7 @@ 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); @@ -242,7 +253,7 @@ 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); diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs index d3ac523e4..0a6419eb7 100644 --- a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs @@ -116,10 +116,9 @@ public void chain_with_state_metadata_attached() state.Sunset!.Date.ShouldBe(date); } - // 4 — apply finalization twice does not add a duplicate header writer (idempotency guard); - // covers the case where another policy inserts at index 0 between the two finalization runs. + // 4 — finalization is idempotent: re-running does not duplicate the writer when it is already at index 0. [Fact] - public void finalization_twice_does_not_duplicate_and_keeps_writer_at_head() + public void finalization_is_idempotent_when_writer_already_at_head() { var opts = new WolverineApiVersioningOptions(); opts.Sunset("1.0").On(DateTimeOffset.UtcNow.AddDays(30)); 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 index 52a78b736..ed0c6072c 100644 --- 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 @@ -20,7 +20,9 @@ public async Task headers_emit_on_iresult_not_found() x.StatusCodeShouldBe(404); }); - result.Context.Response.Headers.ContainsKey("api-supported-versions").ShouldBeTrue(); + // 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("1.0, 3.0"); result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); result.Context.Response.Headers["Link"].FirstOrDefault().ShouldContain("rel=\"deprecation\""); } @@ -35,7 +37,7 @@ public async Task headers_emit_on_validation_problem_details() x.StatusCodeShouldBe(400); }); - result.Context.Response.Headers.ContainsKey("api-supported-versions").ShouldBeTrue(); + result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe("1.0, 3.0"); result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); } @@ -49,7 +51,7 @@ public async Task headers_emit_on_middleware_short_circuit_unauthorized() x.StatusCodeShouldBe(401); }); - result.Context.Response.Headers.ContainsKey("api-supported-versions").ShouldBeTrue(); + result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe("1.0, 3.0"); result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); } @@ -64,7 +66,26 @@ public async Task headers_still_emit_on_success_path() x.StatusCodeShouldBeOk(); }); - result.Context.Response.Headers.ContainsKey("api-supported-versions").ShouldBeTrue(); + result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe("1.0, 3.0"); 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); + }); + + 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 index a7b4f9729..c2f576a0c 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderFinalizationPolicy.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderFinalizationPolicy.cs @@ -1,25 +1,24 @@ +using System.Diagnostics; using JasperFx; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; namespace Wolverine.Http.ApiVersioning; -/// -/// Inserts the call as the very first frame of every chain that -/// previously flagged as needing header emission. Registered by -/// MapWolverineEndpoints after configure has run, so it executes after every +/// 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 themselves insert short-circuiting -/// frames at index 0. Running last guarantees the writer is the first frame at request time, so its -/// Response.OnStarting callback is registered 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. -/// +/// 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 IReadOnlyCollection _chainsRequiringWriter; + private readonly IReadOnlySet _chainsRequiringWriter; - public ApiVersionHeaderFinalizationPolicy(IReadOnlyCollection chainsRequiringWriter) + public ApiVersionHeaderFinalizationPolicy(IReadOnlySet chainsRequiringWriter) { _chainsRequiringWriter = chainsRequiringWriter; } @@ -31,16 +30,15 @@ public void Apply(IReadOnlyList chains, GenerationRules rules, IServi if (!_chainsRequiringWriter.Contains(chain)) continue; - // Idempotency: leave a writer call already at index 0 alone; otherwise remove any stray - // copy and re-insert at 0. Other policies inserting at index 0 between this run and the - // initial step push the writer down, so we have to re-position. + // 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) { - if (chain.Middleware.IndexOf(existing) == 0) continue; - chain.Middleware.Remove(existing); + 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 5746e393c..7ad1a07e5 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs @@ -58,8 +58,8 @@ public ApiVersionHeaderWriter(WolverineApiVersioningOptions options) /// /// /// The method name remains WriteAsync because Wolverine's runtime code generation references - /// it by name. It is invoked once per request, near the head of the chain's middleware list, before - /// any status-branch divergence in the generated code. + /// 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) @@ -70,14 +70,34 @@ public Task WriteAsync(HttpContext context) context.Response.OnStarting(static stateObj => { - var (writer, ctx, hdrState) = ((ApiVersionHeaderWriter, HttpContext, ApiVersionEndpointHeaderState))stateObj; - writer.ApplyHeaders(ctx, hdrState); + var ctx = (HttpContext)stateObj; + var endpoint = ctx.GetEndpoint(); + var hdrState = endpoint?.Metadata.GetMetadata(); + if (hdrState is null) + return Task.CompletedTask; + + var services = ctx.RequestServices; + var writer = services.GetService(typeof(ApiVersionHeaderWriter)) as ApiVersionHeaderWriter; + writer?.ApplyHeaders(ctx, hdrState); return Task.CompletedTask; - }, (this, context, state)); + }, 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>(). + public void WriteVersioningHeadersTo(HttpContext context, ApiVersionEndpointHeaderState state) + => ApplyHeaders(context, state); + private void ApplyHeaders(HttpContext context, ApiVersionEndpointHeaderState state) { var headers = context.Response.Headers; diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs index 4b3562a2f..572db4846 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs @@ -18,21 +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 IReadOnlyCollection ChainsRequiringHeaderWriter => _headerProcessedChains; + internal IReadOnlySet ChainsRequiringHeaderWriter => _headerStateChains; /// Initializes a new instance of . /// The API versioning options that drive this policy's behaviour. @@ -50,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. @@ -222,14 +222,14 @@ private void AttachMetadata(IReadOnlyList chains) /// 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 WireHeaderPostprocessors(IReadOnlyList chains) + 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. @@ -238,7 +238,7 @@ private void WireHeaderPostprocessors(IReadOnlyList chains) } } - 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/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/OrdersV1ErrorPathsEndpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ErrorPathsEndpoint.cs deleted file mode 100644 index ab020f52e..000000000 --- a/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ErrorPathsEndpoint.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Asp.Versioning; -using FluentValidation; -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 }); -} - -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"; -} - -[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/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..7a4773b79 --- /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("intentional failure for exception-handler regression test"); +} diff --git a/src/Http/WolverineWebApi/Program.cs b/src/Http/WolverineWebApi/Program.cs index 56b4eddf2..b9f7ee444 100644 --- a/src/Http/WolverineWebApi/Program.cs +++ b/src/Http/WolverineWebApi/Program.cs @@ -216,6 +216,20 @@ 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. + 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 => From 15b0d003ac858f2ff3d7b11d018705376d948d73 Mon Sep 17 00:00:00 2001 From: Geoffrey MARC Date: Sat, 2 May 2026 14:23:09 +0200 Subject: [PATCH 3/3] refactor(http): apply second-pass review feedback on api-versioning header path Second-pass review fixes for PR #3. Production - Rename ApiVersioningPolicy.ChainsRequiringHeaderWriter to ChainsRequiringHeaderEmission to match the rest of the rename pass ("header state / emission" terminology). Property was already internal, so callers in WolverineHttpEndpointRouteBuilderExtensions are updated in lockstep. - ApiVersionHeaderWriter.WriteAsync now resolves the writer via GetRequiredService() inside the OnStarting callback. The previous GetService(typeof(...)) silently swallowed null and made lost headers invisible. Bootstrap registers the writer unconditionally as a singleton, so a missing registration is a programmer error and must fail fast. - Add inline comments to the early-exit gate and the in-OnStarting re-fetch explaining why the writer must re-resolve the endpoint at flush time (middleware can re-route the request between frames). - Mark WriteVersioningHeadersTo with EditorBrowsable(Advanced) and add a class-level XML doc explaining the asymmetry between WriteAsync (chain frame, locked by codegen) and the sync helper for advanced/middleware use. - WolverineWebApi/Program.cs: pin placement intent of UseExceptionHandler with a "before MapWolverineEndpoints" comment. - OrdersV1ThrowsEndpoint: throw message now identifies the source and flags it as IGNORE for log tailers. Tests - New ApiVersionHeaderWriterTests.missing_writer_in_request_services_throws pins the new fail-fast contract for the GetRequiredService change. - New DEBUG-only ApiVersioningPolicyHeaderWiringTests.finalization_assert_- fires_when_writer_was_displaced exercises the Debug.Assert invariant in ApiVersionHeaderFinalizationPolicy via a throwing TraceListener. Guarded by #if DEBUG since the assert is no-op under RELEASE. - api_versioning_error_path_header_tests: - Extract ExpectedSupportedVersions constant for "1.0, 3.0" with a pointer to Program.cs:303-306 so config drift produces a clear single failure rather than four near-identical ones. - Assert response body contains "global-exception-handler" on the 5xx test so a future change letting Wolverine answer the throws endpoint with its own 500 cannot turn the absent-headers assertion into a tautology. Docs - versioning.md exception-handler snippet: add the missing Microsoft.Extensions.DependencyInjection using and switch to GetRequiredService() to match the production change. --- docs/guide/http/versioning.md | 6 +- .../ApiVersionHeaderWriterTests.cs | 36 +++++++++ .../ApiVersioningPolicyHeaderWiringTests.cs | 75 +++++++++++++++++-- .../api_versioning_error_path_header_tests.cs | 19 ++++- .../ApiVersioning/ApiVersionHeaderWriter.cs | 20 ++++- .../ApiVersioning/ApiVersioningPolicy.cs | 2 +- ...erineHttpEndpointRouteBuilderExtensions.cs | 2 +- .../ApiVersioning/OrdersV1ThrowsEndpoint.cs | 2 +- src/Http/WolverineWebApi/Program.cs | 1 + 9 files changed, 146 insertions(+), 17 deletions(-) diff --git a/docs/guide/http/versioning.md b/docs/guide/http/versioning.md index 11023b32b..4ad84975c 100644 --- a/docs/guide/http/versioning.md +++ b/docs/guide/http/versioning.md @@ -218,6 +218,8 @@ ASP.NET Core middleware on the exception path that reads the matched endpoint's `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 => @@ -228,8 +230,8 @@ app.Use(async (ctx, next) => var endpointState = c.GetEndpoint()?.Metadata.GetMetadata(); if (endpointState is null) return Task.CompletedTask; - var writer = c.RequestServices.GetService(); - writer?.WriteVersioningHeadersTo(c, endpointState); + var writer = c.RequestServices.GetRequiredService(); + writer.WriteVersioningHeadersTo(c, endpointState); return Task.CompletedTask; }, ctx); await next(); diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs index 31e4283f2..7a9f03986 100644 --- a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs @@ -262,4 +262,40 @@ public async Task disabled_emit_supported_versions_skips_supported_versions() // 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 0a6419eb7..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; @@ -51,7 +52,7 @@ public void chain_with_sunset_policy_is_flagged_for_header_finalization() Apply(policy, chain); - policy.ChainsRequiringHeaderWriter.ShouldContain(chain); + 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. @@ -61,7 +62,7 @@ public void chain_with_sunset_policy_is_flagged_for_header_finalization() .ShouldBeFalse(); // Driving the finalization policy directly puts the writer at index 0. - var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderWriter); + var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderEmission); finalization.Apply(new[] { chain }, new GenerationRules(), null!); chain.Middleware.OfType().First() @@ -83,9 +84,9 @@ public void chain_with_no_policies_and_emit_supported_disabled_is_not_flagged() Apply(policy, chain); - policy.ChainsRequiringHeaderWriter.ShouldNotContain(chain); + policy.ChainsRequiringHeaderEmission.ShouldNotContain(chain); - var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderWriter); + var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderEmission); finalization.Apply(new[] { chain }, new GenerationRules(), null!); chain.Middleware @@ -127,7 +128,7 @@ public void finalization_is_idempotent_when_writer_already_at_head() Apply(policy, chain); - var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderWriter); + var finalization = new ApiVersionHeaderFinalizationPolicy(policy.ChainsRequiringHeaderEmission); finalization.Apply(new[] { chain }, new GenerationRules(), null!); var firstCount = chain.Middleware.OfType().Count(c => c.HandlerType == typeof(ApiVersionHeaderWriter)); @@ -139,4 +140,68 @@ public void finalization_is_idempotent_when_writer_already_at_head() 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 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 index ed0c6072c..836bd4ca4 100644 --- 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 @@ -6,6 +6,11 @@ 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) { } @@ -22,7 +27,7 @@ public async Task headers_emit_on_iresult_not_found() // 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("1.0, 3.0"); + 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\""); } @@ -37,7 +42,7 @@ public async Task headers_emit_on_validation_problem_details() x.StatusCodeShouldBe(400); }); - result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe("1.0, 3.0"); + result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe(ExpectedSupportedVersions); result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); } @@ -51,7 +56,7 @@ public async Task headers_emit_on_middleware_short_circuit_unauthorized() x.StatusCodeShouldBe(401); }); - result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe("1.0, 3.0"); + result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe(ExpectedSupportedVersions); result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); } @@ -66,7 +71,7 @@ public async Task headers_still_emit_on_success_path() x.StatusCodeShouldBeOk(); }); - result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe("1.0, 3.0"); + result.Context.Response.Headers["api-supported-versions"].ToString().ShouldBe(ExpectedSupportedVersions); result.Context.Response.Headers["Deprecation"].FirstOrDefault().ShouldNotBeNullOrEmpty(); } @@ -83,6 +88,12 @@ public async Task headers_do_not_emit_on_global_exception_handler_response() 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(); diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs index 7ad1a07e5..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 { @@ -64,6 +76,7 @@ public ApiVersionHeaderWriter(WolverineApiVersioningOptions options) /// 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; @@ -71,14 +84,14 @@ public Task WriteAsync(HttpContext context) 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 services = ctx.RequestServices; - var writer = services.GetService(typeof(ApiVersionHeaderWriter)) as ApiVersionHeaderWriter; - writer?.ApplyHeaders(ctx, hdrState); + var writer = ctx.RequestServices.GetRequiredService(); + writer.ApplyHeaders(ctx, hdrState); return Task.CompletedTask; }, context); @@ -95,6 +108,7 @@ public Task WriteAsync(HttpContext context) /// 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); diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs index 572db4846..dacc38da8 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs @@ -32,7 +32,7 @@ internal sealed class ApiVersioningPolicy : IHttpPolicy /// for to position the writer call at index 0 /// after all other user-supplied policies have run. /// - internal IReadOnlySet ChainsRequiringHeaderWriter => _headerStateChains; + internal IReadOnlySet ChainsRequiringHeaderEmission => _headerStateChains; /// Initializes a new instance of . /// The API versioning options that drive this policy's behaviour. diff --git a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs index d327a462b..5bc86eb26 100644 --- a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs @@ -228,7 +228,7 @@ public static void MapWolverineEndpoints(this IEndpointRouteBuilder endpoints, var versioningPolicy = options.Policies.OfType().FirstOrDefault(); if (versioningPolicy is not null) { - options.Policies.Add(new ApiVersioning.ApiVersionHeaderFinalizationPolicy(versioningPolicy.ChainsRequiringHeaderWriter)); + options.Policies.Add(new ApiVersioning.ApiVersionHeaderFinalizationPolicy(versioningPolicy.ChainsRequiringHeaderEmission)); } if (DynamicCodeBuilder.WithinCodegenCommand) diff --git a/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ThrowsEndpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ThrowsEndpoint.cs index 7a4773b79..b3c53c0a8 100644 --- a/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ThrowsEndpoint.cs +++ b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1ThrowsEndpoint.cs @@ -8,5 +8,5 @@ public static class OrdersV1ThrowsEndpoint { [WolverineGet("/orders/throws", OperationId = "OrdersV1ThrowsEndpoint.Get")] public static string Get() - => throw new InvalidOperationException("intentional failure for exception-handler regression test"); + => throw new InvalidOperationException("OrdersV1ThrowsEndpoint: intentional regression-test failure — IGNORE"); } diff --git a/src/Http/WolverineWebApi/Program.cs b/src/Http/WolverineWebApi/Program.cs index b9f7ee444..a5be7515d 100644 --- a/src/Http/WolverineWebApi/Program.cs +++ b/src/Http/WolverineWebApi/Program.cs @@ -221,6 +221,7 @@ public static async Task Main(string[] args) // 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 =>