diff --git a/Directory.Packages.props b/Directory.Packages.props index 856526271..1d44e8077 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ - + diff --git a/docs/guide/http/versioning.md b/docs/guide/http/versioning.md index 87f9a4b41..75be7ae87 100644 --- a/docs/guide/http/versioning.md +++ b/docs/guide/http/versioning.md @@ -11,8 +11,8 @@ versioning entirely through its own `IHttpPolicy` pipeline, so there is no confl `AddApiVersioning()` registration, and no additional ASP.NET Core middleware is needed. ::: info -This release supports **URL-segment versioning** (e.g. `/v1/...`, `/v2/...`). Each endpoint declares a single -`[ApiVersion]`; multi-version handlers via `[MapToApiVersion]` are not supported. +This release supports **URL-segment versioning** (e.g. `/v1/...`, `/v2/...`) and multi-version handlers +via repeated `[ApiVersion]` attributes or `[MapToApiVersion]`. See [Multi-version handlers](#multi-version-handlers). ::: ## Quick Start @@ -72,12 +72,86 @@ public static class OrdersV2Endpoint } ``` -### One version per endpoint +### Multi-version handlers -Declaring **multiple** `[ApiVersion]` attributes on the same handler method is not supported in v1. The resolver -picks the first attribute encountered and ignores any additional ones. If you have two incompatible response shapes -for the same resource, create separate endpoint classes — one per version — as shown in the sample app -(`OrdersV1Endpoint`, `OrdersV2Endpoint`, `OrdersV3PreviewEndpoint`). +A single handler method can serve multiple API versions by repeating `[ApiVersion]` attributes either at the +method or class level. Wolverine expands the chain at bootstrap into one HTTP endpoint per declared version, +with the URL-segment prefix, sunset / deprecation policies, OpenAPI group name, and `ApiVersionMetadata` +applied per clone. Duplicate-route detection runs across the *expanded* set so two handlers serving the same +`(verb, route, version)` triple still fail fast with a descriptive error. + +#### Method-level multi-version + +```csharp +public static class OrdersHandler +{ + [WolverineGet("/orders")] + [ApiVersion("1.0")] + [ApiVersion("2.0")] + public static OrdersResponse Get() => new(["a", "b"]); +} +``` + +This produces both `/v1/orders` and `/v2/orders` and registers both in the OpenAPI documents. + +#### Class-level multi-version + +```csharp +[ApiVersion("1.0", Deprecated = true)] +[ApiVersion("2.0")] +[ApiVersion("3.0")] +public static class CustomersEndpoint +{ + [WolverineGet("/customers")] + public static CustomersResponse Get() => new(["alice", "bob"]); +} +``` + +Per-version deprecation propagates to the matching clone only: in this example `/v1/customers` carries the +`Deprecation` response header while `/v2/customers` and `/v3/customers` do not. + +#### `[MapToApiVersion]` — opt a method into a subset of class versions + +When a class declares many versions but a particular method only applies to a subset, decorate the method +with `[MapToApiVersion]` instead of redeclaring `[ApiVersion]`: + +```csharp +[ApiVersion("1.0")] +[ApiVersion("2.0")] +[ApiVersion("3.0")] +public static class CustomersEndpoint +{ + // Lives at /v2/customers/v2-only — v1 and v3 are NOT registered for this method. + [WolverineGet("/customers/v2-only")] + [MapToApiVersion("2.0")] + public static CustomersResponse Get() => new(["v2-only"]); +} +``` + +Resolution rules: + +- **Method-level `[ApiVersion]` overrides class-level entirely.** If both are present, only the method + attribute is read; class-level versions are ignored for that method. +- **Method-level `[MapToApiVersion]` filters class-level versions.** Every version listed must also appear + at class level, otherwise startup fails fast with an exception naming both the method and the class. +- **A method may not carry both `[ApiVersion]` and `[MapToApiVersion]`.** Pick one — `[ApiVersion]` declares + versions independently of the class, `[MapToApiVersion]` selects from them. Mixing the two on one method + triggers a startup exception. + +#### Per-version metadata semantics + +When a chain is expanded into clones, every per-version policy is applied to its respective clone: + +| Per-clone state | Source | +|---|---| +| `RoutePattern` | Rewritten with `UrlSegmentPrefix` for that clone's version | +| `ApiVersionMetadata` | Built per clone: `DeclaredApiVersions` is the single clone version; `SupportedApiVersions` and `DeprecatedApiVersions` are the union of all sibling clones at the same `(verb, route-without-version-prefix)` | +| `OperationId` | Inherited from the source chain plus a `_v{sanitised-version-string}` suffix — every non-alphanumeric character is replaced with `_`, so `2.0` → `_v2_0` and date-based versions like `2024-01-01` → `_v2024_01_01` | +| `IEndpointGroupNameMetadata` | `OpenApi.DocumentNameStrategy(clone.ApiVersion)` | +| `DeprecationPolicy` | `[ApiVersion(..., Deprecated = true)]` for that version, or `Deprecate("X.Y")` from options | +| `SunsetPolicy` | `Sunset("X.Y")` from options | +| Response headers (`Deprecation`, `Sunset`, `Link`, `api-supported-versions`) | Per the policies attached to that clone | +| `Endpoint.Metadata` `[ApiVersion]` / `[MapToApiVersion]` | Filtered to only the clone's own version — sibling-version attributes are scrubbed so OpenAPI tooling reports each clone as implementing exactly one declared version | ### Marking a version as deprecated via attribute @@ -301,6 +375,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 @@ -417,7 +533,14 @@ assembly scan, must carry `[ApiVersion]`. Switch to `PassThrough` (the default) remain unversioned, or add the attribute. The exception message lists the offending endpoint by its display name. **Multiple `[ApiVersion]` attributes on the same method.** -Only the first attribute is used; subsequent attributes are silently ignored. If you need to expose a resource at -two different versions, create separate endpoint classes — one per version — sharing the same route template. The -duplicate-detection step in `ApiVersioningPolicy` will catch any `(verb, route, version)` triple that appears -more than once and throw a descriptive `InvalidOperationException` at startup. +Wolverine expands the chain at bootstrap into one HTTP endpoint per declared version. See +[Multi-version handlers](#multi-version-handlers). Duplicate detection still applies across the expanded set +and rejects any `(verb, route, version)` triple that appears more than once. + +**`[MapToApiVersion]` lists a version that the class does not declare.** +Startup throws an `InvalidOperationException` naming both the method and the declaring class. Add the missing +version to the class-level `[ApiVersion]` list, or remove it from `[MapToApiVersion]`. + +**Both `[ApiVersion]` and `[MapToApiVersion]` on the same method.** +Startup throws. Use only one: `[ApiVersion]` declares versions independently of the class; `[MapToApiVersion]` +selects from class-level versions. diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs index 6e1f3078d..1231176b6 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,59 @@ 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) + { + _ = writer; + 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 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. + // Both overloads of OnStarting (callback-only and callback+state) route through the + // (Func, object) overload internally, so this drain handles both forms. + 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 +70,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 +97,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 +114,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 +131,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 +148,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 +168,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 +190,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 +221,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 +249,14 @@ 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(); } + } diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionResolverTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionResolverTests.cs index 6e711b5f8..b8b973865 100644 --- a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionResolverTests.cs +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionResolverTests.cs @@ -34,55 +34,56 @@ private static MethodInfo MethodOf(string name) => typeof(T).GetMethod(name, BindingFlags.Public | BindingFlags.Instance)!; [Fact] - public void no_attribute_returns_null() + public void no_attribute_returns_empty() { var method = MethodOf(nameof(NoVersionHandler.Handle)); - ApiVersionResolver.Resolve(method).ShouldBeNull(); + ApiVersionResolver.ResolveVersions(method).ShouldBeEmpty(); } [Fact] public void class_only_attribute_resolves_to_class_version() { var method = MethodOf(nameof(ClassOnlyVersionHandler.Handle)); - var result = ApiVersionResolver.Resolve(method); - result!.Value.Version.ShouldBe(new ApiVersion(2, 0)); - result.Value.IsDeprecated.ShouldBeFalse(); + var result = ApiVersionResolver.ResolveVersions(method); + result.ShouldHaveSingleItem(); + result[0].Version.ShouldBe(new ApiVersion(2, 0)); + result[0].IsDeprecated.ShouldBeFalse(); } [Fact] public void method_attribute_resolves_to_method_version() { var method = MethodOf(nameof(MethodOnlyVersionHandler.Handle)); - var result = ApiVersionResolver.Resolve(method); - result!.Value.Version.ShouldBe(new ApiVersion(1, 0)); - result.Value.IsDeprecated.ShouldBeFalse(); + var result = ApiVersionResolver.ResolveVersions(method); + result.ShouldHaveSingleItem(); + result[0].Version.ShouldBe(new ApiVersion(1, 0)); + result[0].IsDeprecated.ShouldBeFalse(); } [Fact] public void method_attribute_overrides_class_attribute() { var method = MethodOf(nameof(MethodOverridesClassHandler.Handle)); - var result = ApiVersionResolver.Resolve(method); - result!.Value.Version.ShouldBe(new ApiVersion(1, 0)); - result.Value.IsDeprecated.ShouldBeFalse(); + var result = ApiVersionResolver.ResolveVersions(method); + result.ShouldHaveSingleItem(); + result[0].Version.ShouldBe(new ApiVersion(1, 0)); + result[0].IsDeprecated.ShouldBeFalse(); } [Fact] - public void multiple_versions_on_same_method_throws() + public void multiple_versions_on_same_method_returns_all_of_them() { var method = MethodOf(nameof(MultipleVersionsOnMethodHandler.Handle)); - var ex = Should.Throw(() => ApiVersionResolver.Resolve(method)); - ex.Message.ShouldContain("MultipleVersionsOnMethodHandler.Handle"); - ex.Message.ShouldContain("1.0"); - ex.Message.ShouldContain("2.0"); + var result = ApiVersionResolver.ResolveVersions(method); + result.Select(r => r.Version).ShouldBe(new[] { new ApiVersion(1, 0), new ApiVersion(2, 0) }); } [Fact] public void deprecated_attribute_flag_is_propagated() { - var result = ApiVersionResolver.Resolve(MethodOf(nameof(DeprecatedMethodHandler.Handle))); - result.ShouldNotBeNull(); - result.Value.Version.ShouldBe(new ApiVersion(1, 0)); - result.Value.IsDeprecated.ShouldBeTrue(); + var result = ApiVersionResolver.ResolveVersions(MethodOf(nameof(DeprecatedMethodHandler.Handle))); + result.ShouldHaveSingleItem(); + result[0].Version.ShouldBe(new ApiVersion(1, 0)); + result[0].IsDeprecated.ShouldBeTrue(); } } 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/MultiVersionExpansionTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/MultiVersionExpansionTests.cs new file mode 100644 index 000000000..696055531 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/MultiVersionExpansionTests.cs @@ -0,0 +1,388 @@ +using Asp.Versioning; +using JasperFx.CodeGeneration; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using Shouldly; +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +// ---------- Test handler fixtures ---------- + +internal class TwoVersionMethodOrdersHandler +{ + [WolverineGet("/orders")] + [ApiVersion("1.0")] + [ApiVersion("2.0")] + public string Get() => "two-versions"; +} + +[ApiVersion("1.0")] +[ApiVersion("2.0")] +internal class ClassLevelTwoVersionOrdersHandler +{ + [WolverineGet("/items")] + public string Get() => "class-two-versions"; +} + +[ApiVersion("1.0")] +[ApiVersion("2.0")] +[ApiVersion("3.0")] +internal class ClassThreeMapToTwoOrdersHandler +{ + [WolverineGet("/widgets")] + [MapToApiVersion("2.0")] + public string Get() => "filtered"; +} + +internal class TwoVersionWithPerVersionDeprecationHandler +{ + [WolverineGet("/legacy")] + [ApiVersion("1.0", Deprecated = true)] + [ApiVersion("2.0")] + public string Get() => "per-version-deprecation"; +} + +[ApiVersion("1.0")] +internal class MapToInvalidVersionHandler +{ + [WolverineGet("/missing")] + [MapToApiVersion("3.0")] + public string Get() => "missing"; +} + +[ApiVersion("1.0")] +internal class BothAttrsHandler +{ + [WolverineGet("/both")] + [ApiVersion("2.0")] + [MapToApiVersion("1.0")] + public string Get() => "both"; +} + +// Two handlers that, after expansion, both serve (GET, /reports, v2.0) — duplicate detection target. +internal class FirstReportsMultiVersionHandler +{ + [WolverineGet("/reports")] + [ApiVersion("1.0")] + [ApiVersion("2.0")] + public string Get() => "first"; +} + +internal class SecondReportsMultiVersionHandler +{ + [WolverineGet("/reports")] + [ApiVersion("2.0")] + [ApiVersion("3.0")] + public string Get() => "second"; +} + +// Plain unversioned handler — must survive expansion without any ApiVersion side effects. +internal class UnversionedPlainHandler +{ + [WolverineGet("/health")] + public string Get() => "ok"; +} + +// Date-based version handler used to assert OperationId suffix sanitisation. +internal class DateBasedVersionHandler +{ + [WolverineGet("/dated")] + [ApiVersion("2024-01-01")] + [ApiVersion("2025-06-15")] + public string Get() => "dated"; +} + +// Two distinct handler classes serving different versions of the SAME (verb, route). +// Used to pin the cross-class sibling-merge behaviour in ApiVersioningPolicy.AttachMetadata. +internal class FirstInventoryEndpointHandler +{ + [WolverineGet("/inventory")] + [ApiVersion("1.0")] + [ApiVersion("2.0")] + public string Get() => "first"; +} + +internal class SecondInventoryEndpointHandler +{ + [WolverineGet("/inventory")] + [ApiVersion("3.0")] + public string Get() => "second"; +} + +public class MultiVersionExpansionTests +{ + private static List ListOf(params HttpChain[] chains) => new(chains); + + // 1 — multi-version method produces N versioned routes after expansion + policy. + [Fact] + public void method_level_two_versions_expand_to_two_routes() + { + var chains = ListOf(HttpChain.ChainFor(x => x.Get())); + MultiVersionExpansion.ExpandInPlace(chains); + + chains.Count.ShouldBe(2); + chains.Select(c => c.ApiVersion).ShouldBe(new[] { new ApiVersion(1, 0), new ApiVersion(2, 0) }); + + var policy = new ApiVersioningPolicy(new WolverineApiVersioningOptions()); + policy.Apply(chains, new GenerationRules(), null!); + + chains.Select(c => c.RoutePattern!.RawText).OrderBy(s => s).ShouldBe(new[] { "/v1/orders", "/v2/orders" }); + } + + // 2 — class-level [ApiVersion] list with method-level [MapToApiVersion] keeps only the listed subset. + [Fact] + public void mapto_filters_to_only_the_listed_version() + { + var chains = ListOf(HttpChain.ChainFor(x => x.Get())); + MultiVersionExpansion.ExpandInPlace(chains); + + // Single-version chains are NOT expanded — ApiVersioningPolicy resolves them in ResolveAttributes. + chains.Count.ShouldBe(1); + + var policy = new ApiVersioningPolicy(new WolverineApiVersioningOptions()); + policy.Apply(chains, new GenerationRules(), null!); + + chains[0].ApiVersion.ShouldBe(new ApiVersion(2, 0)); + chains[0].RoutePattern!.RawText.ShouldBe("/v2/widgets"); + } + + // 3 — class with [ApiVersion("1.0")] + method [MapToApiVersion("3.0")] fails fast at expansion. + [Fact] + public void mapto_for_unknown_class_version_throws_at_expansion_time() + { + var chains = ListOf(HttpChain.ChainFor(x => x.Get())); + + var ex = Should.Throw(() => MultiVersionExpansion.ExpandInPlace(chains)); + ex.Message.ShouldContain("MapToApiVersion"); + ex.Message.ShouldContain("MapToInvalidVersionHandler"); + ex.Message.ShouldContain("3.0"); + } + + // 4 — both [ApiVersion] and [MapToApiVersion] on the same method fails fast at expansion. + [Fact] + public void both_apiversion_and_mapto_throws_at_expansion_time() + { + var chains = ListOf(HttpChain.ChainFor(x => x.Get())); + + var ex = Should.Throw(() => MultiVersionExpansion.ExpandInPlace(chains)); + ex.Message.ShouldContain("[ApiVersion]"); + ex.Message.ShouldContain("[MapToApiVersion]"); + } + + // 5 — per-version deprecation propagates only to the deprecated clone. + [Fact] + public void per_version_deprecation_applies_independently_across_clones() + { + var chains = ListOf(HttpChain.ChainFor(x => x.Get())); + MultiVersionExpansion.ExpandInPlace(chains); + + chains.Count.ShouldBe(2); + var v1 = chains.Single(c => c.ApiVersion == new ApiVersion(1, 0)); + var v2 = chains.Single(c => c.ApiVersion == new ApiVersion(2, 0)); + + v1.DeprecationPolicy.ShouldNotBeNull(); + v2.DeprecationPolicy.ShouldBeNull(); + } + + // 6 — class-level multi-version with no method override expands per class versions. + [Fact] + public void class_level_two_versions_expand_to_two_routes() + { + var chains = ListOf(HttpChain.ChainFor(x => x.Get())); + MultiVersionExpansion.ExpandInPlace(chains); + + chains.Count.ShouldBe(2); + chains.Select(c => c.ApiVersion).ShouldBe(new[] { new ApiVersion(1, 0), new ApiVersion(2, 0) }); + } + + // 7 — versioned-route metadata wires up per clone with the union of sibling versions in supported. + [Fact] + public void per_version_metadata_attached_to_each_clone_advertises_sibling_versions() + { + var chains = ListOf(HttpChain.ChainFor(x => x.Get())); + MultiVersionExpansion.ExpandInPlace(chains); + + var policy = new ApiVersioningPolicy(new WolverineApiVersioningOptions()); + policy.Apply(chains, new GenerationRules(), null!); + + var allVersions = new[] { new ApiVersion(1, 0), new ApiVersion(2, 0) }; + + foreach (var chain in chains) + { + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + var versionMeta = endpoint.Metadata.GetMetadata(); + versionMeta.ShouldNotBeNull(); + + var model = versionMeta!.Map(ApiVersionMapping.Explicit); + + // Each clone declares only its own version, but advertises both as supported so the + // api-supported-versions response header reports the full sibling set. + model.DeclaredApiVersions.ShouldBe(new[] { chain.ApiVersion! }); + model.SupportedApiVersions.OrderBy(v => v).ShouldBe(allVersions); + model.ImplementedApiVersions.OrderBy(v => v).ShouldBe(allVersions); + } + } + + // 8 — per-version deprecation reflects in supported / deprecated split of the model. + [Fact] + public void per_version_metadata_reports_deprecated_subset_correctly() + { + var chains = ListOf(HttpChain.ChainFor(x => x.Get())); + MultiVersionExpansion.ExpandInPlace(chains); + + var policy = new ApiVersioningPolicy(new WolverineApiVersioningOptions()); + policy.Apply(chains, new GenerationRules(), null!); + + foreach (var chain in chains) + { + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + var model = endpoint.Metadata.GetMetadata()!.Map(ApiVersionMapping.Explicit); + + model.SupportedApiVersions.ShouldBe(new[] { new ApiVersion(2, 0) }); + model.DeprecatedApiVersions.ShouldBe(new[] { new ApiVersion(1, 0) }); + } + } + + // 9 — Mandate M3: each clone's endpoint metadata exposes only its own [ApiVersion] attribute. + // OpenAPI tooling that walks endpoint metadata must not see foreign versions, otherwise it + // reports each clone as implementing every sibling version. + [Fact] + public void each_clone_endpoint_metadata_only_lists_its_own_apiversion_attribute() + { + var chains = ListOf(HttpChain.ChainFor(x => x.Get())); + MultiVersionExpansion.ExpandInPlace(chains); + + var policy = new ApiVersioningPolicy(new WolverineApiVersioningOptions()); + policy.Apply(chains, new GenerationRules(), null!); + + foreach (var chain in chains) + { + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + var apiVersionAttrs = endpoint.Metadata.GetOrderedMetadata(); + + apiVersionAttrs.Count.ShouldBe(1); + apiVersionAttrs[0].Versions.ShouldBe(new[] { chain.ApiVersion! }); + } + } + + // 10 — duplicate detection: two distinct multi-version handlers overlapping at (GET, /reports, 2.0) + // throw at the policy stage AFTER expansion. + [Fact] + public void overlapping_versions_across_distinct_multi_version_handlers_throw_at_policy() + { + var chains = ListOf( + HttpChain.ChainFor(x => x.Get()), + HttpChain.ChainFor(x => x.Get())); + + MultiVersionExpansion.ExpandInPlace(chains); + + // First handler => v1 + v2; second handler => v2 + v3. The shared (GET, /reports, 2.0) is the conflict. + chains.Count.ShouldBe(4); + + var policy = new ApiVersioningPolicy(new WolverineApiVersioningOptions()); + var ex = Should.Throw(() => policy.Apply(chains, new GenerationRules(), null!)); + ex.Message.ShouldContain("Duplicate endpoint registration"); + ex.Message.ShouldContain("/reports"); + ex.Message.ShouldContain("2.0"); + + // The diagnostic must name BOTH conflicting handler classes so the developer can locate + // the source of the collision without grepping. Regression guard for issue triage UX. + ex.Message.ShouldContain(nameof(FirstReportsMultiVersionHandler)); + ex.Message.ShouldContain(nameof(SecondReportsMultiVersionHandler)); + } + + // 11 — [MapToApiVersion("X")] producing exactly one version: expansion leaves the chain in place, + // ApiVersioningPolicy.ResolveAttributes assigns the version (no clone is created). + [Fact] + public void mapto_producing_single_version_leaves_chain_in_place_for_policy_resolution() + { + var chain = HttpChain.ChainFor(x => x.Get()); + chain.ApiVersion.ShouldBeNull(); + + var chains = ListOf(chain); + MultiVersionExpansion.ExpandInPlace(chains); + + // No clone — same chain reference, ApiVersion still null until policy runs. + chains.Count.ShouldBe(1); + ReferenceEquals(chains[0], chain).ShouldBeTrue(); + chains[0].ApiVersion.ShouldBeNull(); + + var policy = new ApiVersioningPolicy(new WolverineApiVersioningOptions()); + policy.Apply(chains, new GenerationRules(), null!); + + chains[0].ApiVersion.ShouldBe(new ApiVersion(2, 0)); + } + + // 12 — unversioned handlers in the chain set are untouched by expansion. + [Fact] + public void unversioned_chain_is_left_alone_by_expansion() + { + var unversioned = HttpChain.ChainFor(x => x.Get()); + var multi = HttpChain.ChainFor(x => x.Get()); + + var chains = ListOf(unversioned, multi); + MultiVersionExpansion.ExpandInPlace(chains); + + // Multi-version chain expanded to 2; unversioned chain still present, still unversioned. + chains.Count.ShouldBe(3); + chains.ShouldContain(unversioned); + unversioned.ApiVersion.ShouldBeNull(); + } + + // 13 — date-based versions like 2024-01-01 produce a legal identifier in the OperationId suffix. + // The pre-fix .Replace('.', '_') only handled dotted versions; hyphens leaked through. + [Fact] + public void date_based_version_produces_sanitized_operation_id_suffix() + { + var chains = ListOf(HttpChain.ChainFor(x => x.Get())); + MultiVersionExpansion.ExpandInPlace(chains); + + chains.Count.ShouldBe(2); + + // The version-derived suffix must be a sanitised string (no hyphens, no dots). + // Inspect only the suffix added by CloneForVersion ("_v..." onwards). + var suffixes = chains.Select(c => c.OperationId.Substring(c.OperationId.LastIndexOf("_v"))).ToList(); + suffixes.ShouldAllBe(suffix => !suffix.Contains('-') && !suffix.Contains('.')); + + // ShouldStartWith (rather than exact equality) leaves room for future Asp.Versioning + // changes that may append status/group decorations after the sanitised version body. + suffixes.ShouldContain(s => s.StartsWith("_v2024_01_01")); + suffixes.ShouldContain(s => s.StartsWith("_v2025_06_15")); + } + + // 14 — multi-version handlers in DIFFERENT classes that share a (verb, route) merge into one + // sibling chain when ApiVersioningPolicy.AttachMetadata builds the api-supported-versions + // model. This pins the cross-class union behaviour that matches Asp.Versioning convention: + // any chain at the route is part of the same logical version set, regardless of which class + // declared which version. + [Fact] + public void cross_class_chains_at_same_route_share_supported_versions() + { + var chains = ListOf( + HttpChain.ChainFor(x => x.Get()), + HttpChain.ChainFor(x => x.Get())); + + MultiVersionExpansion.ExpandInPlace(chains); + + var policy = new ApiVersioningPolicy(new WolverineApiVersioningOptions()); + policy.Apply(chains, new GenerationRules(), null!); + + chains.Count.ShouldBe(3); + chains.Select(c => c.ApiVersion!.ToString()).OrderBy(s => s) + .ShouldBe(new[] { "1.0", "2.0", "3.0" }); + + var allVersions = new[] { new ApiVersion(1, 0), new ApiVersion(2, 0), new ApiVersion(3, 0) }; + + foreach (var chain in chains) + { + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + var model = endpoint.Metadata.GetMetadata()!.Map(ApiVersionMapping.Explicit); + + // SupportedApiVersions is the union of every sibling at (GET, /vN/inventory) regardless + // of which handler class produced the clone. ImplementedApiVersions therefore contains + // every version any sibling serves at that logical route. + model.ImplementedApiVersions.OrderBy(v => v).ShouldBe(allVersions); + } + } +} diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/MultiVersionResolverTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/MultiVersionResolverTests.cs new file mode 100644 index 000000000..cca80647b --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/MultiVersionResolverTests.cs @@ -0,0 +1,138 @@ +using System.Reflection; +using Asp.Versioning; +using Shouldly; +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +internal class MethodMultiVersionHandler +{ + [ApiVersion("1.0")] + [ApiVersion("2.0")] + public void Handle() { } +} + +internal class MethodMultiVersionWithDeprecationHandler +{ + [ApiVersion("1.0", Deprecated = true)] + [ApiVersion("2.0")] + public void Handle() { } +} + +[ApiVersion("1.0")] +[ApiVersion("2.0")] +internal class ClassMultiVersionHandler +{ + public void Handle() { } +} + +[ApiVersion("1.0")] +[ApiVersion("2.0")] +[ApiVersion("3.0")] +internal class ClassMultiVersionWithMapToHandler +{ + [MapToApiVersion("2.0")] + public void Handle() { } +} + +[ApiVersion("1.0")] +internal class ClassWithMissingMapToHandler +{ + [MapToApiVersion("3.0")] + public void Handle() { } +} + +internal class MapToWithoutClassHandler +{ + [MapToApiVersion("1.0")] + public void Handle() { } +} + +[ApiVersion("1.0")] +internal class BothApiVersionAndMapToHandler +{ + [ApiVersion("2.0")] + [MapToApiVersion("1.0")] + public void Handle() { } +} + +public class MultiVersionResolverTests +{ + private static MethodInfo MethodOf(string name) + => typeof(T).GetMethod(name, BindingFlags.Public | BindingFlags.Instance)!; + + [Fact] + public void method_with_two_apiversion_attributes_returns_both_versions() + { + var method = MethodOf(nameof(MethodMultiVersionHandler.Handle)); + var versions = ApiVersionResolver.ResolveVersions(method); + + versions.Count.ShouldBe(2); + versions.Select(r => r.Version).ShouldBe(new[] { new ApiVersion(1, 0), new ApiVersion(2, 0) }); + versions.All(r => !r.IsDeprecated).ShouldBeTrue(); + } + + [Fact] + public void method_per_version_deprecation_is_applied_independently() + { + var method = MethodOf(nameof(MethodMultiVersionWithDeprecationHandler.Handle)); + var versions = ApiVersionResolver.ResolveVersions(method).OrderBy(v => v.Version).ToList(); + + versions.Count.ShouldBe(2); + versions[0].Version.ShouldBe(new ApiVersion(1, 0)); + versions[0].IsDeprecated.ShouldBeTrue(); + versions[1].Version.ShouldBe(new ApiVersion(2, 0)); + versions[1].IsDeprecated.ShouldBeFalse(); + } + + [Fact] + public void class_multi_version_method_returns_all_class_versions() + { + var method = MethodOf(nameof(ClassMultiVersionHandler.Handle)); + var versions = ApiVersionResolver.ResolveVersions(method); + + versions.Select(r => r.Version).ShouldBe(new[] { new ApiVersion(1, 0), new ApiVersion(2, 0) }); + } + + [Fact] + public void mapto_filters_class_level_versions_to_listed_subset() + { + var method = MethodOf(nameof(ClassMultiVersionWithMapToHandler.Handle)); + var versions = ApiVersionResolver.ResolveVersions(method); + + versions.Count.ShouldBe(1); + versions[0].Version.ShouldBe(new ApiVersion(2, 0)); + } + + [Fact] + public void mapto_listing_version_not_on_class_throws_naming_both() + { + var method = MethodOf(nameof(ClassWithMissingMapToHandler.Handle)); + + var ex = Should.Throw(() => ApiVersionResolver.ResolveVersions(method)); + ex.Message.ShouldContain("MapToApiVersion"); + ex.Message.ShouldContain("ClassWithMissingMapToHandler"); + ex.Message.ShouldContain("3.0"); + ex.Message.ShouldContain("1.0"); + } + + [Fact] + public void mapto_without_class_apiversion_throws() + { + var method = MethodOf(nameof(MapToWithoutClassHandler.Handle)); + + var ex = Should.Throw(() => ApiVersionResolver.ResolveVersions(method)); + ex.Message.ShouldContain("MapToApiVersion"); + ex.Message.ShouldContain("MapToWithoutClassHandler"); + } + + [Fact] + public void apiversion_and_mapto_on_same_method_throws() + { + var method = MethodOf(nameof(BothApiVersionAndMapToHandler.Handle)); + + var ex = Should.Throw(() => ApiVersionResolver.ResolveVersions(method)); + ex.Message.ShouldContain("[ApiVersion]"); + ex.Message.ShouldContain("[MapToApiVersion]"); + } +} 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..39d2b854e --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_error_path_header_tests.cs @@ -0,0 +1,101 @@ +using Alba; +using Shouldly; + +namespace Wolverine.Http.Tests.ApiVersioning; + +[Collection("integration")] +public class api_versioning_error_path_header_tests : IntegrationContext +{ + // Per-endpoint sibling union: these error-path endpoints only declare v1 and have no v3 + // sibling at the same (verb, route), so api-supported-versions correctly reports "1.0" only. + private const string ExpectedSupportedVersions = "1.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.Tests/ApiVersioning/api_versioning_integration_tests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_integration_tests.cs index e080834c0..5853983c4 100644 --- a/src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_integration_tests.cs +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_integration_tests.cs @@ -94,7 +94,10 @@ public async Task v1_orders_emits_link_header_for_deprecation() [Fact] public async Task api_supported_versions_header_lists_all_versions() { - // Any versioned endpoint emits api-supported-versions + // Any versioned endpoint emits api-supported-versions, built from the per-endpoint + // ApiVersionMetadata seeded by ApiVersioningPolicy with the full sibling union at the + // shared (verb, route-after-strip-prefix). For /vN/orders the siblings are v1+v2+v3 + // (OrdersV1Endpoint, OrdersV2Endpoint, OrdersV3PreviewEndpoint). var result = await Scenario(x => { x.Get.Url("/v2/orders"); @@ -103,8 +106,7 @@ public async Task api_supported_versions_header_lists_all_versions() var header = result.Context.Response.Headers["api-supported-versions"].FirstOrDefault(); header.ShouldNotBeNull(); - // The header is built from SunsetPolicies (3.0) + DeprecationPolicies (1.0), sorted ascending - header.ShouldBe("1.0, 3.0"); + header.ShouldBe("1.0, 2.0, 3.0"); } [Fact] diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/multi_version_integration_tests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/multi_version_integration_tests.cs new file mode 100644 index 000000000..5f2a4d04a --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/multi_version_integration_tests.cs @@ -0,0 +1,157 @@ +using Alba; +using Shouldly; +using WolverineWebApi.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +[Collection("integration")] +public class multi_version_integration_tests : IntegrationContext +{ + public multi_version_integration_tests(AppFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task multi_version_endpoint_registers_v1_route() + { + var result = await Scenario(x => + { + x.Get.Url("/v1/customers"); + x.StatusCodeShouldBeOk(); + }); + + var response = result.ReadAsJson(); + response!.Names.ShouldContain("alice"); + } + + [Fact] + public async Task multi_version_endpoint_registers_v2_route() + { + var result = await Scenario(x => + { + x.Get.Url("/v2/customers"); + x.StatusCodeShouldBeOk(); + }); + + var response = result.ReadAsJson(); + response!.Names.ShouldContain("alice"); + } + + [Fact] + public async Task multi_version_endpoint_registers_v3_route() + { + var result = await Scenario(x => + { + x.Get.Url("/v3/customers"); + x.StatusCodeShouldBeOk(); + }); + + var response = result.ReadAsJson(); + response!.Names.ShouldContain("alice"); + } + + [Fact] + public async Task multi_version_endpoint_v1_carries_globally_configured_deprecation() + { + var result = await Scenario(x => + { + x.Get.Url("/v1/customers"); + x.StatusCodeShouldBeOk(); + }); + + // Program.cs registers options.Deprecate("1.0") globally, so v1 endpoints emit the + // header regardless of the per-version [ApiVersion(..., Deprecated = true)] attribute. + // This test pins down the options-driven path. Attribute-only deprecation is asserted + // separately on /v4/customers, which has no matching options.Deprecate call. + var deprecation = result.Context.Response.Headers["Deprecation"].FirstOrDefault(); + deprecation.ShouldNotBeNull(); + } + + [Fact] + public async Task multi_version_endpoint_v2_is_not_deprecated() + { + var result = await Scenario(x => + { + x.Get.Url("/v2/customers"); + x.StatusCodeShouldBeOk(); + }); + + // v2 has [ApiVersion("2.0")] without Deprecated; no per-version Deprecation header. + var deprecation = result.Context.Response.Headers["Deprecation"].FirstOrDefault(); + deprecation.ShouldBeNull(); + } + + [Fact] + public async Task v4_endpoint_deprecation_comes_from_attribute_alone() + { + var result = await Scenario(x => + { + x.Get.Url("/v4/customers"); + x.StatusCodeShouldBeOk(); + }); + + // CustomersV4AttributeDeprecatedEndpoint is decorated with [ApiVersion("4.0", Deprecated = true)] + // and Program.cs registers no options.Deprecate("4.0"). The Deprecation header therefore + // proves the attribute-driven deprecation path works independently of the options map. + var deprecation = result.Context.Response.Headers["Deprecation"].FirstOrDefault(); + deprecation.ShouldNotBeNull(); + } + + [Fact] + public async Task mapto_apiversion_registers_only_listed_version() + { + var v2 = await Scenario(x => + { + x.Get.Url("/v2/customers/v2-only"); + x.StatusCodeShouldBeOk(); + }); + + var response = v2.ReadAsJson(); + response!.Names.ShouldContain("v2-only-alice"); + + // v1 and v3 routes for this endpoint must not exist. + await Scenario(x => + { + x.Get.Url("/v1/customers/v2-only"); + x.StatusCodeShouldBe(404); + }); + + await Scenario(x => + { + x.Get.Url("/v3/customers/v2-only"); + x.StatusCodeShouldBe(404); + }); + } + + [Fact] + public async Task multi_version_endpoint_emits_api_supported_versions_with_sibling_set() + { + // GET /v1/customers is one clone of CustomersMultiVersionEndpoint, which declares 1.0/2.0/3.0, + // and the same (verb, route-after-strip-prefix) is also served by CustomersV4AttributeDeprecatedEndpoint + // at v4.0. The api-supported-versions header on this clone must report the FULL sibling union + // (every version that serves GET /customers regardless of which handler class), not just the + // per-options policy keys nor the per-clone version. This pins the per-endpoint metadata wiring. + var result = await Scenario(x => + { + x.Get.Url("/v1/customers"); + x.StatusCodeShouldBeOk(); + }); + + var supported = result.Context.Response.Headers["api-supported-versions"].FirstOrDefault(); + supported.ShouldNotBeNull(); + + var values = supported! + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .OrderBy(v => v) + .ToArray(); + + // v1 is deprecated (per-attribute) so reported under deprecated, not supported. v2/v3 supported + // come from CustomersMultiVersionEndpoint, v4 is deprecated (per-attribute) so reported under + // deprecated. The combined api-supported-versions header reports the full sibling chain, so + // every version that has a clone at GET /customers must appear. + values.ShouldContain("1.0"); + values.ShouldContain("2.0"); + values.ShouldContain("3.0"); + values.ShouldContain("4.0"); + } +} 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..779ee0f13 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Globalization; using Asp.Versioning; using Microsoft.AspNetCore.Http; @@ -24,19 +25,31 @@ 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 { private readonly WolverineApiVersioningOptions _options; // Computed once on first request via Lazy. Policies added to the options - // dictionaries after the first request will not appear in this header. In normal + // dictionaries after the first request will not appear in this fallback header. + // The fallback only applies to chains whose endpoint has no ApiVersionMetadata + // (i.e. chains not produced by ApiVersioningPolicy's per-clone wiring); in normal // app startup all policies are registered before any HTTP request is processed, // so this is a safe optimization. - private readonly Lazy _supportedVersionsHeader; + private readonly Lazy _fallbackSupportedVersionsHeader; /// /// Initializes a new instance of . @@ -45,25 +58,78 @@ public sealed class ApiVersionHeaderWriter public ApiVersionHeaderWriter(WolverineApiVersioningOptions options) { _options = options; - _supportedVersionsHeader = new Lazy(() => BuildSupportedVersionsHeader(options)); + _fallbackSupportedVersionsHeader = new Lazy(() => BuildFallbackSupportedVersionsHeader(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 api-supported-versions header reads from the endpoint's + /// (seeded by with the + /// full sibling union for chains at the same (verb, route-after-strip-prefix)), + /// falling back to the options-driven sunset/deprecation key union when no metadata is + /// present on the endpoint. /// + /// + /// 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) { - var state = context.GetEndpoint()?.Metadata.GetMetadata(); + var endpoint = context.GetEndpoint(); + var state = endpoint?.Metadata.GetMetadata(); if (state is null) return Task.CompletedTask; + // Capture this + context: writer is already resolved from DI by the generated handler + // (Wolverine codegen via MethodCall.For), so no service location is needed in the callback. + // One closure allocation per request matches the cost ASP.NET Core middleware pays for OnStarting. + context.Response.OnStarting(() => + { + // Re-fetch inside OnStarting because the endpoint can be re-routed by middleware between this frame and header-flush time. + var hdrState = context.GetEndpoint()?.Metadata.GetMetadata(); + if (hdrState is not null) + ApplyHeaders(context, hdrState); + return Task.CompletedTask; + }); + + 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) - headers["api-supported-versions"] = _supportedVersionsHeader.Value; + if (_options.EmitApiSupportedVersionsHeader) + { + var endpoint = context.GetEndpoint(); + if (endpoint is not null) + { + var supportedHeader = BuildSupportedVersionsHeader(endpoint); + if (supportedHeader.Length > 0) + headers["api-supported-versions"] = supportedHeader; + } + } if (_options.EmitDeprecationHeaders) { @@ -81,11 +147,35 @@ public Task WriteAsync(HttpContext context) if (links.Length > 0) headers["Link"] = links; } + } - return Task.CompletedTask; + /// + /// Build the api-supported-versions header value for a single request. The endpoint's + /// is the authoritative source — it carries the full sibling + /// union assembled by at startup, so the header reflects every + /// version that serves the same (verb, route-after-strip-prefix), supported and + /// deprecated alike (matching the Asp.Versioning convention of reporting + /// ImplementedApiVersions). Falls back to the options-driven union for chains that have + /// no per-endpoint metadata (e.g. chains wired up outside the policy pipeline). + /// + private string BuildSupportedVersionsHeader(Endpoint endpoint) + { + var metadata = endpoint.Metadata.GetMetadata(); + if (metadata is null) + return _fallbackSupportedVersionsHeader.Value; + + var model = metadata.Map(ApiVersionMapping.Explicit); + var versions = model.ImplementedApiVersions; + if (versions.Count == 0) + return _fallbackSupportedVersionsHeader.Value; + + return string.Join(", ", versions + .OrderBy(v => v.MajorVersion ?? int.MaxValue) + .ThenBy(v => v.MinorVersion ?? int.MaxValue) + .Select(v => v.ToString())); } - private static string BuildSupportedVersionsHeader(WolverineApiVersioningOptions options) + private static string BuildFallbackSupportedVersionsHeader(WolverineApiVersioningOptions options) { var versions = options.SunsetPolicies.Keys .Concat(options.DeprecationPolicies.Keys) diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs index 56ce41d1e..a7eab839a 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs @@ -8,45 +8,93 @@ namespace Wolverine.Http.ApiVersioning; internal static class ApiVersionResolver { /// - /// Resolves the API version declared on a handler method. The method's [ApiVersion] wins; - /// the declaring class's [ApiVersion] is used as a fallback. + /// Resolves all API versions a handler method serves. Resolution rules: + /// + /// Method-level [ApiVersion] attributes (if any) override class-level entirely. + /// Method-level [MapToApiVersion] filters class-level versions to the listed subset. + /// A method may not carry both [ApiVersion] and [MapToApiVersion]. + /// /// /// The handler method. - /// The single resolved ApiVersion with deprecation status, or null if no [ApiVersion] is present. - /// Thrown when the method (or, when no method-level attribute is present, the declaring class) declares more than one ApiVersion in a single ApiVersionAttribute or via multiple attributes. - public static ApiVersionResolution? Resolve(MethodInfo method) + /// An ordered, distinct list of ; empty when no version attributes are present. + /// Thrown when both [ApiVersion] and [MapToApiVersion] are declared on the same method, or when [MapToApiVersion] lists a version not declared on the class. + public static IReadOnlyList ResolveVersions(MethodInfo method) { - var methodAttrs = method.GetCustomAttributes(inherit: false).ToList(); - List winningAttrs; + var methodApiVersionAttrs = method.GetCustomAttributes(inherit: false).ToList(); + var methodMapToAttrs = method.GetCustomAttributes(inherit: false).ToList(); - if (methodAttrs.Count > 0) + if (methodApiVersionAttrs.Count > 0 && methodMapToAttrs.Count > 0) { - winningAttrs = methodAttrs; + throw new InvalidOperationException( + $"Handler method '{MethodIdentity(method)}' declares both [ApiVersion] and [MapToApiVersion] attributes. " + + "Use only one: [ApiVersion] sets versions independently of the class; " + + "[MapToApiVersion] selects a subset of the class-level versions."); } - else + + // Method-level [ApiVersion] overrides class entirely. + if (methodApiVersionAttrs.Count > 0) + { + var methodVersions = methodApiVersionAttrs.SelectMany(a => a.Versions).Distinct().ToList(); + return BuildResolutions(methodVersions, methodApiVersionAttrs); + } + + var classApiVersionAttrs = method.DeclaringType? + .GetCustomAttributes(inherit: false) + .ToList() ?? new List(); + + // Method-level [MapToApiVersion] filters class-level versions. + if (methodMapToAttrs.Count > 0) { - var classAttrs = method.DeclaringType?.GetCustomAttributes(inherit: false).ToList(); - if (classAttrs is null || classAttrs.Count == 0) + if (classApiVersionAttrs.Count == 0) { - return null; + var className = method.DeclaringType?.FullName ?? method.DeclaringType?.Name ?? "?"; + throw new InvalidOperationException( + $"Handler method '{MethodIdentity(method)}' has [MapToApiVersion] but the declaring class '{className}' has no [ApiVersion] attribute. " + + "[MapToApiVersion] only filters class-level versions; declare class-level [ApiVersion] first."); } - winningAttrs = classAttrs; + var classVersions = classApiVersionAttrs.SelectMany(a => a.Versions).Distinct().ToList(); + var requestedVersions = methodMapToAttrs.SelectMany(a => a.Versions).Distinct().ToList(); + + var missing = requestedVersions.Where(v => !classVersions.Contains(v)).ToList(); + if (missing.Count > 0) + { + var className = method.DeclaringType?.FullName ?? method.DeclaringType?.Name ?? "?"; + var missingList = string.Join(", ", missing.Select(v => v.ToString())); + var classList = string.Join(", ", classVersions.Select(v => v.ToString())); + throw new InvalidOperationException( + $"Handler method '{MethodIdentity(method)}' has [MapToApiVersion({missingList})] but the declaring class '{className}' " + + $"only declares [ApiVersion] for [{classList}]. [MapToApiVersion] must list a subset of class-level versions."); + } + + // Deprecation flags on the class still apply to the filtered subset. + return BuildResolutions(requestedVersions, classApiVersionAttrs); } - var versions = winningAttrs.SelectMany(a => a.Versions).Distinct().ToList(); + if (classApiVersionAttrs.Count == 0) return Array.Empty(); + var classDeclared = classApiVersionAttrs.SelectMany(a => a.Versions).Distinct().ToList(); + return BuildResolutions(classDeclared, classApiVersionAttrs); + } - if (versions.Count == 1) + /// + /// Builds resolutions for the given in order, marking each as + /// deprecated when any attribute in declares the version + /// with . Used by every branch of + /// : class-only, method-only, and [MapToApiVersion] filtering. + /// + private static IReadOnlyList BuildResolutions( + IReadOnlyCollection versions, + IReadOnlyCollection deprecationSource) + { + var result = new List(versions.Count); + foreach (var version in versions) { - var version = versions[0]; - var isDeprecated = winningAttrs.Any(a => a.Deprecated && a.Versions.Contains(version)); - return new ApiVersionResolution(version, isDeprecated); + var isDeprecated = deprecationSource.Any(a => a.Deprecated && a.Versions.Contains(version)); + result.Add(new ApiVersionResolution(version, isDeprecated)); } - - var methodIdentity = (method.DeclaringType?.FullName ?? method.DeclaringType?.Name ?? "?") + "." + method.Name; - var versionList = string.Join(", ", versions); - throw new InvalidOperationException( - $"Handler method '{methodIdentity}' declares multiple API versions [{versionList}]. " + - "Multi-version handlers are not supported in this version of Wolverine.Http API versioning."); + return result; } + + private static string MethodIdentity(MethodInfo method) => + (method.DeclaringType?.FullName ?? method.DeclaringType?.Name ?? "?") + "." + method.Name; } diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs index 442fbd925..63526cb32 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,10 +50,24 @@ public void Apply(IReadOnlyList chains, GenerationRules rules, IServi DetectDuplicateRoutes(chains); RewriteRoutes(chains); AttachMetadata(chains); - WireHeaderPostprocessors(chains); + AttachHeaderState(chains); } - /// Step A — read [ApiVersion] / [ApiVersionNeutral] from the handler method and propagate to the chain. + /// Step A — read [ApiVersion] / [ApiVersionNeutral] from the handler + /// method and propagate to the chain. Order matters here: + /// + /// Check neutrality first so a method-level [ApiVersionNeutral] + /// can clear a prior fluent HasApiVersion(...) assignment on the chain + /// (test pin: method_level_neutral_clears_prior_fluent_apiversion_assignment). + /// If the chain already carries a version after the neutrality check, it + /// came from multi-version expansion or a fluent assignment — keep it as-is. Falling + /// through to on a multi-version method + /// would return every declared version and indexing [0] would silently misclassify + /// clones. + /// Otherwise resolve from method/class attributes; take the first entry + /// after an explicit count check so the default(ApiVersionResolution) foot-gun + /// (a struct with a null Version) is not relied on for the empty case. + /// private static void ResolveAttributes(IReadOnlyList chains) { foreach (var chain in chains) @@ -54,27 +75,35 @@ private static void ResolveAttributes(IReadOnlyList chains) if (chain.Method?.Method is null) continue; + // Single reflection pass — resolves neutrality and validates that [ApiVersion] + // [ApiVersionNeutral] are not both declared on the same target (throws on conflict). - // Method-level wins over class-level in both directions. + // Method-level wins over class-level in both directions. Run this before the + // already-assigned guard below so a fluent HasApiVersion(...) does not suppress a + // method-level [ApiVersionNeutral] override. Multi-version clones cannot be neutral + // (their underlying method declares [ApiVersion]s, so the resolver returns false). if (ApiVersionNeutralResolver.Resolve(chain.Method.Method)) { chain.IsApiVersionNeutral = true; - // Clear any prior fluent HasApiVersion(...) assignment — a method-level - // [ApiVersionNeutral] overriding a versioned class must not leave a stale version - // on the chain that DetectDuplicateRoutes / RewriteRoutes would later observe. chain.ApiVersion = null; continue; } - var resolution = ApiVersionResolver.Resolve(chain.Method.Method); - if (resolution is null) + // Chains produced by multi-version expansion already have ApiVersion assigned; + // chains with a fluent HasApiVersion(...) likewise. In both cases the prior assignment + // wins. Skipping ResolveVersions here also avoids picking versions[0] on a + // multi-version clone and silently misclassifying it. + if (chain.ApiVersion is not null) continue; - if (chain.ApiVersion is null) - chain.ApiVersion = resolution.Value.Version; + var versions = ApiVersionResolver.ResolveVersions(chain.Method.Method); + if (versions.Count == 0) + continue; + + var resolution = versions[0]; + chain.ApiVersion = resolution.Version; - if (resolution.Value.IsDeprecated && chain.DeprecationPolicy is null) + if (resolution.IsDeprecated && chain.DeprecationPolicy is null) chain.DeprecationPolicy = new DeprecationPolicy(); } } @@ -171,7 +200,13 @@ private static void DetectConflicts( foreach (var conflict in conflicts) { - var names = string.Join(", ", conflict.Select(Identify)); + // Use OperationId here (rather than the shared DisplayName via Identify) so the + // diagnostic names every conflicting clone individually — the version-suffixed + // OperationIds make each clone uniquely identifiable when sibling clones across + // distinct handler classes collide at the same (verb, route, version) triple. + // Neutral chains likewise have unique OperationIds, so the same naming works for both + // describe() callers. + var names = string.Join(", ", conflict.Select(c => c.OperationId)); throw new InvalidOperationException(describe(conflict.Key, names)); } } @@ -229,13 +264,48 @@ private string BuildExpectedPrefix(ApiVersion version) } /// Step F — attach group-name, ApiVersionMetadata, and ensure unique endpoint names. - /// Version-neutral chains receive so consumers of the - /// metadata graph (Asp.Versioning tooling, the Swashbuckle filter) can recognise them, but they - /// deliberately get no IEndpointGroupNameMetadata. Without a group name they are skipped - /// by Swashbuckle's default group-name partitioning; users opt them into versioned documents + /// Versioned chains' ApiVersionMetadata model is seeded with the union of versions + /// implemented at the same (verb, route) pair so the api-supported-versions response + /// header reports every sibling clone, not just this clone's own version. Version-neutral + /// chains receive so consumers of the metadata graph + /// (Asp.Versioning tooling, the Swashbuckle filter) can recognise them, but they deliberately + /// get no IEndpointGroupNameMetadata. Without a group name they are skipped by + /// Swashbuckle's default group-name partitioning; users opt them into versioned documents /// from DocInclusionPredicate (see versioning.md). + /// + /// The sibling grouping key for versioned chains is (verb, route-after-strip-prefix), + /// NOT (verb, route-after-strip-prefix, handler-type). Chains from distinct handler + /// classes that publish the same logical route are merged into one sibling set. This matches + /// the Asp.Versioning convention where any chain at the route is part of the same logical + /// version set regardless of which class declared which version (e.g. + /// OrdersV1V2Endpoint declaring v1+v2 and OrdersV3Endpoint declaring v3 at the + /// same (GET, /orders) route are merged into one sibling chain advertising 1.0/2.0/3.0 + /// in api-supported-versions). The cross_class_chains_at_same_route_share_supported_versions + /// integration test pins this behaviour. + /// private void AttachMetadata(IReadOnlyList chains) { + // Group versioned chains by (verb, route-without-version-prefix). Two chains in the same + // group are siblings — typically multi-version clones, but also any chains that happen to + // share a verb and the post-strip route. Each clone's model advertises the full sibling set + // as supported / deprecated so the response header consumers see the union. + var siblingsByKey = new Dictionary<(string Verb, string Route), List>(); + foreach (var chain in chains) + { + if (chain.ApiVersion is null) continue; + + var key = ( + Verb: chain.HttpMethods.FirstOrDefault() ?? "", + Route: StripVersionPrefix(chain)); + + if (!siblingsByKey.TryGetValue(key, out var bucket)) + { + bucket = new List(); + siblingsByKey[key] = bucket; + } + bucket.Add(chain); + } + foreach (var chain in chains) { // Mirror ApplyUnversionedPolicy: deal with the neutral branch first so the intent of @@ -268,7 +338,28 @@ private void AttachMetadata(IReadOnlyList chains) var groupName = _options.OpenApi.DocumentNameStrategy(chain.ApiVersion); chain.Metadata.WithGroupName(groupName); - var model = new ApiVersionModel(chain.ApiVersion); + var key = ( + Verb: chain.HttpMethods.FirstOrDefault() ?? "", + Route: StripVersionPrefix(chain)); + + var siblings = siblingsByKey[key]; + var supported = siblings + .Where(s => s.DeprecationPolicy is null) + .Select(s => s.ApiVersion!) + .Distinct() + .ToArray(); + var deprecated = siblings + .Where(s => s.DeprecationPolicy is not null) + .Select(s => s.ApiVersion!) + .Distinct() + .ToArray(); + + var model = new ApiVersionModel( + declaredVersions: new[] { chain.ApiVersion }, + supportedVersions: supported, + deprecatedVersions: deprecated, + advertisedVersions: Array.Empty(), + deprecatedAdvertisedVersions: Array.Empty()); chain.Metadata.WithMetadata(new ApiVersionMetadata(model, model)); // Make the OperationId (already unique per handler type + method) the explicit @@ -279,38 +370,67 @@ private void AttachMetadata(IReadOnlyList chains) } } + /// Removes the URL-segment version prefix (if one was injected by ) + /// from the chain's current route, returning the trailing portion that is identical across all + /// sibling versions. When is null + /// the original route is returned unchanged. + private string StripVersionPrefix(HttpChain chain) + { + var route = chain.RoutePattern?.RawText ?? string.Empty; + if (_options.UrlSegmentPrefix is null) return route; + + var prefix = BuildExpectedPrefix(chain.ApiVersion!); + if (route == prefix) return string.Empty; + if (route.StartsWith(prefix + "/", StringComparison.Ordinal)) + return route.Substring(prefix.Length); + + return route; + } + private static void EnsureExplicitOperationId(HttpChain chain) { if (!chain.HasExplicitOperationId) chain.SetExplicitOperationId(chain.OperationId); } - /// 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; + /// + /// Diagnostic identifier for a chain in error messages from the unversioned-policy and other + /// non-clone code paths. Prefers so consumer-friendly + /// labels (e.g. "GET /orders (unversioned)") are preserved verbatim. The duplicate-route + /// detector in intentionally uses + /// instead because clones share a DisplayName but have + /// version-suffixed OperationIds. + /// private static string Identify(HttpChain chain) => chain.DisplayName ?? (chain.Method?.Method?.DeclaringType?.FullName + "." + chain.Method?.Method?.Name) diff --git a/src/Http/Wolverine.Http/ApiVersioning/MultiVersionExpansion.cs b/src/Http/Wolverine.Http/ApiVersioning/MultiVersionExpansion.cs new file mode 100644 index 000000000..acb72b32c --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/MultiVersionExpansion.cs @@ -0,0 +1,39 @@ +using Asp.Versioning; + +namespace Wolverine.Http.ApiVersioning; + +/// +/// Expands handler chains that declare more than one API version (via multiple +/// [ApiVersion] attributes or [MapToApiVersion] filtering class-level versions) +/// into one chain per version. Runs before any , so middleware, +/// route prefix, and downstream policies apply uniformly to every clone. +/// +internal static class MultiVersionExpansion +{ + /// + /// Mutates in place: every chain whose handler declares more than + /// one API version is removed and replaced with one clone per declared version. Single-version + /// and unversioned chains are left untouched; the downstream + /// resolves them via . + /// + public static void ExpandInPlace(List chains) + { + for (var i = chains.Count - 1; i >= 0; i--) + { + var chain = chains[i]; + if (chain.Method?.Method is null) continue; + + var versions = ApiVersionResolver.ResolveVersions(chain.Method.Method); + if (versions.Count < 2) continue; + + chains.RemoveAt(i); + // Insert clones at the original position so chains keep stable ordering. + for (var j = versions.Count - 1; j >= 0; j--) + { + var resolution = versions[j]; + var clone = chain.CloneForVersion(resolution.Version, resolution.IsDeprecated); + chains.Insert(i, clone); + } + } + } +} diff --git a/src/Http/Wolverine.Http/HttpChain.cs b/src/Http/Wolverine.Http/HttpChain.cs index 8e186da50..9c535e9ff 100644 --- a/src/Http/Wolverine.Http/HttpChain.cs +++ b/src/Http/Wolverine.Http/HttpChain.cs @@ -2,6 +2,7 @@ using System.Linq.Expressions; using System.Reflection; using System.Text.Json; +using System.Text.RegularExpressions; using Asp.Versioning; using JasperFx; using JasperFx.CodeGeneration; @@ -56,6 +57,10 @@ public static bool IsValidResponseType(Type type) public static readonly Variable[] HttpContextVariables = Variable.VariablesForProperties(HttpGraph.Context); + // Used by CloneForVersion to sanitize ApiVersion text (e.g. "2024-01-01") into a legal + // identifier suffix for OperationId. Compiled once; only ASCII alphanumerics survive. + private static readonly Regex NonAlphanumeric = new(@"[^A-Za-z0-9]", RegexOptions.Compiled); + internal Variable? RequestBodyVariable { get; set; } private string? _fileName; @@ -275,6 +280,64 @@ public HttpChain HasApiVersion(ApiVersion version) return this; } + /// + /// Builds a fresh from the same handler method so it can serve a + /// distinct API version. The clone re-runs the standard ctor pipeline (attributes, configure + /// methods, parameter matching, implied middleware), so attribute-driven policies — auth, + /// fluent validation, before/after middleware, cascading messages — are reapplied per version. + /// The clone's is set to and its + /// is set when is true. + /// + /// + /// Used by multi-version expansion at bootstrap. Expansion runs before any policy in the + /// HTTP pipeline so middleware, route prefix, and downstream policies are applied to clones + /// uniformly with the source chain. + /// + internal HttpChain CloneForVersion(ApiVersion version, bool isDeprecated) + { + // Each clone needs its own MethodCall so JasperFx codegen can wire each handler frame + // independently. Re-using the source MethodCall makes the second clone's codegen throw + // "Frame chain is being re-arranged" when JasperFx tries to set Next on a frame that's + // already chained from the first clone. + var clonedMethodCall = new MethodCall(Method.HandlerType, Method.Method); + var clone = new HttpChain(clonedMethodCall, _parent) + { + ServiceProviderSource = ServiceProviderSource, + ApiVersion = version + }; + + // Multi-version expansion produces N chains sharing the same handler method, so the + // ctor-derived OperationId collides across clones. Suffix it with the version to keep + // ASP.NET Core's "endpoint names must be globally unique" invariant intact. + // Sanitize by replacing every non-alphanumeric character so date-based versions like + // 2024-01-01 still produce a legal identifier (2024_01_01) instead of leaking hyphens. + var versionSuffix = NonAlphanumeric.Replace(version.ToString(), "_"); + clone.OperationId = $"{clone.OperationId}_v{versionSuffix}"; + + if (isDeprecated) + { + clone.DeprecationPolicy ??= new DeprecationPolicy(); + } + + // Strip [ApiVersion] / [MapToApiVersion] attributes that don't match this clone's version. + // applyMetadata() copied every class- and method-level attribute onto the clone, so without + // this pass each clone's ASP.NET Core endpoint metadata reports ALL of the multi-version + // declarations and OpenAPI tooling reports each clone as implementing every sibling version. + clone.Metadata.Add(builder => + { + for (var i = builder.Metadata.Count - 1; i >= 0; i--) + { + var m = builder.Metadata[i]; + if (m is ApiVersionAttribute a && !a.Versions.Contains(version)) + builder.Metadata.RemoveAt(i); + else if (m is MapToApiVersionAttribute mp && !mp.Versions.Contains(version)) + builder.Metadata.RemoveAt(i); + } + }); + + return clone; + } + public static HttpChain ChainFor(Expression> expression, HttpGraph? parent = null) { var method = ReflectionHelper.GetMethod(expression); diff --git a/src/Http/Wolverine.Http/HttpGraph.cs b/src/Http/Wolverine.Http/HttpGraph.cs index 5286f1d2b..9d13d0adb 100644 --- a/src/Http/Wolverine.Http/HttpGraph.cs +++ b/src/Http/Wolverine.Http/HttpGraph.cs @@ -109,6 +109,14 @@ public void DiscoverEndpoints(WolverineHttpOptions wolverineHttpOptions) _chains.AddRange(calls.Select(x => new HttpChain(x, this){ServiceProviderSource = wolverineHttpOptions.ServiceProviderSource})); + // Expand multi-version handlers before any policy runs, so middleware, route prefix, + // and other policies are applied uniformly to every per-version clone. Without this, + // clones would miss whatever the policies subsequently mutate. + if (wolverineHttpOptions.ApiVersioning is not null) + { + ApiVersioning.MultiVersionExpansion.ExpandInPlace(_chains); + } + wolverineHttpOptions.Middleware.Apply(_chains, Rules, Container); _optionsWriterPolicies.AddRange(wolverineHttpOptions.ResourceWriterPolicies); 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/CustomersMultiVersionEndpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/CustomersMultiVersionEndpoint.cs new file mode 100644 index 000000000..634de46a1 --- /dev/null +++ b/src/Http/WolverineWebApi/ApiVersioning/CustomersMultiVersionEndpoint.cs @@ -0,0 +1,61 @@ +using Asp.Versioning; +using Wolverine.Http; + +namespace WolverineWebApi.ApiVersioning; + +/// +/// Multi-version handler example. Class-level [ApiVersion] declares every version this +/// type serves; the single Get method serves all three. The Wolverine.Http startup pipeline +/// expands this class into one HTTP chain per version, each rewritten with the URL-segment prefix +/// (e.g. /v1/customers, /v2/customers, /v3/customers). +/// +[ApiVersion("1.0", Deprecated = true)] +[ApiVersion("2.0")] +[ApiVersion("3.0")] +public static class CustomersMultiVersionEndpoint +{ + // The explicit OperationId is retained here because WolverineWebApi is also loaded by + // tests that DO NOT call options.UseApiVersioning(). In that mode MultiVersionExpansion + // and ApiVersioningPolicy never run, so the per-clone auto-suffix and the SetExplicitOperationId + // call in AttachMetadata never fire — without an explicit OperationId on the source attribute, + // multiple chains at /customers (this class plus CustomersV4AttributeDeprecatedEndpoint) would + // collide on the route-derived endpoint name 'GET_customers'. When versioning IS enabled, the + // policy auto-suffixes each clone (CustomersMultiVersionEndpoint.Get_v1_0, _v2_0, _v3_0) so + // global uniqueness is guaranteed regardless of this attribute. + [WolverineGet("/customers", OperationId = "CustomersMultiVersionEndpoint.Get")] + public static CustomersResponse Get() => new(["alice", "bob"]); +} + +/// +/// [MapToApiVersion] example: the class advertises 1.0, 2.0, and 3.0; this method opts in +/// to v2.0 only. The other versions are not registered for this route. +/// +[ApiVersion("1.0")] +[ApiVersion("2.0")] +[ApiVersion("3.0")] +public static class CustomersV2OnlyEndpoint +{ + [WolverineGet("/customers/v2-only", OperationId = "CustomersV2OnlyEndpoint.Get")] + [MapToApiVersion("2.0")] + public static CustomersResponse Get() => new(["v2-only-alice"]); +} + +/// +/// Attribute-only deprecation example. v4.0 is marked deprecated via the attribute alone — there +/// is no options.Deprecate("4.0") in Program.cs, so the Deprecation response +/// header on /v4/customers proves the per-version attribute is honoured independently of +/// the options-driven sunset / deprecation map. Used by integration tests to isolate attribute +/// behaviour from options behaviour. +/// +[ApiVersion("4.0", Deprecated = true)] +public static class CustomersV4AttributeDeprecatedEndpoint +{ + // Explicit OperationId required for the same reason documented on CustomersMultiVersionEndpoint: + // tests that load WolverineWebApi without UseApiVersioning() must still produce unique endpoint + // names. Both chains share GET /customers; without explicit operation IDs they collide on + // 'GET_customers' before any policy can disambiguate them. + [WolverineGet("/customers", OperationId = "CustomersV4AttributeDeprecatedEndpoint.Get")] + public static CustomersResponse Get() => new(["v4-alice"]); +} + +public record CustomersResponse(IReadOnlyList Names); 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..aebcde1d6 100644 --- a/src/Http/WolverineWebApi/Program.cs +++ b/src/Http/WolverineWebApi/Program.cs @@ -64,6 +64,9 @@ public static async Task Main(string[] args) x.SwaggerDoc("v1", new OpenApiInfo { Title = "Wolverine Web API v1", Version = "v1" }); x.SwaggerDoc("v2", new OpenApiInfo { Title = "Wolverine Web API v2", Version = "v2" }); x.SwaggerDoc("v3", new OpenApiInfo { Title = "Wolverine Web API v3", Version = "v3" }); + // v4 has no options.Deprecate("4.0") — used by integration tests to prove the + // attribute-driven [ApiVersion("4.0", Deprecated = true)] is honoured on its own. + x.SwaggerDoc("v4", new OpenApiInfo { Title = "Wolverine Web API v4", Version = "v4" }); x.OperationFilter(); x.OperationFilter(); x.DocInclusionPredicate((docName, api) => @@ -216,6 +219,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 =>