From bfaeb9abd30d51e12f739a6fe6fb90d10ea83a3f Mon Sep 17 00:00:00 2001 From: Geoffrey MARC Date: Thu, 30 Apr 2026 14:19:03 +0200 Subject: [PATCH 1/3] feat(http): add native API versioning support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds URL-segment API versioning to Wolverine.Http driven from an IHttpPolicy. Single dependency on Asp.Versioning.Abstractions 10.0.0 (no Asp.Versioning.Http). * HttpChain.ApiVersion / SunsetPolicy / DeprecationPolicy properties * WolverineHttpOptions.UseApiVersioning configuration surface with fluent Sunset / Deprecate per-version builders * [ApiVersion] attribute resolver — method-wins-over-class, multi-version handlers rejected at startup * ApiVersioningPolicy: route rewriting, sunset/deprecation propagation, duplicate (verb,route,version) detection, IEndpointGroupNameMetadata for OpenAPI document partitioning, Asp.Versioning.ApiVersionMetadata for downstream tooling * ApiVersionHeaderWriter: RFC 9745 Deprecation, RFC 8594 Sunset, RFC 8288 Link, api-supported-versions response headers * DescribeWolverineApiVersions extension for SwaggerUI / Scalar dropdown wiring * WolverineApiVersioningOpenApiOptions.DocumentNameStrategy for customising per-version OpenAPI document names --- Directory.Packages.props | 2 + .../ApiVersionHeaderWriterTests.cs | 214 +++++++++++++ .../ApiVersioning/ApiVersionResolverTests.cs | 88 +++++ .../ApiVersioningPolicyHeaderWiringTests.cs | 119 +++++++ .../ApiVersioning/ApiVersioningPolicyTests.cs | 300 ++++++++++++++++++ .../DescribeWolverineApiVersionsTests.cs | 275 ++++++++++++++++ .../HttpChainApiVersioningTests.cs | 47 +++ .../ApiVersioning/UseApiVersioningTests.cs | 43 +++ ...lverineApiVersioningOpenApiOptionsTests.cs | 38 +++ .../WolverineApiVersioningOptionsTests.cs | 104 ++++++ ...piVersioningSwaggerOperationFilterTests.cs | 88 +++++ .../ApiVersioning/ApiVersionHeaderWriter.cs | 129 ++++++++ .../ApiVersioning/ApiVersionResolver.cs | 52 +++ .../ApiVersioning/ApiVersioningPolicy.cs | 239 ++++++++++++++ .../ApiVersioning/DeprecationPolicyBuilder.cs | 89 ++++++ .../HttpChainApiVersioningExtensions.cs | 14 + .../ApiVersioning/SunsetPolicyBuilder.cs | 89 ++++++ .../ApiVersioning/UnversionedPolicy.cs | 17 + .../WolverineApiVersionDescription.cs | 28 ++ .../WolverineApiVersioningOpenApiOptions.cs | 28 ++ .../WolverineApiVersioningOptions.cs | 102 ++++++ ...neOpenApiEndpointRouteBuilderExtensions.cs | 97 ++++++ src/Http/Wolverine.Http/HttpChain.cs | 29 ++ src/Http/Wolverine.Http/Wolverine.Http.csproj | 1 + ...erineHttpEndpointRouteBuilderExtensions.cs | 9 + .../Wolverine.Http/WolverineHttpOptions.cs | 22 ++ 26 files changed, 2263 insertions(+) create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionResolverTests.cs create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyTests.cs create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/DescribeWolverineApiVersionsTests.cs create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/HttpChainApiVersioningTests.cs create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/UseApiVersioningTests.cs create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningOpenApiOptionsTests.cs create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningOptionsTests.cs create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningSwaggerOperationFilterTests.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/DeprecationPolicyBuilder.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/HttpChainApiVersioningExtensions.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/SunsetPolicyBuilder.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/UnversionedPolicy.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersionDescription.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersioningOpenApiOptions.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersioningOptions.cs create mode 100644 src/Http/Wolverine.Http/ApiVersioning/WolverineOpenApiEndpointRouteBuilderExtensions.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 4ed5fd0f8..8628c3eb1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + @@ -83,6 +84,7 @@ + diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs new file mode 100644 index 000000000..6e1f3078d --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionHeaderWriterTests.cs @@ -0,0 +1,214 @@ +using System.Globalization; +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Shouldly; +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +public class ApiVersionHeaderWriterTests +{ + // Helper: build a DefaultHttpContext that has the given state attached as endpoint metadata. + private static DefaultHttpContext ContextWithState(ApiVersionEndpointHeaderState state) + { + var ctx = new DefaultHttpContext(); + 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() + { + return new DefaultHttpContext(); + } + + // 1 — no state → no headers emitted + [Fact] + public async Task no_state_metadata_emits_no_headers() + { + var opts = new WolverineApiVersioningOptions(); + var writer = new ApiVersionHeaderWriter(opts); + var ctx = ContextWithNoState(); + + await writer.WriteAsync(ctx); + + ctx.Response.Headers.ContainsKey("api-supported-versions").ShouldBeFalse(); + ctx.Response.Headers.ContainsKey("Deprecation").ShouldBeFalse(); + ctx.Response.Headers.ContainsKey("Sunset").ShouldBeFalse(); + ctx.Response.Headers.ContainsKey("Link").ShouldBeFalse(); + } + + // 2 — api-supported-versions is union of sunset + deprecation policy keys, sorted ascending + [Fact] + public async Task api_supported_versions_includes_sunset_and_deprecation_keys() + { + var date1 = new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero); + var date2 = new DateTimeOffset(2028, 1, 1, 0, 0, 0, TimeSpan.Zero); + + var opts = new WolverineApiVersioningOptions(); + opts.Sunset("1.0").On(date1); + opts.Deprecate("2.0").On(date2); + + var writer = new ApiVersionHeaderWriter(opts); + var state = new ApiVersionEndpointHeaderState( + new ApiVersion(1, 0), + opts.SunsetPolicies[new ApiVersion(1, 0)], + null); + var ctx = ContextWithState(state); + + await writer.WriteAsync(ctx); + + ctx.Response.Headers["api-supported-versions"].ToString().ShouldBe("1.0, 2.0"); + } + + // 3 — Deprecation header uses IMF-fixdate when policy has a date + [Fact] + public async Task deprecation_with_date_uses_imf_fixdate() + { + var depDate = new DateTimeOffset(2030, 6, 15, 0, 0, 0, TimeSpan.Zero); + var opts = new WolverineApiVersioningOptions(); + var depPolicy = new DeprecationPolicy(depDate); + var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), null, depPolicy); + var writer = new ApiVersionHeaderWriter(opts); + var ctx = ContextWithState(state); + + await writer.WriteAsync(ctx); + + var expected = depDate.UtcDateTime.ToString("R", CultureInfo.InvariantCulture); + ctx.Response.Headers["Deprecation"].ToString().ShouldBe(expected); + } + + // 4 — Deprecation header is "true" when policy has no date + [Fact] + public async Task deprecation_without_date_emits_true_token() + { + var opts = new WolverineApiVersioningOptions(); + var depPolicy = new DeprecationPolicy(); + var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), null, depPolicy); + var writer = new ApiVersionHeaderWriter(opts); + var ctx = ContextWithState(state); + + await writer.WriteAsync(ctx); + + ctx.Response.Headers["Deprecation"].ToString().ShouldBe("true"); + } + + // 5 — Sunset header uses IMF-fixdate + [Fact] + public async Task sunset_emits_imf_fixdate() + { + var sunsetDate = new DateTimeOffset(2029, 12, 31, 0, 0, 0, TimeSpan.Zero); + var opts = new WolverineApiVersioningOptions(); + var sunsetPolicy = new SunsetPolicy(sunsetDate); + var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), sunsetPolicy, null); + var writer = new ApiVersionHeaderWriter(opts); + var ctx = ContextWithState(state); + + await writer.WriteAsync(ctx); + + var expected = sunsetDate.UtcDateTime.ToString("R", CultureInfo.InvariantCulture); + ctx.Response.Headers["Sunset"].ToString().ShouldBe(expected); + } + + // 6 — Link header: single sunset link with rel="sunset", title, type + [Fact] + public async Task single_link_with_sunset_rel() + { + var sunsetDate = new DateTimeOffset(2029, 1, 1, 0, 0, 0, TimeSpan.Zero); + var linkUri = new Uri("https://example.com/info"); + var link = new LinkHeaderValue(linkUri, "sunset") { Title = "Info", Type = "text/html" }; + var sunsetPolicy = new SunsetPolicy(sunsetDate, link); + var opts = new WolverineApiVersioningOptions(); + var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), sunsetPolicy, null); + var writer = new ApiVersionHeaderWriter(opts); + var ctx = ContextWithState(state); + + await writer.WriteAsync(ctx); + + ctx.Response.Headers["Link"].ToString() + .ShouldBe("; rel=\"sunset\"; title=\"Info\"; type=\"text/html\""); + } + + // 7 — multiple links from one SunsetPolicy are comma-space joined + [Fact] + public async Task multiple_links_are_comma_separated() + { + var sunsetDate = new DateTimeOffset(2029, 1, 1, 0, 0, 0, TimeSpan.Zero); + var link1 = new LinkHeaderValue(new Uri("https://example.com/first"), "sunset"); + var link2 = new LinkHeaderValue(new Uri("https://example.com/second"), "sunset"); + var sunsetPolicy = new SunsetPolicy(sunsetDate, link1); + sunsetPolicy.Links.Add(link2); + + var opts = new WolverineApiVersioningOptions(); + var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), sunsetPolicy, null); + var writer = new ApiVersionHeaderWriter(opts); + var ctx = ContextWithState(state); + + await writer.WriteAsync(ctx); + + var linkHeader = ctx.Response.Headers["Link"].ToString(); + linkHeader.ShouldContain("; rel=\"sunset\""); + linkHeader.ShouldContain("; rel=\"sunset\""); + linkHeader.ShouldContain(", "); + } + + // 8 — EmitDeprecationHeaders=false suppresses Deprecation/Sunset/Link; api-supported-versions still emits + [Fact] + public async Task disabled_emit_deprecation_headers_skips_deprecation_sunset_and_link() + { + var date = new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero); + var opts = new WolverineApiVersioningOptions + { + EmitDeprecationHeaders = false, + EmitApiSupportedVersionsHeader = true + }; + opts.Sunset("1.0").On(date); + + var sunsetPolicy = opts.SunsetPolicies[new ApiVersion(1, 0)]; + var depPolicy = new DeprecationPolicy(date); + var linkUri = new Uri("https://example.com"); + var link = new LinkHeaderValue(linkUri, "sunset"); + sunsetPolicy.Links.Add(link); + + var state = new ApiVersionEndpointHeaderState(new ApiVersion(1, 0), sunsetPolicy, depPolicy); + var writer = new ApiVersionHeaderWriter(opts); + var ctx = ContextWithState(state); + + await writer.WriteAsync(ctx); + + // api-supported-versions should still be present + ctx.Response.Headers.ContainsKey("api-supported-versions").ShouldBeTrue(); + // Deprecation/Sunset/Link should be absent + ctx.Response.Headers.ContainsKey("Deprecation").ShouldBeFalse(); + ctx.Response.Headers.ContainsKey("Sunset").ShouldBeFalse(); + ctx.Response.Headers.ContainsKey("Link").ShouldBeFalse(); + } + + // 9 — EmitApiSupportedVersionsHeader=false suppresses api-supported-versions + [Fact] + public async Task disabled_emit_supported_versions_skips_supported_versions() + { + var date = new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero); + var opts = new WolverineApiVersioningOptions + { + EmitApiSupportedVersionsHeader = false, + EmitDeprecationHeaders = true + }; + opts.Deprecate("1.0").On(date); + + 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); + + await writer.WriteAsync(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 new file mode 100644 index 000000000..6e711b5f8 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersionResolverTests.cs @@ -0,0 +1,88 @@ +using System.Reflection; +using Asp.Versioning; +using Shouldly; +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +internal class NoVersionHandler { public void Handle() { } } + +[ApiVersion("2.0")] +internal class ClassOnlyVersionHandler { public void Handle() { } } + +internal class MethodOnlyVersionHandler { [ApiVersion("1.0")] public void Handle() { } } + +[ApiVersion("2.0")] +internal class MethodOverridesClassHandler { [ApiVersion("1.0")] public void Handle() { } } + +internal class MultipleVersionsOnMethodHandler +{ + [ApiVersion("1.0")] + [ApiVersion("2.0")] + public void Handle() { } +} + +internal class DeprecatedMethodHandler +{ + [ApiVersion("1.0", Deprecated = true)] + public void Handle() { } +} + +public class ApiVersionResolverTests +{ + private static MethodInfo MethodOf(string name) + => typeof(T).GetMethod(name, BindingFlags.Public | BindingFlags.Instance)!; + + [Fact] + public void no_attribute_returns_null() + { + var method = MethodOf(nameof(NoVersionHandler.Handle)); + ApiVersionResolver.Resolve(method).ShouldBeNull(); + } + + [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(); + } + + [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(); + } + + [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(); + } + + [Fact] + public void multiple_versions_on_same_method_throws() + { + 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"); + } + + [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(); + } +} diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs new file mode 100644 index 000000000..ee8bee289 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyHeaderWiringTests.cs @@ -0,0 +1,119 @@ +using Asp.Versioning; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using Microsoft.AspNetCore.Routing; +using Shouldly; +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +// ---------- Handler fixtures ---------- + +internal class SunsetV1EndpointHandler +{ + [WolverineGet("/sunset-items")] + [ApiVersion("1.0")] + public string Get() => "v1 sunset"; +} + +internal class NoDeprecationV1EndpointHandler +{ + [WolverineGet("/plain-items")] + [ApiVersion("1.0")] + public string Get() => "v1 plain"; +} + +internal class OrdersHeaderTestHandler +{ + [WolverineGet("/orders-header-test")] + [ApiVersion("1.0")] + public string Get() => "v1 orders"; +} + +// ---------- Tests ---------- + +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 + [Fact] + public void chain_with_sunset_policy_gets_header_writer_postprocessor() + { + var date = new DateTimeOffset(2027, 6, 1, 0, 0, 0, TimeSpan.Zero); + var opts = new WolverineApiVersioningOptions(); + opts.Sunset("1.0").On(date); + + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + chain.Postprocessors + .OfType() + .Any(c => c.HandlerType == typeof(ApiVersionHeaderWriter)) + .ShouldBeTrue(); + } + + // 2 — chain with all header emit flags disabled and no deprecation/sunset gets no postprocessor + [Fact] + public void chain_with_no_policies_and_emit_supported_disabled_gets_no_postprocessor() + { + var opts = new WolverineApiVersioningOptions + { + EmitApiSupportedVersionsHeader = false, + EmitDeprecationHeaders = false + }; + + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + chain.Postprocessors + .OfType() + .Any(c => c.HandlerType == typeof(ApiVersionHeaderWriter)) + .ShouldBeFalse(); + } + + // 3 — ApiVersionEndpointHeaderState metadata is attached to the endpoint after policy runs + [Fact] + public void chain_with_state_metadata_attached() + { + var date = new DateTimeOffset(2027, 6, 1, 0, 0, 0, TimeSpan.Zero); + var opts = new WolverineApiVersioningOptions(); + opts.Sunset("1.0").On(date); + + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + var state = endpoint.Metadata.GetMetadata(); + + state.ShouldNotBeNull(); + state!.Version.ShouldBe(new ApiVersion(1, 0)); + state.Sunset.ShouldNotBeNull(); + state.Sunset!.Date.ShouldBe(date); + } + + // 4 — apply twice does not add duplicate header postprocessor (idempotency guard) + [Fact] + public void apply_twice_does_not_add_duplicate_header_postprocessor() + { + 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 firstCount = chain.Postprocessors.OfType().Count(c => c.HandlerType == typeof(ApiVersionHeaderWriter)); + + Apply(policy, chain); + var secondCount = chain.Postprocessors.OfType().Count(c => c.HandlerType == typeof(ApiVersionHeaderWriter)); + + secondCount.ShouldBe(firstCount); + } +} diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyTests.cs new file mode 100644 index 000000000..cb061af68 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/ApiVersioningPolicyTests.cs @@ -0,0 +1,300 @@ +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 OrdersV1Handler +{ + [WolverineGet("/orders")] + [ApiVersion("1.0")] + public string Get() => "v1"; +} + +internal class OrdersV2Handler +{ + [WolverineGet("/orders")] + [ApiVersion("2.0")] + public string Get() => "v2"; +} + +internal class OrdersV1DuplicateHandler +{ + [WolverineGet("/orders")] + [ApiVersion("1.0")] + public string Get() => "v1-dup"; +} + +internal class UnversionedOrdersHandler +{ + [WolverineGet("/orders")] + public string Get() => "unversioned"; +} + +internal class DeprecatedV1Handler +{ + [WolverineGet("/items")] + [ApiVersion("1.0", Deprecated = true)] + public string Get() => "deprecated"; +} + +// ---------- Tests ---------- + +public class ApiVersioningPolicyTests +{ + + private static void Apply(ApiVersioningPolicy policy, params HttpChain[] chains) + => policy.Apply(chains, new GenerationRules(), null!); + + // 1 + [Fact] + public void attribute_resolves_version_onto_chain() + { + var opts = new WolverineApiVersioningOptions(); + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + chain.ApiVersion.ShouldBe(new ApiVersion(1, 0)); + } + + // 2 + [Fact] + public void versioned_chain_has_url_segment_prefixed() + { + var opts = new WolverineApiVersioningOptions(); // default UrlSegmentPrefix = "v{version}" + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + chain.RoutePattern!.RawText.ShouldBe("/v1/orders"); + } + + // 3 + [Fact] + public void unversioned_passthrough_leaves_chain_alone() + { + var opts = new WolverineApiVersioningOptions { UnversionedPolicy = UnversionedPolicy.PassThrough }; + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + var originalRoute = chain.RoutePattern!.RawText; + + Apply(policy, chain); + + chain.ApiVersion.ShouldBeNull(); + chain.RoutePattern!.RawText.ShouldBe(originalRoute); + } + + // 4 + [Fact] + public void unversioned_require_explicit_throws() + { + var opts = new WolverineApiVersioningOptions { UnversionedPolicy = UnversionedPolicy.RequireExplicit }; + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + chain.DisplayName = "GET /orders (unversioned)"; + + var ex = Should.Throw(() => Apply(policy, chain)); + ex.Message.ShouldContain("GET /orders (unversioned)"); + } + + // 5 + [Fact] + public void unversioned_assign_default_uses_default_version() + { + var opts = new WolverineApiVersioningOptions + { + UnversionedPolicy = UnversionedPolicy.AssignDefault, + DefaultVersion = new ApiVersion(1, 0) + }; + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + chain.ApiVersion.ShouldBe(new ApiVersion(1, 0)); + chain.RoutePattern!.RawText.ShouldBe("/v1/orders"); + } + + // 6 + [Fact] + public void assign_default_without_default_version_throws() + { + var opts = new WolverineApiVersioningOptions + { + UnversionedPolicy = UnversionedPolicy.AssignDefault, + DefaultVersion = null + }; + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + var ex = Should.Throw(() => Apply(policy, chain)); + ex.Message.ShouldContain("DefaultVersion must be set"); + } + + // 7 + [Fact] + public void sunset_policy_attached_from_options() + { + var date = new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero); + var opts = new WolverineApiVersioningOptions(); + opts.Sunset("1.0").On(date); + + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + chain.SunsetPolicy.ShouldNotBeNull(); + chain.SunsetPolicy!.Date.ShouldBe(date); + } + + // 8 + [Fact] + public void deprecation_attribute_marks_chain_deprecated() + { + var opts = new WolverineApiVersioningOptions(); + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + chain.DeprecationPolicy.ShouldNotBeNull(); + } + + // 9 + [Fact] + public void attribute_deprecation_takes_precedence_over_options() + { + var otherDate = new DateTimeOffset(2028, 6, 1, 0, 0, 0, TimeSpan.Zero); + var opts = new WolverineApiVersioningOptions(); + opts.Deprecate("1.0").On(otherDate); + + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + chain.DeprecationPolicy.ShouldNotBeNull(); + chain.DeprecationPolicy!.Date.ShouldBeNull(); // attribute-driven, no date + } + + // 10 + [Fact] + public void duplicate_route_and_version_throws() + { + var opts = new WolverineApiVersioningOptions(); + var policy = new ApiVersioningPolicy(opts); + var chain1 = HttpChain.ChainFor(x => x.Get()); + var chain2 = HttpChain.ChainFor(x => x.Get()); + chain1.DisplayName = "OrdersV1Handler"; + chain2.DisplayName = "OrdersV1DuplicateHandler"; + + var ex = Should.Throw(() => Apply(policy, chain1, chain2)); + ex.Message.ShouldContain("GET"); + ex.Message.ShouldContain("/orders"); + ex.Message.ShouldContain("1.0"); + ex.Message.ShouldContain("OrdersV1Handler"); + ex.Message.ShouldContain("OrdersV1DuplicateHandler"); + } + + // 11 + [Fact] + public void duplicate_route_with_different_versions_does_not_throw() + { + var opts = new WolverineApiVersioningOptions(); + var policy = new ApiVersioningPolicy(opts); + var chain1 = HttpChain.ChainFor(x => x.Get()); + var chain2 = HttpChain.ChainFor(x => x.Get()); + + Should.NotThrow(() => Apply(policy, chain1, chain2)); + } + + // 12 — group-name metadata attached + [Fact] + public void group_name_metadata_attached() + { + var opts = new WolverineApiVersioningOptions(); + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + // BuildEndpoint commits all metadata callbacks to the RouteEndpointBuilder. + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + + var groupMeta = endpoint.Metadata.GetMetadata(); + groupMeta.ShouldNotBeNull(); + groupMeta!.EndpointGroupName.ShouldBe("v1"); + } + + // 13 — ApiVersionMetadata attached + [Fact] + public void apiversion_metadata_attached() + { + var opts = new WolverineApiVersioningOptions(); + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Apply(policy, chain); + + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + + var versionMeta = endpoint.Metadata.GetMetadata(); + versionMeta.ShouldNotBeNull(); + versionMeta!.IsApiVersionNeutral.ShouldBeFalse(); + versionMeta.Map(ApiVersionMapping.Explicit).ImplementedApiVersions.ShouldContain(new ApiVersion(1, 0)); + } + + // 14 — UrlSegmentPrefix validation + [Fact] + public void url_segment_prefix_without_version_token_throws() + { + var opts = new WolverineApiVersioningOptions { UrlSegmentPrefix = "api" }; + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + Should.Throw(() => + policy.Apply(new[] { chain }, new GenerationRules(), null!)) + .Message.ShouldContain("{version}"); + } + + // 15 — Idempotency: apply() called twice should not double-prefix + [Fact] + public void apply_twice_is_idempotent() + { + var opts = new WolverineApiVersioningOptions(); + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + policy.Apply(new[] { chain }, new GenerationRules(), null!); + var routeAfterFirstApply = chain.RoutePattern!.RawText; + + policy.Apply(new[] { chain }, new GenerationRules(), null!); + + chain.RoutePattern!.RawText.ShouldBe(routeAfterFirstApply); + } + + // 16 — Custom document name strategy drives group name + [Fact] + public void custom_document_name_strategy_drives_group_name() + { + var opts = new WolverineApiVersioningOptions(); + opts.OpenApi.DocumentNameStrategy = v => $"api-v{v.MajorVersion}"; + var policy = new ApiVersioningPolicy(opts); + var chain = HttpChain.ChainFor(x => x.Get()); + + policy.Apply(new[] { chain }, new GenerationRules(), null!); + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + + endpoint.Metadata.GetMetadata()!.EndpointGroupName.ShouldBe("api-v1"); + } + +} diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/DescribeWolverineApiVersionsTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/DescribeWolverineApiVersionsTests.cs new file mode 100644 index 000000000..62c36eeba --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/DescribeWolverineApiVersionsTests.cs @@ -0,0 +1,275 @@ +using Asp.Versioning; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.Core.IoC; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Shouldly; +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +// --------------------------------------------------------------------------- +// Handler fixtures — dedicated to these tests to avoid fixture name collisions +// --------------------------------------------------------------------------- + +internal class DescribeV1Handler +{ + [WolverineGet("/describe-v1")] + [ApiVersion("1.0")] + public string Get() => "v1"; +} + +internal class DescribeV2HandlerA +{ + [WolverineGet("/describe-v2-a")] + [ApiVersion("2.0")] + public string Get() => "v2a"; +} + +internal class DescribeV2HandlerB +{ + [WolverineGet("/describe-v2-b")] + [ApiVersion("2.0")] + public string Get() => "v2b"; +} + +internal class DescribeV3Handler +{ + [WolverineGet("/describe-v3")] + [ApiVersion("3.0")] + public string Get() => "v3"; +} + +internal class DescribeDeprecatedV1Handler +{ + [WolverineGet("/describe-deprecated-v1")] + [ApiVersion("1.0", Deprecated = true)] + public string Get() => "deprecated-v1"; +} + +internal class DescribeUnversionedHandler +{ + [WolverineGet("/describe-unversioned")] + public string Get() => "unversioned"; +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/// +/// Constructs an stub backed by a real +/// with the supplied chains pre-installed. +/// +internal static class DescribeTestHelper +{ + /// + /// Builds a minimal service provider, populates a with + /// the given (which must already have ApiVersion etc. + /// set via a policy), wires it into , + /// and returns a stub whose + /// ServiceProvider resolves that options instance. + /// + public static IEndpointRouteBuilder BuildEndpoints( + WolverineHttpOptions httpOptions, + IReadOnlyList chains) + { + // Build a minimal IServiceContainer so HttpGraph can be constructed. + var registry = new ServiceCollection(); + registry.AddSingleton(); + registry.AddSingleton(registry); + var sp = registry.BuildServiceProvider(); + var container = sp.GetRequiredService(); + + var graph = new HttpGraph(new WolverineOptions(), container); + + // Inject the pre-built chains into the graph. + // HttpGraph.Chains is public read-only, but _chains (the backing field) is private. + // Direct field access is necessary to preserve pre-built chains with their applied policies. + InjectChainsIntoGraph(graph, chains); + + httpOptions.Endpoints = graph; + + // Build a fake IServiceProvider that returns our WolverineHttpOptions. + var fakeServiceProvider = Substitute.For(); + fakeServiceProvider.GetService(typeof(WolverineHttpOptions)).Returns(httpOptions); + + var fakeEndpoints = Substitute.For(); + fakeEndpoints.ServiceProvider.Returns(fakeServiceProvider); + + return fakeEndpoints; + } + + /// + /// Injects pre-built HttpChain instances into an HttpGraph's internal _chains collection. + /// This preserves chains with their applied policies (e.g., ApiVersioning metadata). + /// HttpGraph.Chains is public read-only, so direct field access via reflection is required. + /// + private static void InjectChainsIntoGraph(HttpGraph graph, IReadOnlyList chains) + { + var chainsField = typeof(HttpGraph) + .GetField("_chains", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var list = (List)chainsField.GetValue(graph)!; + foreach (var chain in chains) + list.Add(chain); + } + + /// + /// Convenience overload: applies the versioning policy and builds the stubs in one step. + /// + public static IEndpointRouteBuilder BuildEndpointsWithPolicy( + WolverineHttpOptions httpOptions, + params HttpChain[] chains) + { + if (httpOptions.ApiVersioning is not null) + { + var policy = new ApiVersioningPolicy(httpOptions.ApiVersioning); + policy.Apply(chains, new GenerationRules(), null!); + } + + return BuildEndpoints(httpOptions, chains); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +public class DescribeWolverineApiVersionsTests +{ + // 1 — returns empty list when versioning is not configured + [Fact] + public void returns_empty_list_when_versioning_not_configured() + { + var httpOptions = new WolverineHttpOptions(); + // No UseApiVersioning call — ApiVersioning is null. + + var chain = HttpChain.ChainFor(x => x.Get()); + var endpoints = DescribeTestHelper.BuildEndpoints(httpOptions, new[] { chain }); + + var result = endpoints.DescribeWolverineApiVersions(); + + result.ShouldBeEmpty(); + } + + // 2 — returns one description per distinct version + [Fact] + public void returns_one_description_per_distinct_version() + { + var httpOptions = new WolverineHttpOptions(); + httpOptions.UseApiVersioning(_ => { }); + + var v1Chain = HttpChain.ChainFor(x => x.Get()); + var v2aChain = HttpChain.ChainFor(x => x.Get()); + var v2bChain = HttpChain.ChainFor(x => x.Get()); + + var endpoints = DescribeTestHelper.BuildEndpointsWithPolicy( + httpOptions, v1Chain, v2aChain, v2bChain); + + var result = endpoints.DescribeWolverineApiVersions(); + + result.Count.ShouldBe(2); + result.Select(d => d.ApiVersion).ShouldBe( + new[] { new ApiVersion(1, 0), new ApiVersion(2, 0) }, + ignoreOrder: false); + } + + // 3 — results are sorted ascending by version + [Fact] + public void descriptions_sorted_ascending_by_version() + { + var httpOptions = new WolverineHttpOptions(); + httpOptions.UseApiVersioning(_ => { }); + + var v3Chain = HttpChain.ChainFor(x => x.Get()); + var v1Chain = HttpChain.ChainFor(x => x.Get()); + + // Pass v3 first intentionally. + var endpoints = DescribeTestHelper.BuildEndpointsWithPolicy( + httpOptions, v3Chain, v1Chain); + + var result = endpoints.DescribeWolverineApiVersions(); + + result.Count.ShouldBe(2); + result[0].ApiVersion.ShouldBe(new ApiVersion(1, 0)); + result[1].ApiVersion.ShouldBe(new ApiVersion(3, 0)); + } + + // 4 — document name comes from the configured strategy + [Fact] + public void description_has_correct_document_name_from_strategy() + { + var httpOptions = new WolverineHttpOptions(); + httpOptions.UseApiVersioning(opts => + opts.OpenApi.DocumentNameStrategy = v => "v" + v.MajorVersion); + + var v1Chain = HttpChain.ChainFor(x => x.Get()); + + var endpoints = DescribeTestHelper.BuildEndpointsWithPolicy(httpOptions, v1Chain); + + var result = endpoints.DescribeWolverineApiVersions(); + + result.Count.ShouldBe(1); + result[0].DocumentName.ShouldBe("v1"); + result[0].DisplayName.ShouldBe("API v1"); + } + + // 5 — IsDeprecated is true when [ApiVersion(Deprecated = true)] is on the handler + [Fact] + public void is_deprecated_true_when_attribute_marks_handler() + { + var httpOptions = new WolverineHttpOptions(); + httpOptions.UseApiVersioning(_ => { }); + + var chain = HttpChain.ChainFor(x => x.Get()); + + var endpoints = DescribeTestHelper.BuildEndpointsWithPolicy(httpOptions, chain); + + var result = endpoints.DescribeWolverineApiVersions(); + + result.Count.ShouldBe(1); + result[0].IsDeprecated.ShouldBeTrue(); + } + + // 6 — IsDeprecated is true when options.Deprecate() was called (no attribute) + [Fact] + public void is_deprecated_true_when_options_deprecate_called() + { + var date = new DateTimeOffset(2027, 6, 1, 0, 0, 0, TimeSpan.Zero); + var httpOptions = new WolverineHttpOptions(); + httpOptions.UseApiVersioning(opts => opts.Deprecate("2.0").On(date)); + + var v2aChain = HttpChain.ChainFor(x => x.Get()); + var v2bChain = HttpChain.ChainFor(x => x.Get()); + + var endpoints = DescribeTestHelper.BuildEndpointsWithPolicy( + httpOptions, v2aChain, v2bChain); + + var result = endpoints.DescribeWolverineApiVersions(); + + result.Count.ShouldBe(1); + result[0].ApiVersion.ShouldBe(new ApiVersion(2, 0)); + result[0].IsDeprecated.ShouldBeTrue(); + } + + // 7 — SunsetPolicy is present when opts.Sunset() is configured + [Fact] + public void sunset_policy_present_when_configured() + { + var date = new DateTimeOffset(2028, 1, 1, 0, 0, 0, TimeSpan.Zero); + var httpOptions = new WolverineHttpOptions(); + httpOptions.UseApiVersioning(opts => opts.Sunset("1.0").On(date)); + + var v1Chain = HttpChain.ChainFor(x => x.Get()); + + var endpoints = DescribeTestHelper.BuildEndpointsWithPolicy(httpOptions, v1Chain); + + var result = endpoints.DescribeWolverineApiVersions(); + + result.Count.ShouldBe(1); + result[0].SunsetPolicy.ShouldNotBeNull(); + result[0].SunsetPolicy!.Date.ShouldBe(date); + } +} diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/HttpChainApiVersioningTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/HttpChainApiVersioningTests.cs new file mode 100644 index 000000000..64c93cb63 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/HttpChainApiVersioningTests.cs @@ -0,0 +1,47 @@ +using Asp.Versioning; +using Shouldly; +using Wolverine.Http; + +namespace Wolverine.Http.Tests.ApiVersioning; + +internal class SampleApiVersioningEndpoint +{ + [WolverineGet("/api-versioning/sample")] + public string Get() => "ok"; +} + +public class HttpChainApiVersioningTests +{ + private static HttpChain BuildChain() + => HttpChain.ChainFor(x => x.Get()); + + [Fact] + public void chain_with_no_version_has_null_api_version() + { + var chain = BuildChain(); + chain.ApiVersion.ShouldBeNull(); + } + + [Fact] + public void has_api_version_sets_property_and_returns_chain() + { + var chain = BuildChain(); + var version = new ApiVersion(1, 0); + + var returned = chain.HasApiVersion(version); + + returned.ShouldBeSameAs(chain); + chain.ApiVersion.ShouldBe(version); + } + + [Fact] + public void sunset_policy_round_trips() + { + var chain = BuildChain(); + var policy = new SunsetPolicy(DateTimeOffset.UtcNow.AddDays(30)); + + chain.SunsetPolicy = policy; + + chain.SunsetPolicy.ShouldBeSameAs(policy); + } +} diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/UseApiVersioningTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/UseApiVersioningTests.cs new file mode 100644 index 000000000..d4841d198 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/UseApiVersioningTests.cs @@ -0,0 +1,43 @@ +using Shouldly; +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +public class UseApiVersioningTests +{ + [Fact] + public void use_api_versioning_stores_options() + { + var opts = new WolverineHttpOptions(); + + opts.UseApiVersioning(v => v.UrlSegmentPrefix = "api/v{version}"); + + opts.ApiVersioning.ShouldNotBeNull(); + opts.ApiVersioning.UrlSegmentPrefix.ShouldBe("api/v{version}"); + } + + [Fact] + public void use_api_versioning_called_twice_accumulates() + { + var opts = new WolverineHttpOptions(); + var date1 = new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero); + var date2 = new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero); + + opts.UseApiVersioning(v => v.Sunset("1.0").On(date1)); + opts.UseApiVersioning(v => v.Deprecate("2.0").On(date2)); + + opts.ApiVersioning.ShouldNotBeNull(); + + var av = opts.ApiVersioning; + av.SunsetPolicies.ContainsKey(new Asp.Versioning.ApiVersion(1, 0)).ShouldBeTrue(); + av.DeprecationPolicies.ContainsKey(new Asp.Versioning.ApiVersion(2, 0)).ShouldBeTrue(); + } + + [Fact] + public void null_configure_throws_argument_null() + { + var opts = new WolverineHttpOptions(); + + Should.Throw(() => opts.UseApiVersioning(null!)); + } +} diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningOpenApiOptionsTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningOpenApiOptionsTests.cs new file mode 100644 index 000000000..b5d41b73b --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningOpenApiOptionsTests.cs @@ -0,0 +1,38 @@ +using Asp.Versioning; +using Shouldly; +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +public class WolverineApiVersioningOpenApiOptionsTests +{ + [Fact] + public void default_strategy_emits_v_major_for_major_minor() + { + var opts = new WolverineApiVersioningOpenApiOptions(); + + opts.DocumentNameStrategy(new ApiVersion(1, 0)).ShouldBe("v1"); + opts.DocumentNameStrategy(new ApiVersion(2, 5)).ShouldBe("v2"); + } + + [Fact] + public void default_strategy_falls_back_for_date_versions() + { + var opts = new WolverineApiVersioningOpenApiOptions(); + var dateVersion = new ApiVersion(new DateTime(2024, 11, 1)); + + var result = opts.DocumentNameStrategy(dateVersion); + + result.ShouldBe(dateVersion.ToString()); + } + + [Fact] + public void custom_strategy_overrides_default() + { + var opts = new WolverineApiVersioningOpenApiOptions(); + opts.DocumentNameStrategy = v => $"v{v.MajorVersion}.{v.MinorVersion}"; + + opts.DocumentNameStrategy(new ApiVersion(1, 0)).ShouldBe("v1.0"); + opts.DocumentNameStrategy(new ApiVersion(2, 3)).ShouldBe("v2.3"); + } +} diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningOptionsTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningOptionsTests.cs new file mode 100644 index 000000000..e31bb26be --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningOptionsTests.cs @@ -0,0 +1,104 @@ +using Asp.Versioning; +using Shouldly; +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +public class WolverineApiVersioningOptionsTests +{ + [Fact] + public void default_url_segment_prefix_is_v_token() + { + new WolverineApiVersioningOptions().UrlSegmentPrefix.ShouldBe("v{version}"); + } + + [Fact] + public void default_url_formatter_emits_major_only() + { + var formatter = new WolverineApiVersioningOptions().UrlSegmentVersionFormatter; + + formatter(new ApiVersion(1, 0)).ShouldBe("1"); + formatter(new ApiVersion(2, 5)).ShouldBe("2"); + } + + [Fact] + public void default_unversioned_policy_is_passthrough() + { + new WolverineApiVersioningOptions().UnversionedPolicy.ShouldBe(UnversionedPolicy.PassThrough); + } + + [Fact] + public void sunset_builder_stores_date_and_links() + { + var opts = new WolverineApiVersioningOptions(); + var date = new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero); + var linkUri = new Uri("https://example.com/sunset"); + + opts.Sunset("1.0") + .On(date) + .WithLink(linkUri, "info", "text/html"); + + var key = new ApiVersion(1, 0); + opts.SunsetPolicies.ContainsKey(key).ShouldBeTrue(); + + var policy = opts.SunsetPolicies[key]; + policy.Date.ShouldBe(date); + policy.Links.Count.ShouldBe(1); + policy.Links[0].LinkTarget.ShouldBe(linkUri); + policy.Links[0].Title.ShouldBe("info"); + policy.Links[0].Type.ShouldBe("text/html"); + } + + [Fact] + public void sunset_builder_supports_chaining_with_multiple_links() + { + var opts = new WolverineApiVersioningOptions(); + var date = new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero); + var uri1 = new Uri("https://example.com/sunset/1"); + var uri2 = new Uri("https://example.com/sunset/2"); + + opts.Sunset("1.0") + .On(date) + .WithLink(uri1) + .WithLink(uri2); + + var policy = opts.SunsetPolicies[new ApiVersion(1, 0)]; + policy.Links.Count.ShouldBe(2); + } + + [Fact] + public void deprecate_builder_stores_date() + { + var opts = new WolverineApiVersioningOptions(); + var date = new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero); + + opts.Deprecate(new ApiVersion(2, 0)).On(date); + + var key = new ApiVersion(2, 0); + opts.DeprecationPolicies.ContainsKey(key).ShouldBeTrue(); + opts.DeprecationPolicies[key].Date.ShouldBe(date); + } + + [Fact] + public void parse_string_overload_yields_same_dictionary_key() + { + var opts = new WolverineApiVersioningOptions(); + var date = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + opts.Sunset("1.0").On(date); + + opts.SunsetPolicies.ContainsKey(new ApiVersion(1, 0)).ShouldBeTrue(); + } + + [Fact] + public void sunset_builder_stores_date_only_with_no_links() + { + var opts = new WolverineApiVersioningOptions(); + var date = DateTimeOffset.UtcNow.AddDays(30); + opts.Sunset("1.0").On(date); + + var policy = opts.SunsetPolicies[new ApiVersion(1, 0)]; + policy.Date.ShouldBe(date); + policy.Links.Count.ShouldBe(0); + } +} diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningSwaggerOperationFilterTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningSwaggerOperationFilterTests.cs new file mode 100644 index 000000000..b8ce31f75 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/WolverineApiVersioningSwaggerOperationFilterTests.cs @@ -0,0 +1,88 @@ +using Asp.Versioning; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Models; +using Shouldly; +using Wolverine.Http.ApiVersioning; +using WolverineWebApi.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +public class WolverineApiVersioningSwaggerOperationFilterTests +{ + private static OpenApiOperation MakeOperation() => new() { Summary = "Test" }; + + [Fact] + public void filter_marks_operation_deprecated_when_state_has_deprecation_policy() + { + var state = new ApiVersionEndpointHeaderState( + new ApiVersion(1, 0), + Sunset: null, + Deprecation: new DeprecationPolicy()); + var metadata = new List { state }; + var operation = MakeOperation(); + + WolverineApiVersioningSwaggerOperationFilter.ApplyFromMetadata(operation, metadata); + + operation.Deprecated.ShouldBeTrue(); + } + + [Fact] + public void filter_does_nothing_when_no_state_metadata() + { + var metadata = new List(); + var operation = MakeOperation(); + + WolverineApiVersioningSwaggerOperationFilter.ApplyFromMetadata(operation, metadata); + + operation.Deprecated.ShouldBeFalse(); + ((IDictionary)operation.Extensions).ShouldNotContainKey("x-api-versioning"); + } + + [Fact] + public void filter_emits_x_api_versioning_with_sunset_date() + { + var sunsetDate = new DateTimeOffset(2027, 6, 1, 0, 0, 0, TimeSpan.Zero); + var sunsetPolicy = new SunsetPolicy(sunsetDate); + var state = new ApiVersionEndpointHeaderState( + new ApiVersion(1, 0), + Sunset: sunsetPolicy, + Deprecation: new DeprecationPolicy()); + var metadata = new List { state }; + var operation = MakeOperation(); + + WolverineApiVersioningSwaggerOperationFilter.ApplyFromMetadata(operation, metadata); + + var extensions = (IDictionary)operation.Extensions; + extensions.ShouldContainKey("x-api-versioning"); + var ext = (OpenApiObject)extensions["x-api-versioning"]; + ((IDictionary)ext).ShouldContainKey("sunset"); + var sunsetStr = ((OpenApiString)ext["sunset"]).Value; + sunsetStr.ShouldBe(sunsetDate.UtcDateTime.ToString("R")); + } + + [Fact] + public void filter_emits_links_array_in_x_api_versioning() + { + var linkUri = new Uri("https://example.com/sunset"); + var sunsetPolicy = new SunsetPolicy(new LinkHeaderValue(linkUri, "sunset")); + var state = new ApiVersionEndpointHeaderState( + new ApiVersion(1, 0), + Sunset: sunsetPolicy, + Deprecation: null); + var metadata = new List { state }; + var operation = MakeOperation(); + + WolverineApiVersioningSwaggerOperationFilter.ApplyFromMetadata(operation, metadata); + + var extensions = (IDictionary)operation.Extensions; + extensions.ShouldContainKey("x-api-versioning"); + var ext = (OpenApiObject)extensions["x-api-versioning"]; + ((IDictionary)ext).ShouldContainKey("links"); + var links = (OpenApiArray)ext["links"]; + links.Count.ShouldBe(1); + var firstLink = (OpenApiObject)links[0]; + ((IDictionary)firstLink).ShouldContainKey("href"); + ((OpenApiString)firstLink["href"]).Value.ShouldBe(linkUri.ToString()); + } +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs new file mode 100644 index 000000000..2780f1fb8 --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionHeaderWriter.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using Asp.Versioning; +using Microsoft.AspNetCore.Http; + +namespace Wolverine.Http.ApiVersioning; + +/// +/// Metadata record attached to the endpoint for each versioned chain, carrying the per-chain +/// version and optional sunset / deprecation policies so they can be read at request time +/// without per-chain code-gen arguments. This is part of the framework's observable contract +/// and is consumed by at request time and by user-defined +/// OpenAPI filters (e.g., Swashbuckle IOperationFilter) for documentation generation. +/// +public sealed record ApiVersionEndpointHeaderState( + ApiVersion Version, + SunsetPolicy? Sunset, + DeprecationPolicy? Deprecation); + +/// +/// Singleton service that emits RFC 9745 Deprecation, RFC 8594 Sunset/Link, +/// and Asp.Versioning-style api-supported-versions response headers on versioned endpoints. +/// The per-chain state is read from stored in the +/// endpoint metadata (set by ), so this writer can be a plain +/// 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. +/// +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 + // app startup all policies are registered before any HTTP request is processed, + // so this is a safe optimization. + private readonly Lazy _supportedVersionsHeader; + + /// + /// Initializes a new instance of . + /// + /// The API versioning options used to compute the supported-versions header. + public ApiVersionHeaderWriter(WolverineApiVersioningOptions options) + { + _options = options; + _supportedVersionsHeader = new Lazy(() => BuildSupportedVersionsHeader(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. + /// + /// The current HTTP context. + public Task WriteAsync(HttpContext context) + { + var state = context.GetEndpoint()?.Metadata.GetMetadata(); + if (state is null) + return Task.CompletedTask; + + var headers = context.Response.Headers; + + if (_options.EmitApiSupportedVersionsHeader && _supportedVersionsHeader.Value.Length > 0) + headers["api-supported-versions"] = _supportedVersionsHeader.Value; + + if (_options.EmitDeprecationHeaders) + { + if (state.Deprecation is not null) + { + headers["Deprecation"] = state.Deprecation.Date is { } depDate + ? depDate.UtcDateTime.ToString("R", CultureInfo.InvariantCulture) + : "true"; + } + + if (state.Sunset?.Date is { } sunsetDate) + headers["Sunset"] = sunsetDate.UtcDateTime.ToString("R", CultureInfo.InvariantCulture); + + var links = BuildLinks(state.Sunset, state.Deprecation); + if (links.Length > 0) + headers["Link"] = links; + } + + return Task.CompletedTask; + } + + private static string BuildSupportedVersionsHeader(WolverineApiVersioningOptions options) + { + var versions = options.SunsetPolicies.Keys + .Concat(options.DeprecationPolicies.Keys) + .Distinct() + .OrderBy(v => v.MajorVersion ?? int.MaxValue) + .ThenBy(v => v.MinorVersion ?? int.MaxValue) + .Select(v => v.ToString()) + .ToArray(); + + return versions.Length == 0 ? string.Empty : string.Join(", ", versions); + } + + private static string BuildLinks(SunsetPolicy? sunset, DeprecationPolicy? deprecation) + { + var entries = new List(); + + if (sunset is not null) + foreach (var link in sunset.Links) + entries.Add(FormatLink(link, "sunset")); + + if (deprecation is not null) + foreach (var link in deprecation.Links) + entries.Add(FormatLink(link, "deprecation")); + + return entries.Count == 0 ? string.Empty : string.Join(", ", entries); + } + + private static string FormatLink(LinkHeaderValue link, string rel) + { + var sb = new System.Text.StringBuilder(); + sb.Append('<').Append(link.LinkTarget).Append(">; rel=\"").Append(rel).Append('"'); + + var title = link.Title.Value; + if (!string.IsNullOrEmpty(title)) sb.Append("; title=\"").Append(title).Append('"'); + + var type = link.Type.Value; + if (!string.IsNullOrEmpty(type)) sb.Append("; type=\"").Append(type).Append('"'); + + return sb.ToString(); + } +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs new file mode 100644 index 000000000..56ce41d1e --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using Asp.Versioning; + +namespace Wolverine.Http.ApiVersioning; + +internal readonly record struct ApiVersionResolution(ApiVersion Version, bool IsDeprecated); + +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. + /// + /// 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) + { + var methodAttrs = method.GetCustomAttributes(inherit: false).ToList(); + List winningAttrs; + + if (methodAttrs.Count > 0) + { + winningAttrs = methodAttrs; + } + else + { + var classAttrs = method.DeclaringType?.GetCustomAttributes(inherit: false).ToList(); + if (classAttrs is null || classAttrs.Count == 0) + { + return null; + } + + winningAttrs = classAttrs; + } + + var versions = winningAttrs.SelectMany(a => a.Versions).Distinct().ToList(); + + if (versions.Count == 1) + { + var version = versions[0]; + var isDeprecated = winningAttrs.Any(a => a.Deprecated && a.Versions.Contains(version)); + return 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."); + } +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs new file mode 100644 index 000000000..a84267a85 --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs @@ -0,0 +1,239 @@ +using Asp.Versioning; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Wolverine.Http.ApiVersioning; + +/// +/// An that applies API versioning semantics to every +/// during bootstrapping. Steps run in order: +/// +/// A — Resolve [ApiVersion] attributes on handler methods. +/// B — Apply to chains that remain unversioned. +/// C — Attach sunset / deprecation policies from . +/// 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. +/// +/// +internal sealed class ApiVersioningPolicy : IHttpPolicy +{ + private readonly WolverineApiVersioningOptions _options; + private readonly HashSet _processedChains = new(); + private readonly HashSet _headerProcessedChains = new(); + + /// Initializes a new instance of . + /// The API versioning options that drive this policy's behaviour. + public ApiVersioningPolicy(WolverineApiVersioningOptions options) + { + _options = options; + } + + /// + public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceContainer container) + { + ResolveAttributes(chains); + ApplyUnversionedPolicy(chains); + ApplyOptionsPolicies(chains); + DetectDuplicateRoutes(chains); + RewriteRoutes(chains); + AttachMetadata(chains); + WireHeaderPostprocessors(chains); + } + + /// Step A — read [ApiVersion] from the handler method and propagate to the chain. + private static void ResolveAttributes(IReadOnlyList chains) + { + foreach (var chain in chains) + { + if (chain.Method?.Method is null) + continue; + + var resolution = ApiVersionResolver.Resolve(chain.Method.Method); + if (resolution is null) + continue; + + if (chain.ApiVersion is null) + chain.ApiVersion = resolution.Value.Version; + + if (resolution.Value.IsDeprecated && chain.DeprecationPolicy is null) + chain.DeprecationPolicy = new DeprecationPolicy(); + } + } + + /// Step B — handle chains still missing a version per the configured fallback rule. + private void ApplyUnversionedPolicy(IReadOnlyList chains) + { + foreach (var chain in chains) + { + if (chain.ApiVersion is not null) + continue; + + switch (_options.UnversionedPolicy) + { + case UnversionedPolicy.PassThrough: + break; + + case UnversionedPolicy.RequireExplicit: + throw new InvalidOperationException( + $"Endpoint '{Identify(chain)}' does not declare an [ApiVersion] attribute. " + + $"The current UnversionedPolicy is '{UnversionedPolicy.RequireExplicit}', which requires every endpoint " + + "to carry an explicit version."); + + case UnversionedPolicy.AssignDefault: + chain.ApiVersion = _options.DefaultVersion + ?? throw new InvalidOperationException( + "DefaultVersion must be set when UnversionedPolicy is AssignDefault."); + break; + } + } + } + + /// Step C — apply sunset / deprecation policies from options without overwriting attribute-driven values. + private void ApplyOptionsPolicies(IReadOnlyList chains) + { + foreach (var chain in chains) + { + if (chain.ApiVersion is null) + continue; + + if (chain.SunsetPolicy is null && _options.SunsetPolicies.TryGetValue(chain.ApiVersion, out var sunset)) + chain.SunsetPolicy = sunset; + + if (chain.DeprecationPolicy is null && _options.DeprecationPolicies.TryGetValue(chain.ApiVersion, out var dep)) + chain.DeprecationPolicy = dep; + } + } + + /// Step D — fail fast when two chains share (verb, route, version). + private static void DetectDuplicateRoutes(IReadOnlyList chains) + { + var conflicts = chains + .Where(c => c.ApiVersion is not null) + .GroupBy(c => ( + Verb: c.HttpMethods.FirstOrDefault() ?? "", + Route: c.RoutePattern?.RawText ?? "", + Version: c.ApiVersion!.ToString())) + .Where(g => g.Count() > 1); + + foreach (var conflict in conflicts) + { + var names = string.Join(", ", conflict.Select(Identify)); + throw new InvalidOperationException( + $"Duplicate endpoint registration detected: " + + $"[{conflict.Key.Verb}] '{conflict.Key.Route}' at version '{conflict.Key.Version}'. " + + $"Conflicting chains: {names}"); + } + } + + /// Step E — prepend the URL-segment version prefix to every versioned chain. + private void RewriteRoutes(IReadOnlyList chains) + { + if (_options.UrlSegmentPrefix is null) + return; + + ValidateUrlSegmentPrefix(chains); + + foreach (var chain in chains) + { + if (chain.ApiVersion is null || chain.RoutePattern is null) + continue; + + RewriteRouteForChain(chain); + } + } + + private void ValidateUrlSegmentPrefix(IReadOnlyList chains) + { + if (_options.UrlSegmentPrefix!.Contains("{version}", StringComparison.Ordinal)) + return; + + if (!chains.Any(c => c.ApiVersion is not null)) + return; + + throw new InvalidOperationException( + $"WolverineApiVersioningOptions.UrlSegmentPrefix is set to '{_options.UrlSegmentPrefix}' which does not contain the required '{{version}}' token. All versioned endpoints would map to the same URL prefix. Set UrlSegmentPrefix to null to disable URL-segment versioning, or include '{{version}}' in the prefix template (e.g. 'v{{version}}' or 'api/v{{version}}')."); + } + + private void RewriteRouteForChain(HttpChain chain) + { + var expectedPrefix = BuildExpectedPrefix(chain.ApiVersion!); + var currentRoute = chain.RoutePattern!.RawText ?? string.Empty; + + // Idempotency guard: skip if the chain is already prefixed. + if (currentRoute == expectedPrefix || + currentRoute.StartsWith(expectedPrefix + "/", StringComparison.Ordinal)) + { + return; + } + + var trimmed = currentRoute.TrimStart('/'); + var newRoute = string.IsNullOrEmpty(trimmed) ? expectedPrefix : $"{expectedPrefix}/{trimmed}"; + chain.RoutePattern = RoutePatternFactory.Parse(newRoute); + } + + private string BuildExpectedPrefix(ApiVersion version) + { + var versionSegment = _options.UrlSegmentVersionFormatter(version); + return "/" + _options.UrlSegmentPrefix!.Replace("{version}", versionSegment).TrimStart('/'); + } + + /// Step F — attach group-name, ApiVersionMetadata, and ensure unique endpoint names. + private void AttachMetadata(IReadOnlyList chains) + { + foreach (var chain in chains) + { + if (chain.ApiVersion is null || !_processedChains.Add(chain)) + continue; + + var groupName = _options.OpenApi.DocumentNameStrategy(chain.ApiVersion); + chain.Metadata.WithGroupName(groupName); + + var model = new ApiVersionModel(chain.ApiVersion); + chain.Metadata.WithMetadata(new ApiVersionMetadata(model, model)); + + // Make the OperationId (already unique per handler type + method) the explicit + // endpoint name. Without this, ASP.NET Core uses ToString() which is derived from + // the original route pattern and collides when multiple versions share the same + // route template (e.g. [WolverineGet("/orders")] on three different classes). + if (!chain.HasExplicitOperationId) + chain.SetExplicitOperationId(chain.OperationId); + } + } + + /// Step G — register the response-header postprocessor for chains that emit headers. + private void WireHeaderPostprocessors(IReadOnlyList chains) + { + foreach (var chain in chains) + { + if (chain.ApiVersion is null || !RequiresHeaderWriter(chain)) + continue; + + if (!_headerProcessedChains.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) => + chain.SunsetPolicy is not null + || chain.DeprecationPolicy is not null + || _options.EmitApiSupportedVersionsHeader; + + private static string Identify(HttpChain chain) => + chain.DisplayName + ?? (chain.Method?.Method?.DeclaringType?.FullName + "." + chain.Method?.Method?.Name) + ?? "(unknown)"; +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/DeprecationPolicyBuilder.cs b/src/Http/Wolverine.Http/ApiVersioning/DeprecationPolicyBuilder.cs new file mode 100644 index 000000000..5ae6995c1 --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/DeprecationPolicyBuilder.cs @@ -0,0 +1,89 @@ +using Asp.Versioning; + +namespace Wolverine.Http.ApiVersioning; + +/// +/// Fluent builder for configuring a deprecation policy on a specific API version. +/// Obtain an instance from . +/// +public interface IWolverineDeprecationPolicyBuilder +{ + /// Set the deprecation date for this version. + /// The date on which the version is deprecated. + /// The builder for chaining. + IWolverineDeprecationPolicyBuilder On(DateTimeOffset date); + + /// + /// Add an RFC 8288 Link header reference pointing to information about this deprecation. + /// + /// The link target URI. + /// Optional human-readable title for the link. + /// Optional media type hint for the linked resource. + /// The builder for chaining. + IWolverineDeprecationPolicyBuilder WithLink(Uri uri, string? title = null, string? type = null); +} + +internal sealed class DeprecationPolicyBuilder : IWolverineDeprecationPolicyBuilder +{ + private readonly WolverineApiVersioningOptions _options; + private readonly ApiVersion _version; + private DateTimeOffset? _date; + private readonly List _links = new(); + + internal DeprecationPolicyBuilder(WolverineApiVersioningOptions options, ApiVersion version) + { + _options = options; + _version = version; + } + + /// + public IWolverineDeprecationPolicyBuilder On(DateTimeOffset date) + { + _date = date; + CommitPolicy(); + return this; + } + + /// + public IWolverineDeprecationPolicyBuilder WithLink(Uri uri, string? title = null, string? type = null) + { + var link = new LinkHeaderValue(uri, "deprecation"); + if (title != null) link.Title = title; + if (type != null) link.Type = type; + _links.Add(link); + CommitPolicy(); + return this; + } + + private void CommitPolicy() + { + DeprecationPolicy policy; + + if (_date.HasValue && _links.Count > 0) + { + policy = new DeprecationPolicy(_date.Value, _links[0]); + for (var i = 1; i < _links.Count; i++) + { + policy.Links.Add(_links[i]); + } + } + else if (_date.HasValue) + { + policy = new DeprecationPolicy(_date.Value); + } + else if (_links.Count > 0) + { + policy = new DeprecationPolicy(_links[0]); + for (var i = 1; i < _links.Count; i++) + { + policy.Links.Add(_links[i]); + } + } + else + { + policy = new DeprecationPolicy(); + } + + _options.DeprecationPolicies[_version] = policy; + } +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/HttpChainApiVersioningExtensions.cs b/src/Http/Wolverine.Http/ApiVersioning/HttpChainApiVersioningExtensions.cs new file mode 100644 index 000000000..4ea632aef --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/HttpChainApiVersioningExtensions.cs @@ -0,0 +1,14 @@ +using Asp.Versioning; + +namespace Wolverine.Http.ApiVersioning; + +/// Fluent helpers for declaring API versioning behavior on individual instances inside ConfigureEndpoints. +public static class HttpChainApiVersioningExtensions +{ + /// Mark this chain as deprecated by attaching a default with no scheduled date or links. + public static HttpChain MarkDeprecated(this HttpChain chain) + { + chain.DeprecationPolicy ??= new DeprecationPolicy(); + return chain; + } +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/SunsetPolicyBuilder.cs b/src/Http/Wolverine.Http/ApiVersioning/SunsetPolicyBuilder.cs new file mode 100644 index 000000000..f687bb91f --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/SunsetPolicyBuilder.cs @@ -0,0 +1,89 @@ +using Asp.Versioning; + +namespace Wolverine.Http.ApiVersioning; + +/// +/// Fluent builder for configuring a sunset policy on a specific API version. +/// Obtain an instance from . +/// +public interface IWolverineSunsetPolicyBuilder +{ + /// Set the sunset date for this version. + /// The date on which the version will sunset. + /// The builder for chaining. + IWolverineSunsetPolicyBuilder On(DateTimeOffset date); + + /// + /// Add an RFC 8288 Link header reference pointing to information about this sunset. + /// + /// The link target URI. + /// Optional human-readable title for the link. + /// Optional media type hint for the linked resource. + /// The builder for chaining. + IWolverineSunsetPolicyBuilder WithLink(Uri uri, string? title = null, string? type = null); +} + +internal sealed class SunsetPolicyBuilder : IWolverineSunsetPolicyBuilder +{ + private readonly WolverineApiVersioningOptions _options; + private readonly ApiVersion _version; + private DateTimeOffset? _date; + private readonly List _links = new(); + + internal SunsetPolicyBuilder(WolverineApiVersioningOptions options, ApiVersion version) + { + _options = options; + _version = version; + } + + /// + public IWolverineSunsetPolicyBuilder On(DateTimeOffset date) + { + _date = date; + CommitPolicy(); + return this; + } + + /// + public IWolverineSunsetPolicyBuilder WithLink(Uri uri, string? title = null, string? type = null) + { + var link = new LinkHeaderValue(uri, "sunset"); + if (title != null) link.Title = title; + if (type != null) link.Type = type; + _links.Add(link); + CommitPolicy(); + return this; + } + + private void CommitPolicy() + { + SunsetPolicy policy; + + if (_date.HasValue && _links.Count > 0) + { + policy = new SunsetPolicy(_date.Value, _links[0]); + for (var i = 1; i < _links.Count; i++) + { + policy.Links.Add(_links[i]); + } + } + else if (_date.HasValue) + { + policy = new SunsetPolicy(_date.Value); + } + else if (_links.Count > 0) + { + policy = new SunsetPolicy(_links[0]); + for (var i = 1; i < _links.Count; i++) + { + policy.Links.Add(_links[i]); + } + } + else + { + policy = new SunsetPolicy(); + } + + _options.SunsetPolicies[_version] = policy; + } +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/UnversionedPolicy.cs b/src/Http/Wolverine.Http/ApiVersioning/UnversionedPolicy.cs new file mode 100644 index 000000000..5b32cc776 --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/UnversionedPolicy.cs @@ -0,0 +1,17 @@ +namespace Wolverine.Http.ApiVersioning; + +/// +/// Behaviour applied by when an HTTP endpoint is discovered +/// without an . +/// +public enum UnversionedPolicy +{ + /// Endpoint stays at its declared route, no version metadata is attached. + PassThrough, + + /// Bootstrap throws if any chain is missing an . + RequireExplicit, + + /// Endpoint is automatically assigned . + AssignDefault +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersionDescription.cs b/src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersionDescription.cs new file mode 100644 index 000000000..4e675dc35 --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersionDescription.cs @@ -0,0 +1,28 @@ +using Asp.Versioning; + +namespace Wolverine.Http.ApiVersioning; + +/// +/// Describes a discovered API version with the metadata needed to wire SwaggerUI / Scalar +/// version dropdowns. Returned by +/// . +/// +/// The discovered API version. +/// +/// Document name produced by +/// ; matches the +/// chain's EndpointGroupName. +/// +/// Human-friendly label suitable for UI display (e.g. "API v1"). +/// +/// when at least one chain at this version has an attribute-driven +/// [ApiVersion(..., Deprecated = true)] declaration or a configured +/// . +/// +/// The configured sunset policy for this version, if any. +public sealed record WolverineApiVersionDescription( + ApiVersion ApiVersion, + string DocumentName, + string DisplayName, + bool IsDeprecated, + SunsetPolicy? SunsetPolicy); diff --git a/src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersioningOpenApiOptions.cs b/src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersioningOpenApiOptions.cs new file mode 100644 index 000000000..63fcf12df --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersioningOpenApiOptions.cs @@ -0,0 +1,28 @@ +using Asp.Versioning; +using System.Globalization; + +namespace Wolverine.Http.ApiVersioning; + +/// +/// OpenAPI integration options exposed via . +/// +public sealed class WolverineApiVersioningOpenApiOptions +{ + /// + /// Strategy that maps an to a Swashbuckle / Microsoft.AspNetCore.OpenApi + /// document name. The same string is also used as the + /// IEndpointGroupNameMetadata.EndpointGroupName attached to each chain by + /// . Defaults to v{major} for major.minor versions + /// (e.g. v1, v2); falls back to for + /// date-based versions. + /// + /// + /// Configure this strategy before calling MapWolverineEndpoints. The strategy is + /// invoked once per versioned chain during policy application at startup; reassigning the + /// property after startup has no effect on already-bound endpoints. + /// + public Func DocumentNameStrategy { get; set; } + = static v => v.MajorVersion is { } major + ? "v" + major.ToString(CultureInfo.InvariantCulture) + : v.ToString(); +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersioningOptions.cs b/src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersioningOptions.cs new file mode 100644 index 000000000..a97fe7961 --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/WolverineApiVersioningOptions.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using Asp.Versioning; + +namespace Wolverine.Http.ApiVersioning; + +/// +/// Configuration options for Wolverine's native API versioning support. Pass an instance to +/// to configure URL-segment behaviour, +/// unversioned-endpoint policy, and per-version sunset / deprecation policies. +/// +public sealed class WolverineApiVersioningOptions +{ + /// + /// URL-segment template injected ahead of versioned routes. The literal {version} is + /// replaced with the formatted version string produced by . + /// Set to to disable URL-segment versioning. + /// + /// + /// Must contain the literal '{version}' token when non-null. Setting a prefix without + /// the token causes to throw at startup. Set to null + /// to disable URL-segment versioning entirely. + /// + public string? UrlSegmentPrefix { get; set; } = "v{version}"; + + /// + /// Formatter producing the version string substituted into . + /// Defaults to major-only (e.g. "1" for ApiVersion(1, 0) rather than "1.0"). + /// + /// + /// Date-based versions (where is null) fall back to + /// which may include hyphens. Override this formatter + /// if your URL scheme requires a different shape for date-based versions. + /// + public Func UrlSegmentVersionFormatter { get; set; } + = static v => v.MajorVersion?.ToString(CultureInfo.InvariantCulture) ?? v.ToString(); + + /// + /// Behaviour for endpoints that do not declare an [ApiVersion] attribute. + /// Defaults to . + /// + public UnversionedPolicy UnversionedPolicy { get; set; } = UnversionedPolicy.PassThrough; + + /// + /// Used when is . + /// Required in that mode; otherwise ignored. + /// + public ApiVersion? DefaultVersion { get; set; } + + /// + /// Emit the api-supported-versions response header on every versioned endpoint. + /// + public bool EmitApiSupportedVersionsHeader { get; set; } = true; + + /// + /// Emit RFC 9745 Deprecation and RFC 8594 Sunset/Link headers on endpoints + /// that have a configured policy. + /// + public bool EmitDeprecationHeaders { get; set; } = true; + + /// OpenAPI integration options. + public WolverineApiVersioningOpenApiOptions OpenApi { get; } = new(); + + /// + /// Per-version sunset policies. Populated via or + /// . + /// + internal Dictionary SunsetPolicies { get; } = new(); + + /// + /// Per-version deprecation policies. Populated via or + /// . + /// + internal Dictionary DeprecationPolicies { get; } = new(); + + /// Configure a sunset policy for the given version. + /// The API version to configure a sunset policy for. + /// A builder that can be used to set dates and link references. + public IWolverineSunsetPolicyBuilder Sunset(ApiVersion version) => new SunsetPolicyBuilder(this, version); + + /// Convenience overload that parses the version string (e.g. "1.0"). + /// A version string such as "1.0" or "2". + /// A builder that can be used to set dates and link references. + public IWolverineSunsetPolicyBuilder Sunset(string version) + { + ArgumentException.ThrowIfNullOrWhiteSpace(version); + return Sunset(ApiVersionParser.Default.Parse(version)); + } + + /// Configure a deprecation policy for the given version. + /// The API version to configure a deprecation policy for. + /// A builder that can be used to set dates and link references. + public IWolverineDeprecationPolicyBuilder Deprecate(ApiVersion version) => new DeprecationPolicyBuilder(this, version); + + /// Convenience overload that parses the version string (e.g. "1.0"). + /// A version string such as "1.0" or "2". + /// A builder that can be used to set dates and link references. + public IWolverineDeprecationPolicyBuilder Deprecate(string version) + { + ArgumentException.ThrowIfNullOrWhiteSpace(version); + return Deprecate(ApiVersionParser.Default.Parse(version)); + } +} diff --git a/src/Http/Wolverine.Http/ApiVersioning/WolverineOpenApiEndpointRouteBuilderExtensions.cs b/src/Http/Wolverine.Http/ApiVersioning/WolverineOpenApiEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..223db7c20 --- /dev/null +++ b/src/Http/Wolverine.Http/ApiVersioning/WolverineOpenApiEndpointRouteBuilderExtensions.cs @@ -0,0 +1,97 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Wolverine.Http.ApiVersioning; + +/// +/// Extensions for surfacing Wolverine API-versioning information into ASP.NET Core endpoint +/// routing — primarily for wiring SwaggerUI / Scalar version dropdowns. +/// +public static class WolverineOpenApiEndpointRouteBuilderExtensions +{ + /// + /// Returns one description per discovered API version, sorted ascending by major then minor. + /// Returns an empty list when API versioning is not configured or no versioned endpoint + /// has been discovered. + /// + /// The endpoint route builder (typically the WebApplication instance). + /// + /// A read-only list of instances, one per + /// distinct found across all registered Wolverine HTTP chains, + /// sorted ascending by major version then minor version. + /// + public static IReadOnlyList DescribeWolverineApiVersions( + this IEndpointRouteBuilder endpoints) + { + var httpOptions = endpoints.ServiceProvider.GetService(); + + // Not configured or AddWolverineHttp() was never called — return empty. + if (httpOptions?.ApiVersioning is null) + return Array.Empty(); + + var graph = httpOptions.Endpoints; + + // MapWolverineEndpoints has not been called yet — return empty rather than throwing. + if (graph is null) + return Array.Empty(); + + var apiVersioning = httpOptions.ApiVersioning; + var openApiOptions = apiVersioning.OpenApi; + + // Gather every chain that has a resolved ApiVersion, then group by version. + var byVersion = graph.Chains + .Where(c => c.ApiVersion is not null) + .GroupBy(c => c.ApiVersion!) + .ToList(); + + if (byVersion.Count == 0) + return Array.Empty(); + + var descriptions = new List(byVersion.Count); + + foreach (var group in byVersion) + { + var version = group.Key; + + var documentName = openApiOptions.DocumentNameStrategy(version); + var displayName = $"API {documentName}"; + + // A version is deprecated when any chain in the group has a deprecation policy, + // OR when the options-level DeprecationPolicies map contains an entry for it. + var isDeprecated = group.Any(c => c.DeprecationPolicy is not null) + || apiVersioning.DeprecationPolicies.ContainsKey(version); + + // Prefer the policy already attached to a chain (set during policy application); + // fall back to the options-level map (handles the case where the chain was + // PassThrough / not reachable by the policy). + var sunsetPolicy = group.Select(c => c.SunsetPolicy).FirstOrDefault(p => p is not null) + ?? apiVersioning.SunsetPolicies.GetValueOrDefault(version); + + descriptions.Add(new WolverineApiVersionDescription( + version, + documentName, + displayName, + isDeprecated, + sunsetPolicy)); + } + + // Sort ascending by major version then minor version. + // Treat null MajorVersion (date-based versions) as int.MaxValue so they sort last. + descriptions.Sort((a, b) => + { + var majorA = a.ApiVersion.MajorVersion ?? int.MaxValue; + var majorB = b.ApiVersion.MajorVersion ?? int.MaxValue; + + var majorCmp = majorA.CompareTo(majorB); + if (majorCmp != 0) + return majorCmp; + + var minorA = a.ApiVersion.MinorVersion ?? 0; + var minorB = b.ApiVersion.MinorVersion ?? 0; + return minorA.CompareTo(minorB); + }); + + return descriptions.AsReadOnly(); + } +} diff --git a/src/Http/Wolverine.Http/HttpChain.cs b/src/Http/Wolverine.Http/HttpChain.cs index cf908e50f..cf9c4a748 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 Asp.Versioning; using JasperFx; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; @@ -250,6 +251,22 @@ internal set /// public TenancyMode? TenancyMode { get; set; } + /// API version declared for this endpoint via [ApiVersion] or fluent configuration. Null when the endpoint is version-neutral. + public ApiVersion? ApiVersion { get; set; } + + /// Sunset policy for this endpoint's API version. Populated by configuration during app startup. + public SunsetPolicy? SunsetPolicy { get; set; } + + /// Deprecation policy for this endpoint's API version. Populated by configuration during app startup. + public DeprecationPolicy? DeprecationPolicy { get; set; } + + /// Fluent helper to declare an API version on this chain. Returns this chain. + public HttpChain HasApiVersion(ApiVersion version) + { + ApiVersion = version; + return this; + } + public static HttpChain ChainFor(Expression> expression, HttpGraph? parent = null) { var method = ReflectionHelper.GetMethod(expression); @@ -747,6 +764,18 @@ public HttpElementVariable GetOrCreateHeaderVariable(IFromHeaderMetadata metadat string IEndpointSummaryMetadata.Summary => EndpointSummary ?? ToString(); + /// + /// Sets an explicit operation ID (endpoint name) and marks it as explicit so it is used + /// as the endpoint name in the ASP.NET Core routing infrastructure. This is used by + /// to disambiguate endpoints that share the + /// same handler method name after URL-segment version prefixes are applied. + /// + internal void SetExplicitOperationId(string operationId) + { + OperationId = operationId; + HasExplicitOperationId = true; + } + public List FileParameters { get; } = []; [MemberNotNullWhen(true, nameof(RequestType))] diff --git a/src/Http/Wolverine.Http/Wolverine.Http.csproj b/src/Http/Wolverine.Http/Wolverine.Http.csproj index 1bf977498..0325609e4 100644 --- a/src/Http/Wolverine.Http/Wolverine.Http.csproj +++ b/src/Http/Wolverine.Http/Wolverine.Http.csproj @@ -8,5 +8,6 @@ + diff --git a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs index bd64ae1bf..249d79e22 100644 --- a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Options; using Wolverine.Configuration; using Wolverine.Configuration.Capabilities; +using Wolverine.Http.ApiVersioning; using Wolverine.Http.CodeGen; using Wolverine.Http.Transport; using Wolverine.Http.Validation; @@ -169,6 +170,14 @@ public static IServiceCollection AddWolverineHttp(this IServiceCollection servic services.AddSingleton(); + // Registered unconditionally — harmless when no versioned endpoint uses it. + services.AddSingleton(sp => + { + var httpOptions = sp.GetRequiredService(); + var versioningOptions = httpOptions.ApiVersioning ?? new WolverineApiVersioningOptions(); + return new ApiVersionHeaderWriter(versioningOptions); + }); + services.ConfigureWolverine(opts => { opts.CodeGeneration.Sources.Add(new NullableHttpContextSource()); diff --git a/src/Http/Wolverine.Http/WolverineHttpOptions.cs b/src/Http/Wolverine.Http/WolverineHttpOptions.cs index c9a5f6d46..aa1377eae 100644 --- a/src/Http/Wolverine.Http/WolverineHttpOptions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpOptions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http; using Newtonsoft.Json; using Wolverine.Configuration; +using Wolverine.Http.ApiVersioning; using Wolverine.Http.CodeGen; using Wolverine.Http.Policies; using Wolverine.Http.Resources; @@ -151,6 +152,27 @@ public void UseDataAnnotationsValidationProblemDetailMiddleware() { AddPolicy(); } + + internal WolverineApiVersioningOptions? ApiVersioning { get; private set; } + + /// + /// Enable native API versioning support. On the first call, creates the + /// instance and registers an + /// ApiVersioningPolicy that applies versioning semantics at bootstrap time. + /// Subsequent calls accumulate configuration onto the same options instance without + /// registering a second policy. + /// + /// An action to configure the . + public void UseApiVersioning(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + if (ApiVersioning is null) + { + ApiVersioning = new WolverineApiVersioningOptions(); + Policies.Add(new ApiVersioningPolicy(ApiVersioning)); + } + configure(ApiVersioning); + } public async ValueTask TryDetectTenantId(HttpContext httpContext) { From fa38a6eac8445a9e88c376a9eaa9c57e6c434be2 Mon Sep 17 00:00:00 2001 From: Geoffrey MARC Date: Thu, 30 Apr 2026 14:19:16 +0200 Subject: [PATCH 2/3] feat(http): demo API versioning in WolverineWebApi sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * OrdersV1Endpoint, OrdersV2Endpoint, OrdersV3PreviewEndpoint sharing the same /orders route — versioning policy rewrites them to /v1/orders, /v2/orders, /v3/orders * WolverineApiVersioningSwaggerOperationFilter reference filter that surfaces deprecation + sunset metadata into OpenAPI operations * Multi-document Swashbuckle setup (default + v1/v2/v3) wired against DescribeWolverineApiVersions for the SwaggerUI dropdown * Scalar.AspNetCore wired for an alternative API explorer UI * Alba integration tests covering route rewriting, response headers, per-version OpenAPI document partitioning, and deprecation flag * IntegrationContext / swashbuckle_integration updated to read the new "default" Swashbuckle document (was implicitly v1) --- .../api_versioning_integration_tests.cs | 175 ++++++++++++++++++ .../IntegrationContext.cs | 4 +- .../swashbuckle_integration.cs | 4 +- .../ApiVersioning/OrdersV1Endpoint.cs | 13 ++ .../ApiVersioning/OrdersV2Endpoint.cs | 13 ++ .../ApiVersioning/OrdersV3PreviewEndpoint.cs | 11 ++ ...rineApiVersioningSwaggerOperationFilter.cs | 65 +++++++ src/Http/WolverineWebApi/Program.cs | 45 ++++- .../WolverineWebApi/WolverineWebApi.csproj | 1 + 9 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_integration_tests.cs create mode 100644 src/Http/WolverineWebApi/ApiVersioning/OrdersV1Endpoint.cs create mode 100644 src/Http/WolverineWebApi/ApiVersioning/OrdersV2Endpoint.cs create mode 100644 src/Http/WolverineWebApi/ApiVersioning/OrdersV3PreviewEndpoint.cs create mode 100644 src/Http/WolverineWebApi/ApiVersioning/WolverineApiVersioningSwaggerOperationFilter.cs 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 new file mode 100644 index 000000000..8cfa01455 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/api_versioning_integration_tests.cs @@ -0,0 +1,175 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using Alba; +using Shouldly; +using WolverineWebApi.ApiVersioning; + +namespace Wolverine.Http.Tests.ApiVersioning; + +[Collection("integration")] +public class api_versioning_integration_tests : IntegrationContext +{ + public api_versioning_integration_tests(AppFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task v1_orders_returns_v1_response() + { + var result = await Scenario(x => + { + x.Get.Url("/v1/orders"); + x.StatusCodeShouldBeOk(); + }); + + var response = result.ReadAsJson(); + response.ShouldNotBeNull(); + response.Orders.ShouldContain("v1-order-1"); + response.Orders.ShouldContain("v1-order-2"); + } + + [Fact] + public async Task v2_orders_returns_v2_response() + { + var result = await Scenario(x => + { + x.Get.Url("/v2/orders"); + x.StatusCodeShouldBeOk(); + }); + + var response = result.ReadAsJson(); + response.ShouldNotBeNull(); + response.Status.ShouldBe("ok"); + response.Items.ShouldContain("v2-a"); + } + + [Fact] + public async Task v3_orders_emits_sunset_header() + { + var result = await Scenario(x => + { + x.Get.Url("/v3/orders"); + x.StatusCodeShouldBeOk(); + }); + + var sunsetHeader = result.Context.Response.Headers["Sunset"].FirstOrDefault(); + sunsetHeader.ShouldNotBeNull(); + + // RFC 1123 of 2027-01-01T00:00:00Z + var expectedSunset = new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero) + .UtcDateTime.ToString("R", CultureInfo.InvariantCulture); + sunsetHeader.ShouldBe(expectedSunset); + } + + [Fact] + public async Task v1_orders_emits_deprecation_header() + { + var result = await Scenario(x => + { + x.Get.Url("/v1/orders"); + x.StatusCodeShouldBeOk(); + }); + + // Deprecation header must be present (either a date or "true") + var deprecationHeader = result.Context.Response.Headers["Deprecation"].FirstOrDefault(); + deprecationHeader.ShouldNotBeNull(); + deprecationHeader.ShouldNotBeEmpty(); + } + + [Fact] + public async Task v1_orders_emits_link_header_for_deprecation() + { + var result = await Scenario(x => + { + x.Get.Url("/v1/orders"); + x.StatusCodeShouldBeOk(); + }); + + var linkHeader = result.Context.Response.Headers["Link"].FirstOrDefault(); + linkHeader.ShouldNotBeNull(); + linkHeader.ShouldContain("rel=\"deprecation\""); + } + + [Fact] + public async Task api_supported_versions_header_lists_all_versions() + { + // Any versioned endpoint emits api-supported-versions + var result = await Scenario(x => + { + x.Get.Url("/v2/orders"); + x.StatusCodeShouldBeOk(); + }); + + 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"); + } + + [Fact] + public async Task unversioned_endpoint_does_not_emit_api_supported_versions() + { + // /hello is an unversioned endpoint (PassThrough) — no header writer attached + var result = await Scenario(x => + { + x.Get.Url("/hello"); + x.StatusCodeShouldBeOk(); + }); + + result.Context.Response.Headers.ContainsKey("api-supported-versions").ShouldBeFalse(); + } + + [Fact] + public async Task swagger_v1_doc_contains_orders_endpoint() + { + var result = await Scenario(x => + { + x.Get.Url("/swagger/v1/swagger.json"); + x.StatusCodeShouldBeOk(); + }); + + var body = result.ReadAsText(); + body.ShouldContain("/v1/orders"); + } + + [Fact] + public async Task swagger_default_doc_contains_all_orders_versions() + { + var result = await Scenario(x => + { + x.Get.Url("/swagger/default/swagger.json"); + x.StatusCodeShouldBeOk(); + }); + + var body = result.ReadAsText(); + body.ShouldContain("/v1/orders"); + body.ShouldContain("/v2/orders"); + body.ShouldContain("/v3/orders"); + } + + [Fact] + public async Task swagger_v1_doc_marks_orders_deprecated() + { + // v1 has a DeprecationPolicy from options, so the operation should be marked deprecated + var result = await Scenario(x => + { + x.Get.Url("/swagger/v1/swagger.json"); + x.StatusCodeShouldBeOk(); + }); + + var body = result.ReadAsText(); + + // Parse JSON and navigate to the deprecated property + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + // Navigate to paths./v1/orders.get.deprecated + var paths = root.GetProperty("paths"); + var v1OrdersPath = paths.GetProperty("/v1/orders"); + var getOperation = v1OrdersPath.GetProperty("get"); + var deprecated = getOperation.GetProperty("deprecated"); + + deprecated.GetBoolean().ShouldBeTrue(); + } +} diff --git a/src/Http/Wolverine.Http.Tests/IntegrationContext.cs b/src/Http/Wolverine.Http.Tests/IntegrationContext.cs index 4b23efbd7..023950aa9 100644 --- a/src/Http/Wolverine.Http.Tests/IntegrationContext.cs +++ b/src/Http/Wolverine.Http.Tests/IntegrationContext.cs @@ -131,7 +131,7 @@ protected Endpoint EndpointFor(string routePattern) protected (OpenApiPathItem, OpenApiOperation) FindOpenApiDocument(string path) { var swagger = Host.Services.GetRequiredService(); - var document = swagger.GetSwagger("v1"); + var document = swagger.GetSwagger("default"); if (document.Paths.TryGetValue(path, out var item)) { @@ -144,7 +144,7 @@ protected Endpoint EndpointFor(string routePattern) public (OpenApiPathItem, OpenApiOperation) FindOpenApiDocument(OperationType httpMethod, string path) { var swagger = Host.Services.GetRequiredService(); - var document = swagger.GetSwagger("v1"); + var document = swagger.GetSwagger("default"); if (document.Paths.TryGetValue(path, out var item)) { diff --git a/src/Http/Wolverine.Http.Tests/swashbuckle_integration.cs b/src/Http/Wolverine.Http.Tests/swashbuckle_integration.cs index 0ceedbb80..0fbcd9f5d 100644 --- a/src/Http/Wolverine.Http.Tests/swashbuckle_integration.cs +++ b/src/Http/Wolverine.Http.Tests/swashbuckle_integration.cs @@ -15,7 +15,7 @@ public swashbuckle_integration(AppFixture fixture) : base(fixture) [Fact] public async Task wolverine_stuff_is_in_the_document() { - var results = await Scenario(x => { x.Get.Url("/swagger/v1/swagger.json"); }); + var results = await Scenario(x => { x.Get.Url("/swagger/default/swagger.json"); }); var doc = results.ReadAsText(); @@ -30,7 +30,7 @@ public void ignore_endpoint_methods_that_are_marked_with_ExcludeFromDescription( HttpChains.Chains.Any(x => x.RoutePattern!.RawText == "/ignore").ShouldBeTrue(); var generator = Host.Services.GetRequiredService(); - var doc = generator.GetSwagger("v1"); + var doc = generator.GetSwagger("default"); doc.Paths.Any(x => x.Key == "/ignore").ShouldBeFalse(); } diff --git a/src/Http/WolverineWebApi/ApiVersioning/OrdersV1Endpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1Endpoint.cs new file mode 100644 index 000000000..5079959df --- /dev/null +++ b/src/Http/WolverineWebApi/ApiVersioning/OrdersV1Endpoint.cs @@ -0,0 +1,13 @@ +using Asp.Versioning; +using Wolverine.Http; + +namespace WolverineWebApi.ApiVersioning; + +[ApiVersion("1.0")] +public static class OrdersV1Endpoint +{ + [WolverineGet("/orders", OperationId = "OrdersV1Endpoint.Get")] + public static OrdersV1Response Get() => new(["v1-order-1", "v1-order-2"]); +} + +public record OrdersV1Response(IReadOnlyList Orders); diff --git a/src/Http/WolverineWebApi/ApiVersioning/OrdersV2Endpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/OrdersV2Endpoint.cs new file mode 100644 index 000000000..4316a8eb7 --- /dev/null +++ b/src/Http/WolverineWebApi/ApiVersioning/OrdersV2Endpoint.cs @@ -0,0 +1,13 @@ +using Asp.Versioning; +using Wolverine.Http; + +namespace WolverineWebApi.ApiVersioning; + +[ApiVersion("2.0")] +public static class OrdersV2Endpoint +{ + [WolverineGet("/orders", OperationId = "OrdersV2Endpoint.Get")] + public static OrdersV2Response Get() => new("ok", ["v2-a", "v2-b", "v2-c"]); +} + +public record OrdersV2Response(string Status, IReadOnlyList Items); diff --git a/src/Http/WolverineWebApi/ApiVersioning/OrdersV3PreviewEndpoint.cs b/src/Http/WolverineWebApi/ApiVersioning/OrdersV3PreviewEndpoint.cs new file mode 100644 index 000000000..252713620 --- /dev/null +++ b/src/Http/WolverineWebApi/ApiVersioning/OrdersV3PreviewEndpoint.cs @@ -0,0 +1,11 @@ +using Asp.Versioning; +using Wolverine.Http; + +namespace WolverineWebApi.ApiVersioning; + +[ApiVersion("3.0")] +public static class OrdersV3PreviewEndpoint +{ + [WolverineGet("/orders", OperationId = "OrdersV3PreviewEndpoint.Get")] + public static OrdersV2Response Get() => new("preview", ["v3-preview"]); +} diff --git a/src/Http/WolverineWebApi/ApiVersioning/WolverineApiVersioningSwaggerOperationFilter.cs b/src/Http/WolverineWebApi/ApiVersioning/WolverineApiVersioningSwaggerOperationFilter.cs new file mode 100644 index 000000000..676ecd8cf --- /dev/null +++ b/src/Http/WolverineWebApi/ApiVersioning/WolverineApiVersioningSwaggerOperationFilter.cs @@ -0,0 +1,65 @@ +using Asp.Versioning; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Globalization; +using Wolverine.Http.ApiVersioning; + +namespace WolverineWebApi.ApiVersioning; + +/// +/// Swashbuckle operation filter that surfaces Wolverine API-versioning metadata in OpenAPI: +/// +/// Sets when the chain has a deprecation policy. +/// Adds an x-api-versioning extension carrying sunset date and links. +/// +/// Copy this filter into your own project and register it via opts.OperationFilter<...>(). +/// +public sealed class WolverineApiVersioningSwaggerOperationFilter : IOperationFilter +{ + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var metadata = context.ApiDescription.ActionDescriptor.EndpointMetadata; + ApplyFromMetadata(operation, metadata); + } + + /// + /// Core logic extracted for testability. Reads + /// from and mutates accordingly. + /// + internal static void ApplyFromMetadata(OpenApiOperation operation, IList metadata) + { + var state = metadata.OfType().FirstOrDefault(); + if (state is null) return; + + if (state.Deprecation is not null) + operation.Deprecated = true; + + if (state.Sunset is null && state.Deprecation is null) return; + + var ext = new OpenApiObject(); + + if (state.Sunset?.Date is { } sunsetDate) + ext["sunset"] = new OpenApiString(sunsetDate.UtcDateTime.ToString("R", CultureInfo.InvariantCulture)); + + var links = new OpenApiArray(); + foreach (var link in (state.Sunset?.Links ?? []).Concat(state.Deprecation?.Links ?? [])) + { + var linkObj = new OpenApiObject + { + ["href"] = new OpenApiString(link.LinkTarget?.ToString() ?? string.Empty) + }; + if (link.Title.HasValue && link.Title.Length > 0) + linkObj["title"] = new OpenApiString(link.Title.Value); + if (link.Type.HasValue && link.Type.Length > 0) + linkObj["type"] = new OpenApiString(link.Type.Value); + links.Add(linkObj); + } + if (links.Count > 0) + ext["links"] = links; + + if (ext.Count > 0) + operation.Extensions["x-api-versioning"] = ext; + } +} diff --git a/src/Http/WolverineWebApi/Program.cs b/src/Http/WolverineWebApi/Program.cs index 241d82878..56b4eddf2 100644 --- a/src/Http/WolverineWebApi/Program.cs +++ b/src/Http/WolverineWebApi/Program.cs @@ -12,18 +12,22 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; +using Scalar.AspNetCore; using Wolverine; using Wolverine.AdminApi; using Wolverine.EntityFrameworkCore; using Wolverine.ErrorHandling; using Wolverine.FluentValidation; using Wolverine.Http; +using Wolverine.Http.ApiVersioning; using Wolverine.Http.FluentValidation; using Wolverine.Http.Marten; using Wolverine.Http.Tests.DifferentAssembly.Validation; using Wolverine.Http.Transport; using Wolverine.Marten; using WolverineWebApi; +using WolverineWebApi.ApiVersioning; using WolverineWebApi.Bugs; using WolverineWebApi.Marten; using WolverineWebApi.Samples; @@ -56,7 +60,14 @@ public static async Task Main(string[] args) #region sample_register_custom_swashbuckle_filter builder.Services.AddSwaggerGen(x => { + x.SwaggerDoc("default", new OpenApiInfo { Title = "Wolverine Web API", Version = "default" }); + 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" }); x.OperationFilter(); + x.OperationFilter(); + x.DocInclusionPredicate((docName, api) => + docName == "default" || api.GroupName == docName); x.ResolveConflictingActions(apiDescriptions => apiDescriptions.First()); }); @@ -207,7 +218,29 @@ public static async Task Main(string[] args) // Configure the HTTP request pipeline. app.UseSwagger(); - app.UseSwaggerUI(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/default/swagger.json", "default"); + foreach (var description in app.DescribeWolverineApiVersions()) + { + c.SwaggerEndpoint( + $"/swagger/{description.DocumentName}/swagger.json", + description.DisplayName + (description.IsDeprecated ? " (deprecated)" : "")); + } + }); + + app.MapScalarApiReference(options => + { + options.AddDocument("default", "Wolverine Web API (all)", + $"/swagger/default/swagger.json"); + foreach (var description in app.DescribeWolverineApiVersions()) + { + options.AddDocument( + description.DocumentName, + description.DisplayName + (description.IsDeprecated ? " (deprecated)" : ""), + $"/swagger/{description.DocumentName}/swagger.json"); + } + }); app.UseAuthorization(); @@ -249,6 +282,16 @@ public static async Task Main(string[] args) #region sample_using_configure_endpoints app.MapWolverineEndpoints(opts => { + opts.UseApiVersioning(v => + { + // Existing unversioned endpoints are left unchanged + v.UnversionedPolicy = UnversionedPolicy.PassThrough; + v.Sunset("3.0").On(DateTimeOffset.Parse("2027-01-01T00:00:00Z")) + .WithLink(new Uri("https://example.com/migrate-to-v2"), "Migration guide", "text/html"); + v.Deprecate("1.0").On(DateTimeOffset.Parse("2026-12-31T00:00:00Z")) + .WithLink(new Uri("https://example.com/sunset-v1")); + }); + // This is strictly to test the endpoint policy opts.ConfigureEndpoints(httpChain => diff --git a/src/Http/WolverineWebApi/WolverineWebApi.csproj b/src/Http/WolverineWebApi/WolverineWebApi.csproj index 5c56cb1d8..04e30bf7a 100644 --- a/src/Http/WolverineWebApi/WolverineWebApi.csproj +++ b/src/Http/WolverineWebApi/WolverineWebApi.csproj @@ -6,6 +6,7 @@ + From 51bdfc7234c7772335648295bde24a084b331bf6 Mon Sep 17 00:00:00 2001 From: Geoffrey MARC Date: Thu, 30 Apr 2026 14:19:25 +0200 Subject: [PATCH 3/3] docs(http): add API versioning guide New docs/guide/http/versioning.md covers attribute usage, UnversionedPolicy modes, sunset/deprecation policies, RFC 9745/8594 header behaviour, and OpenAPI integration patterns for Swashbuckle, Scalar, and Microsoft.AspNetCore.OpenApi. Cross-linked from metadata.md and registered in the Vitepress sidebar. --- CHANGELOG.md | 11 ++ docs/.vitepress/config.mts | 1 + docs/guide/http/index.md | 6 + docs/guide/http/metadata.md | 6 + docs/guide/http/versioning.md | 319 ++++++++++++++++++++++++++++++++++ 5 files changed, 343 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 docs/guide/http/versioning.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..6496d0033 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## Unreleased + +### WolverineFx.Http + +- Added native API versioning support via `Asp.Versioning.Abstractions` 10.x. Supports URL-segment versioning + (`/v1/...`, `/v2/...`), sunset/deprecation policies with RFC 9745/8594/8288 response headers, and automatic + OpenAPI document partitioning with Swashbuckle/Scalar/Microsoft.AspNetCore.OpenApi. No dependency on + `Asp.Versioning.Http` — versioning is driven entirely via `IHttpPolicy`. + See [versioning guide](docs/guide/http/versioning.md). diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 0b4e6d76c..5bb2f19de 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -264,6 +264,7 @@ const config: UserConfig = { {text: 'Exception Handling', link: '/guide/http/exception-handling'}, {text: 'Policies', link: '/guide/http/policies.md'}, {text: 'OpenAPI Metadata', link: '/guide/http/metadata'}, + {text: 'API Versioning', link: '/guide/http/versioning'}, {text: 'Using as Mediator', link: '/guide/http/mediator'}, {text: 'Multi-Tenancy and ASP.Net Core', link: '/guide/http/multi-tenancy'}, {text: 'Publishing Messages', link: '/guide/http/messaging'}, diff --git a/docs/guide/http/index.md b/docs/guide/http/index.md index 1397b5cd4..b1a868b32 100644 --- a/docs/guide/http/index.md +++ b/docs/guide/http/index.md @@ -273,4 +273,10 @@ leads to code that is hard to reason about and hence, potentially buggy in real need this functionality in the real world, so here you go. ::: +## API Versioning + +Wolverine.Http has native support for versioning your HTTP APIs over time using URL-segment strategies (e.g. +`/v1/orders`, `/v2/orders`), per-version sunset and deprecation policies with RFC 9745/8594 response headers, +and automatic OpenAPI document partitioning. See the [HTTP API Versioning guide](./versioning.md) for full details. + diff --git a/docs/guide/http/metadata.md b/docs/guide/http/metadata.md index 33f0ecba2..6266d6636 100644 --- a/docs/guide/http/metadata.md +++ b/docs/guide/http/metadata.md @@ -58,6 +58,12 @@ public static void Configure(HttpChain chain) snippet source | anchor +::: tip +For HTTP API versioning that partitions the OpenAPI output into one document per version, see the +[Versioning guide](./versioning.md). It covers the multi-document `SwaggerDoc` setup, `DocInclusionPredicate`, +`DescribeWolverineApiVersions()`, and Scalar integration. +::: + ## Swashbuckle and Wolverine [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) is de facto the OpenAPI tooling for ASP.Net Core diff --git a/docs/guide/http/versioning.md b/docs/guide/http/versioning.md new file mode 100644 index 000000000..a982ba108 --- /dev/null +++ b/docs/guide/http/versioning.md @@ -0,0 +1,319 @@ +# HTTP API Versioning + +Wolverine.Http provides native API versioning support that lets you evolve your HTTP services over time without +breaking existing clients. Versioned endpoints coexist at separate URL prefixes (e.g. `/v1/orders` and `/v2/orders`), +carry the correct OpenAPI metadata, and can emit RFC 9745 `Deprecation` and RFC 8594 `Sunset` response headers to +signal planned end-of-life to callers. + +The feature depends on the `Asp.Versioning.Abstractions` 10.x NuGet package — the thin, framework-neutral abstraction +layer. It does **not** require `Asp.Versioning.Http` (the ASP.NET Core-specific middleware pack). Wolverine drives +versioning entirely through its own `IHttpPolicy` pipeline, so there is no conflict with an existing +`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. +::: + +## Quick Start + +**1. Add the package** (if not already present via `WolverineFx.Http`): + +```bash +dotnet add package Asp.Versioning.Abstractions +``` + +**2. Decorate your endpoint class or method:** + +```csharp +using Asp.Versioning; +using Wolverine.Http; + +[ApiVersion("1.0")] +public static class OrdersV1Endpoint +{ + [WolverineGet("/orders", OperationId = "OrdersV1Endpoint.Get")] + public static OrdersV1Response Get() => new(["order-a", "order-b"]); +} + +public record OrdersV1Response(IReadOnlyList Orders); +``` + +**3. Enable versioning inside `MapWolverineEndpoints`:** + +```csharp +app.MapWolverineEndpoints(opts => +{ + opts.UseApiVersioning(v => + { + // All options are optional — the defaults work out of the box. + v.UnversionedPolicy = UnversionedPolicy.PassThrough; + }); +}); +``` + +With these two pieces in place Wolverine will rewrite the route to `/v1/orders` and attach the appropriate +`ApiVersionMetadata` and OpenAPI group name automatically. + +## Declaring Versions + +### Attribute placement + +Apply `[ApiVersion]` to the endpoint class or to the handler method. **Method-level wins over class-level:** +if both declare a version, the method attribute is used. + +```csharp +// Class-level — all methods in this class are v2. +[ApiVersion("2.0")] +public static class OrdersV2Endpoint +{ + [WolverineGet("/orders")] + public static OrdersV2Response Get() => new("ok", ["v2-a", "v2-b"]); +} +``` + +### One version per endpoint + +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`). + +### Marking a version as deprecated via attribute + +The `Deprecated` property on `[ApiVersion]` is honoured and will automatically attach a deprecation policy to +the chain: + +```csharp +[ApiVersion("1.0", Deprecated = true)] +public static class LegacyOrdersEndpoint +{ + [WolverineGet("/orders")] + public static OrdersV1Response Get() => new([]); +} +``` + +When this attribute is present, Wolverine emits a `Deprecation: true` response header (RFC 9745) and marks the +OpenAPI operation as deprecated without any additional configuration. + +## URL-Segment Versioning + +### Default behaviour + +By default, Wolverine prepends `v{major}` to every versioned route. Given `[ApiVersion("2.0")]` and the route +`/orders`, the live URL becomes `/v2/orders`. + +### Customising the URL segment prefix + +The prefix template is controlled by `UrlSegmentPrefix` (default `"v{version}"`). The `{version}` token is +**required** — omitting it causes a startup exception (see [Troubleshooting](#troubleshooting)): + +```csharp +opts.UseApiVersioning(v => +{ + // Changes /v2/orders → /api/v2/orders + v.UrlSegmentPrefix = "api/v{version}"; +}); +``` + +Set `UrlSegmentPrefix = null` to disable URL-segment versioning entirely. In that case the original route is +kept unchanged and no prefix is injected. + +### Version format in the URL + +The `UrlSegmentVersionFormatter` callback controls how an `ApiVersion` is turned into the string substituted +into the prefix. The default formats `ApiVersion(2, 0)` as `"2"` (major-only), giving clean URLs like `/v2/`. + +Override it to emit `major.minor` format: + +```csharp +opts.UseApiVersioning(v => +{ + // Produces /v2.0/orders instead of /v2/orders + v.UrlSegmentVersionFormatter = apiVersion => + apiVersion.MajorVersion.HasValue + ? $"{apiVersion.MajorVersion}.{apiVersion.MinorVersion ?? 0}" + : apiVersion.ToString(); +}); +``` + +For date-based versions where `MajorVersion` is `null`, the default formatter falls back to `ApiVersion.ToString()`, +which may include hyphens (e.g. `2024-01-01`). Override the formatter if your URL scheme needs a different shape. + +## Unversioned-Endpoint Policy + +When `UseApiVersioning` is active, every endpoint that lacks an `[ApiVersion]` attribute is subject to the +`UnversionedPolicy`. Three behaviours are available: + +| Policy | Behaviour | +|---|---| +| `PassThrough` *(default)* | Unversioned endpoints stay at their declared route with no version metadata. They coexist alongside versioned endpoints. | +| `RequireExplicit` | Bootstrap throws an `InvalidOperationException` if any endpoint is missing `[ApiVersion]`. Recommended for greenfield APIs that should be fully versioned from day one. | +| `AssignDefault` | All unversioned endpoints are silently promoted to `DefaultVersion`. Set `DefaultVersion` when using this mode. | + +```csharp +// RequireExplicit — every endpoint must carry [ApiVersion] +opts.UseApiVersioning(v => +{ + v.UnversionedPolicy = UnversionedPolicy.RequireExplicit; +}); + +// AssignDefault — migrate an existing unversioned API to a v1 baseline +opts.UseApiVersioning(v => +{ + v.UnversionedPolicy = UnversionedPolicy.AssignDefault; + v.DefaultVersion = new ApiVersion(1, 0); +}); +``` + +## Sunset and Deprecation Policies + +Beyond the attribute-driven `Deprecated = true` flag, you can configure per-version sunset and deprecation +policies with dates and RFC 8288 link references directly in the options: + +```csharp +opts.UseApiVersioning(v => +{ + // Announce that v1.0 is deprecated as of a specific date with a migration link. + v.Deprecate("1.0") + .On(DateTimeOffset.Parse("2026-12-31T00:00:00Z")) + .WithLink(new Uri("https://example.com/migration-guide")); + + // Announce that v3.0 will sunset (be removed) on a future date. + v.Sunset("3.0") + .On(DateTimeOffset.Parse("2027-01-01T00:00:00Z")) + .WithLink(new Uri("https://example.com/migrate-from-v3"), "Migration guide", "text/html"); +}); +``` + +### Response headers + +When a versioned endpoint is matched, Wolverine emits response headers according to the applicable RFCs: + +| Header | RFC | When emitted | +|---|---|---| +| `api-supported-versions` | — | All versioned endpoints (toggleable via `EmitApiSupportedVersionsHeader`) | +| `Deprecation` | RFC 9745 | Endpoints with a deprecation policy | +| `Sunset` | RFC 8594 | Endpoints with a sunset date configured | +| `Link` | RFC 8288 | Endpoints with link references on either policy | + +Toggle the headers globally: + +```csharp +opts.UseApiVersioning(v => +{ + v.EmitApiSupportedVersionsHeader = false; // suppress the supported-versions header + v.EmitDeprecationHeaders = false; // suppress Deprecation/Sunset/Link headers +}); +``` + +## OpenAPI Integration + +Wolverine attaches two pieces of metadata to every versioned chain during bootstrapping: + +- `IEndpointGroupNameMetadata` — the document name string (e.g. `"v1"`, `"v2"`) used by Swashbuckle, Scalar, + and Microsoft.AspNetCore.OpenApi to partition the OpenAPI output. +- `Asp.Versioning.ApiVersionMetadata` — the typed version model consumed by `Asp.Versioning.Http` tooling if + it is also present in the application. + +### Customising the document name strategy + +The default strategy maps `ApiVersion(2, 0)` → `"v2"`. Override it via: + +```csharp +opts.UseApiVersioning(v => +{ + // Use "api-v2" as the Swashbuckle document name for version 2.x + v.OpenApi.DocumentNameStrategy = apiVersion => + apiVersion.MajorVersion.HasValue + ? $"api-v{apiVersion.MajorVersion}" + : apiVersion.ToString(); +}); +``` + +### Swashbuckle multi-document setup + +Register one `SwaggerDoc` per version. Use `DocInclusionPredicate` to route each endpoint to the correct document +via the group name Wolverine has set. Register `WolverineApiVersioningSwaggerOperationFilter` to surface sunset +and deprecation state in the OpenAPI output: + +```csharp +builder.Services.AddSwaggerGen(x => +{ + x.SwaggerDoc("v1", new OpenApiInfo { Title = "API v1", Version = "v1" }); + x.SwaggerDoc("v2", new OpenApiInfo { Title = "API v2", Version = "v2" }); + + // Route each endpoint to the document whose name matches the endpoint group name. + x.DocInclusionPredicate((doc, api) => api.GroupName == doc); + + // Surfaces deprecation / sunset state in the OpenAPI output. + x.OperationFilter(); +}); +``` + +`WolverineApiVersioningSwaggerOperationFilter` is **not distributed as a NuGet library**. Copy it from the sample +app (`src/Http/WolverineWebApi/ApiVersioning/WolverineApiVersioningSwaggerOperationFilter.cs`) into your own +project and register it as shown above. + +### SwaggerUI version dropdown + +Use `DescribeWolverineApiVersions()` to enumerate the discovered versions and wire each into the SwaggerUI +endpoint list: + +```csharp +app.UseSwagger(); +app.UseSwaggerUI(c => +{ + // Wire in non-versioned endpoints under a "default" document if you have them. + c.SwaggerEndpoint("/swagger/default/swagger.json", "default"); + + foreach (var description in app.DescribeWolverineApiVersions()) + { + c.SwaggerEndpoint( + $"/swagger/{description.DocumentName}/swagger.json", + description.DisplayName); + } +}); +``` + +`DescribeWolverineApiVersions()` returns an empty list when versioning is not configured or no versioned +endpoints have been discovered, so the loop is always safe to call. + +### Scalar integration + +Scalar follows the same pattern. Register one document per version, then iterate `DescribeWolverineApiVersions()`: + +```csharp +app.MapScalarApiReference(opts => +{ + opts.Title = "My API"; + foreach (var description in app.DescribeWolverineApiVersions()) + opts.AddDocument(description.DocumentName, description.DisplayName); +}); +``` + +### Microsoft.AspNetCore.OpenApi + +When using the built-in `Microsoft.AspNetCore.OpenApi` package, Wolverine's group name is surfaced as +`ApiDescription.GroupName` in `ApiDescriptionGroupCollection`. The standard ASP.NET Core document filter +mechanism can be used to partition endpoints by group name in exactly the same way as Swashbuckle's +`DocInclusionPredicate`. + +## Troubleshooting + +**`UrlSegmentPrefix` is set but the route is unchanged.** +Check that the prefix string contains the literal `{version}` token. If no `{version}` is present and there are +versioned endpoints, Wolverine throws at startup: + +> `WolverineApiVersioningOptions.UrlSegmentPrefix is set to 'myprefix' which does not contain the required '{version}' token. ... Set UrlSegmentPrefix to null to disable URL-segment versioning, or include '{version}' in the prefix template.` + +**All my existing endpoints stopped resolving after I turned on `RequireExplicit`.** +`RequireExplicit` means every endpoint, including infrastructure or health-check endpoints discovered by Wolverine's +assembly scan, must carry `[ApiVersion]`. Switch to `PassThrough` (the default) for any endpoints that should +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.