From 15263ee72bb676077d4671453c06e44838030ad2 Mon Sep 17 00:00:00 2001 From: Brandon K Date: Mon, 6 Jul 2026 10:02:37 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Add=20WolverineFx.Http.AspVersioning=20?= =?UTF-8?q?=E2=80=94=20Asp.Versioning.Http=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/http.yml | 4 + Directory.Packages.props | 2 + build/CITargets.cs | 10 + build/build.cs | 1 + docs/guide/http/versioning.md | 146 ++++++++ .../AdvertisedVersionResolverTests.cs | 104 ++++++ .../GroupingTests.cs | 272 ++++++++++++++ .../HostlessAspVersioningContext.cs | 79 ++++ .../HttpChainExtensions.cs | 50 +++ .../Parallelization.cs | 1 + .../Parity/AspVersioningIntegrationContext.cs | 155 ++++++++ .../Parity/DedicatedHostParityTests.cs | 182 +++++++++ .../Parity/NativeParityTests.cs | 345 ++++++++++++++++++ .../Parity/OpenApiParityTests.cs | 228 ++++++++++++ .../Parity/ParityEndpoints.cs | 303 +++++++++++++++ .../Parity/ParityFixture.cs | 50 +++ .../Parity/UrlSegmentParityTests.cs | 115 ++++++ .../PolicyOutputTests.cs | 217 +++++++++++ .../SetBuildingTests.cs | 163 +++++++++ .../Usings.cs | 1 + .../ValidationTests.cs | 75 ++++ .../Wolverine.Http.AspVersioning.Tests.csproj | 37 ++ .../AdvertisedVersionResolver.cs | 41 +++ .../AspVersioningPolicy.cs | 152 ++++++++ .../AssemblyAttributes.cs | 3 + .../VersionedChain.cs | 44 +++ .../Wolverine.Http.AspVersioning.csproj | 23 ++ .../WolverineHttpOptionsExtensions.cs | 19 + .../MultiVersionResolverTests.cs | 20 + .../ApiVersioning/ApiVersionResolver.cs | 23 +- src/Http/Wolverine.Http/AssemblyAttributes.cs | 3 + .../HttpChain.EndpointBuilder.cs | 24 +- src/Http/Wolverine.Http/HttpChain.cs | 6 +- wolverine.slnx | 6 + 34 files changed, 2894 insertions(+), 10 deletions(-) create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/AdvertisedVersionResolverTests.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/GroupingTests.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/HostlessAspVersioningContext.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/HttpChainExtensions.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/Parallelization.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/Parity/AspVersioningIntegrationContext.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/Parity/DedicatedHostParityTests.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/Parity/NativeParityTests.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/Parity/OpenApiParityTests.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/Parity/ParityEndpoints.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/Parity/ParityFixture.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/Parity/UrlSegmentParityTests.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/PolicyOutputTests.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/SetBuildingTests.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/Usings.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/ValidationTests.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning.Tests/Wolverine.Http.AspVersioning.Tests.csproj create mode 100644 src/Http/Wolverine.Http.AspVersioning/AdvertisedVersionResolver.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning/AspVersioningPolicy.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning/AssemblyAttributes.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning/VersionedChain.cs create mode 100644 src/Http/Wolverine.Http.AspVersioning/Wolverine.Http.AspVersioning.csproj create mode 100644 src/Http/Wolverine.Http.AspVersioning/WolverineHttpOptionsExtensions.cs diff --git a/.github/workflows/http.yml b/.github/workflows/http.yml index 4b6dfdb34..23934b34e 100644 --- a/.github/workflows/http.yml +++ b/.github/workflows/http.yml @@ -38,6 +38,10 @@ jobs: - name: Run HTTP Tests run: ./build.sh CIHttp --framework net9.0 + # Asp.Versioning tests are pinned to net10.0 + - name: Run HTTP Asp.Versioning Tests + run: ./build.sh CIHttpAspVersioning + - name: Stop containers if: always() run: docker compose down diff --git a/Directory.Packages.props b/Directory.Packages.props index 2f028445c..bffc19283 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,8 @@ + + diff --git a/build/CITargets.cs b/build/CITargets.cs index 35ba384b9..2d1dc53a5 100644 --- a/build/CITargets.cs +++ b/build/CITargets.cs @@ -450,6 +450,16 @@ void BuildTestProjectsWithFramework(string frameworkOverride, params AbsolutePat .EnableNoBuild()); }); + Target CIHttpAspVersioning => _ => _ + .Executes(() => + { + var tests = RootDirectory / "src" / "Http" / "Wolverine.Http.AspVersioning.Tests" / "Wolverine.Http.AspVersioning.Tests.csproj"; + + BuildTestProjectsWithFramework("net10.0", tests); + + RunTestProject(tests, frameworkOverride: "net10.0"); + }); + Target CIRabbitMQ => _ => _ .ProceedAfterFailure() .Executes(() => diff --git a/build/build.cs b/build/build.cs index d4b91027b..2b758e87d 100644 --- a/build/build.cs +++ b/build/build.cs @@ -383,6 +383,7 @@ partial class Build : NukeBuild Solution.Extensions.Wolverine_Newtonsoft, Solution.Extensions.Wolverine_Protobuf, Solution.Http.Wolverine_Http, + Solution.Http.Wolverine_Http_AspVersioning, Solution.Http.Wolverine_Http_Newtonsoft, Solution.Http.Wolverine_Http_FluentValidation, Solution.Http.Wolverine_Http_Marten, diff --git a/docs/guide/http/versioning.md b/docs/guide/http/versioning.md index 75be7ae87..4ce00d987 100644 --- a/docs/guide/http/versioning.md +++ b/docs/guide/http/versioning.md @@ -15,6 +15,11 @@ This release supports **URL-segment versioning** (e.g. `/v1/...`, `/v2/...`) and via repeated `[ApiVersion]` attributes or `[MapToApiVersion]`. See [Multi-version handlers](#multi-version-handlers). ::: +If you need the full `Asp.Versioning.Http` feature set — its request-time matcher, version reporter, and +API explorer — or you are migrating an existing Asp.Versioning integration to Wolverine, use the +`WolverineFx.Http.AspVersioning` bridge instead. See +[Asp.Versioning.Http Package Support](#asp-versioning-http-package-support). + ## Quick Start **1. Add the package** (if not already present via `WolverineFx.Http`): @@ -544,3 +549,144 @@ version to the class-level `[ApiVersion]` list, or remove it from `[MapToApiVers **Both `[ApiVersion]` and `[MapToApiVersion]` on the same method.** Startup throws. Use only one: `[ApiVersion]` declares versions independently of the class; `[MapToApiVersion]` selects from class-level versions. + +## Asp.Versioning.Http Package Support + +Everything above is Wolverine's **native** versioning, which depends only on `Asp.Versioning.Abstractions` +and supports URL-segment versioning with minimal runtime overhead. If you'd prefer to version your +endpoints using [`Asp.Versioning.Http`](https://github.com/dotnet/aspnet-api-versioning) or are migrating +an existing Asp.Versioning codebase and want identical behavior, Wolverine offers a dedicated +`WolverineFx.Http.AspVersioning` bridge package to support that integration. + +::: warning +This integration currently requires **.NET 10 or later** — the `WolverineFx.Http.AspVersioning` package +targets `net10.0` only. +::: + +::: tip Prefer native versioning unless you need the full Asp.Versioning feature set +`Asp.Versioning.Http` registers a request-time `ApiVersionMatcherPolicy` and wraps every matched +endpoint's delegate with response decorators, so it carries real per-request cost. Wolverine's native +[`UseApiVersioning`](#quick-start) does not register the `Asp.Versioning.Http` runtime stack, so it +generally performs better and should be favored by teams that do not require full Asp.Versioning +functionality. +::: + +### Decision Matrix + +| | Native `UseApiVersioning()` | Bridge `UseAspVersioning()` | +|---------------------|-------------------------------------------------|------------------------------------------------------| +| Dependency | `Asp.Versioning.Abstractions` only | full `Asp.Versioning.Http` | +| Target frameworks | net9.0 / net10.0 | **net10.0 only** | +| Version readers | URL-segment only | query, header, media-type, URL-segment, combined | +| URL-segment routes | auto-rewrites `/orders` → `/v1/orders` | you author `{version:apiVersion}` (no auto prefix) | +| Runtime cost | no request-time matcher or response decorators | Asp.Versioning matcher policy + response decorators | +| Configured via | `opts.UseApiVersioning(v => …)` | your own `AddApiVersioning(…)` | + +In short: prefer native for URL-segment versioning at the lowest overhead (or on .NET 9); reach for +the bridge when you need header/query/media-type readers, the rest of the Asp.Versioning feature set, +or a behavior-identical migration of an existing Asp.Versioning app. + +### Basic Usage + +**1. Install the package:** + +```bash +dotnet add package WolverineFx.Http.AspVersioning +``` + +**2. Register Asp.Versioning and enable the bridge.** Unlike native versioning, you register +`AddApiVersioning(...)` yourself — the bridge delegates entirely to *your* Asp.Versioning configuration +(version reader, reporting, sunset policies, and so on): + +```csharp +using Wolverine.Http.AspVersioning; + +// Your own Asp.Versioning configuration — reader, reporting, defaults, etc. +builder.Services.AddApiVersioning(options => +{ + options.ReportApiVersions = true; +}); + +builder.Services.AddWolverineHttp(); + +// ... + +app.MapWolverineEndpoints(opts => opts.UseAspVersioning()); +``` + +::: warning +Wolverine will throw an exception if `UseApiVersioning()` and `UseAspVersioning()` are both used in +the same application — choose one or the other. +::: + +**3. Version your endpoints with the usual attributes.** The bridge understands `[ApiVersion]`, +`[MapToApiVersion]`, `[AdvertiseApiVersions]`, and `[ApiVersionNeutral]` with the same +method-overrides-class precedence as native versioning: + +```csharp +[ApiVersion("1.0")] +public static class OrdersV1Endpoint +{ + [WolverineGet("/orders")] + public static OrdersV1Response Get() => new(["order-a", "order-b"]); +} + +[ApiVersion("2.0")] +public static class OrdersV2Endpoint +{ + // Same route as v1 — Asp.Versioning selects between them by the requested version. + [WolverineGet("/orders")] + public static OrdersV2Response Get() => new("ok", ["v2-a", "v2-b"]); +} +``` + +With this configuration `GET /orders?api-version=1.0` reaches the v1 endpoint and +`GET /orders?api-version=2.0` reaches the v2 endpoint. + +### URL-segment versioning + +URL-segment versioning works exactly as it does in a vanilla Asp.Versioning minimal API: author the +version as a `{version:apiVersion}` route parameter and configure a URL-segment (or combined) reader via +`AddApiVersioning`. Unlike native versioning, the bridge does **not** synthesize a `/v1/` prefix — you +write the segment yourself, and endpoints declaring different versions share the one template: + +```csharp +[ApiVersion("1.0")] +public static class OrdersV1Endpoint +{ + [WolverineGet("/v{version:apiVersion}/orders")] + public static OrdersV1Response Get() => new(["order-a", "order-b"]); +} + +[ApiVersion("2.0")] +public static class OrdersV2Endpoint +{ + [WolverineGet("/v{version:apiVersion}/orders")] + public static OrdersV2Response Get() => new("ok", ["v2-a", "v2-b"]); +} +``` + +`GET /v1/orders` reaches the v1 endpoint and `GET /v2/orders` the v2 endpoint. Set +`AddApiExplorer(o => o.SubstituteApiVersionInUrl = true)` to render concrete versions (`/v1/orders`) in +the generated OpenAPI documents. + +### OpenAPI + +Asp.Versioning's API explorer is offered as a separate package (`Asp.Versioning.Mvc.ApiExplorer`) +and is not included as a dependency of `WolverineFx.Http.AspVersioning`. Once added to your project, +it can be configured exactly as if you were building a minimal API. For example: + +```csharp +builder.Services + .AddApiVersioning(options => options.ReportApiVersions = true) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = false; + }); +``` + +::: warning +Do not mix in the native [document-name strategy](#openapi-integration) or `DescribeWolverineApiVersions()`; +those belong to the native versioning path described above. +::: diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/AdvertisedVersionResolverTests.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/AdvertisedVersionResolverTests.cs new file mode 100644 index 000000000..fab89b7b5 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/AdvertisedVersionResolverTests.cs @@ -0,0 +1,104 @@ +using System.Reflection; +using Asp.Versioning; +using Shouldly; + +namespace Wolverine.Http.AspVersioning.Tests; + +internal class NoAdvertisedHandler +{ + public void Handle() { } +} + +[AdvertiseApiVersions("1.0")] +internal class ClassOnlyAdvertisedHandler +{ + public void Handle() { } +} + +internal class MethodOnlyAdvertisedHandler +{ + [AdvertiseApiVersions("2.0")] + public void Handle() { } +} + +[AdvertiseApiVersions("4.0", "5.0")] +internal class MultipleAdvertisedHandler +{ + [AdvertiseApiVersions("1.0", "2.0")] + [AdvertiseApiVersions("3.0")] + public void Handle() { } +} + +[AdvertiseApiVersions("1.0", Deprecated = true)] +[AdvertiseApiVersions("2.0")] +[AdvertiseApiVersions("4.0", Deprecated = true)] +internal class DeprecatedAdvertisedHandler +{ + [AdvertiseApiVersions("1.0")] + [AdvertiseApiVersions("2.0", Deprecated = true)] + [AdvertiseApiVersions("3.0")] + public void Handle() { } +} + +public class AdvertisedVersionResolverTests +{ + private static MethodInfo MethodOf(string name = "Handle") => + typeof(T).GetMethod(name, BindingFlags.Public | BindingFlags.Instance)!; + + [Fact] + public void no_attribute_returns_empty() + { + var method = MethodOf(nameof(NoAdvertisedHandler.Handle)); + AdvertisedVersionResolver.ResolveAdvertised(method).ShouldBeEmpty(); + } + + [Fact] + public void class_attribute_resolves() + { + var method = MethodOf( + nameof(ClassOnlyAdvertisedHandler.Handle) + ); + var result = AdvertisedVersionResolver.ResolveAdvertised(method); + result.ShouldHaveSingleItem(); + result[0].Version.ShouldBe(new ApiVersion(1, 0)); + result[0].IsDeprecated.ShouldBeFalse(); + } + + [Fact] + public void method_attribute_resolves() + { + var method = MethodOf( + nameof(MethodOnlyAdvertisedHandler.Handle) + ); + var result = AdvertisedVersionResolver.ResolveAdvertised(method); + result.ShouldHaveSingleItem(); + result[0].Version.ShouldBe(new ApiVersion(2, 0)); + result[0].IsDeprecated.ShouldBeFalse(); + } + + [Fact] + public void multiple_attributes_merge() + { + var method = MethodOf(nameof(MultipleAdvertisedHandler.Handle)); + var result = AdvertisedVersionResolver.ResolveAdvertised(method); + result + .Select(r => r.Version) + .ShouldBe(Enumerable.Range(1, 5).Select(i => new ApiVersion(i, 0))); + } + + [Fact] + public void deprecations_merge_and_propagate() + { + var method = MethodOf( + nameof(DeprecatedAdvertisedHandler.Handle) + ); + var result = AdvertisedVersionResolver.ResolveAdvertised(method); + result + .Select(r => r.Version) + .ShouldBe(Enumerable.Range(1, 4).Select(i => new ApiVersion(i, 0))); + result.Single(r => r.Version == new ApiVersion(1, 0)).IsDeprecated.ShouldBeTrue(); + result.Single(r => r.Version == new ApiVersion(2, 0)).IsDeprecated.ShouldBeTrue(); + result.Single(r => r.Version == new ApiVersion(3, 0)).IsDeprecated.ShouldBeFalse(); + result.Single(r => r.Version == new ApiVersion(4, 0)).IsDeprecated.ShouldBeTrue(); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/GroupingTests.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/GroupingTests.cs new file mode 100644 index 000000000..bd844a499 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/GroupingTests.cs @@ -0,0 +1,272 @@ +using Asp.Versioning; +using Shouldly; + +namespace Wolverine.Http.AspVersioning.Tests; + +[ApiVersion("1.0")] +internal class SameRouteV1Handler +{ + [WolverineGet("/same-route")] + public string Get() => "v1"; +} + +[ApiVersion("2.0")] +internal class SameRouteV2Handler +{ + [WolverineGet("/same-route")] + public string Get() => "v2"; +} + +[ApiVersion("1.0")] +internal class UpperCaseRouteHandler +{ + [WolverineGet("/CaseRoute")] + public string Get() => "upper"; +} + +[ApiVersion("2.0")] +internal class LowerCaseRouteHandler +{ + [WolverineGet("/caseroute")] + public string Get() => "lower"; +} + +[ApiVersion("1.0")] +internal class LeadingSlashRouteHandler +{ + [WolverineGet("/slash-route")] + public string Get() => "leading"; +} + +[ApiVersion("2.0")] +internal class TrailingSlashRouteHandler +{ + [WolverineGet("slash-route/")] + public string Get() => "trailing"; +} + +[ApiVersion("1.0")] +internal class DistinctOrdersHandler +{ + [WolverineGet("/distinct-orders")] + public string Get() => "orders"; +} + +// Distinct version so the two routes group separately and their version spaces differ observably. +[ApiVersion("2.0")] +internal class DistinctCustomersHandler +{ + [WolverineGet("/distinct-customers")] + public string Get() => "customers"; +} + +[ApiVersion("1.0")] +internal class SharedRouteGetHandler +{ + [WolverineGet("/shared-verbs")] + public string Get() => "get-v1"; +} + +[ApiVersion("2.0")] +internal class SharedRoutePostHandler +{ + [WolverinePost("/shared-verbs")] + public string Post() => "post-v2"; +} + +[ApiVersionNeutral] +internal class NeutralSiblingHandler +{ + [WolverineGet("/neutral-mix")] + public string Get() => "neutral"; +} + +[ApiVersion("1.0")] +internal class VersionedSiblingHandler +{ + [WolverineGet("/neutral-mix")] + public string Get() => "v1"; +} + +[ApiVersion("1.0")] +internal class RootV1Handler +{ + [WolverineGet("/")] + public string Get() => "root-v1"; +} + +[ApiVersion("2.0")] +internal class RootV2Handler +{ + [WolverineGet("/")] + public string Get() => "root-v2"; +} + +// URL-segment idiom: two endpoints share one {version:apiVersion} template. +[ApiVersion("1.0")] +internal class SegmentTemplateV1Handler +{ + [WolverineGet("/segment/v{version:apiVersion}/things")] + public string Get() => "v1"; +} + +[ApiVersion("2.0")] +internal class SegmentTemplateV2Handler +{ + [WolverineGet("/segment/v{version:apiVersion}/things")] + public string Get() => "v2"; +} + +// Multi-method class: both methods share the class-level [ApiVersion]; grouping is still by route. +[ApiVersion("1.0")] +[ApiVersion("2.0")] +internal class MultiMethodSameRouteHandler +{ + [MapToApiVersion("1.0")] + [WolverineGet("/multi-same")] + public string Get() => "get"; + + [MapToApiVersion("2.0")] + [WolverinePost("/multi-same")] + public string Post() => "post"; +} + +[ApiVersion("1.0")] +[ApiVersion("2.0")] +internal class MultiMethodDistinctRoutesHandler +{ + [MapToApiVersion("1.0")] + [WolverineGet("/multi-distinct-a")] + public string A() => "a"; + + [MapToApiVersion("2.0")] + [WolverineGet("/multi-distinct-b")] + public string B() => "b"; +} + +/// +/// Route-only grouping performed by . The finalizer consumes the +/// shared ApiVersionSet into each chain's , so the observable +/// signal of grouping is the aggregate (group-wide) version space: identical across chains in one +/// group, disjoint across separate groups. +/// +public class GroupingTests : HostlessAspVersioningContext +{ + public GroupingTests(HostlessAspVersioningFixture fixture) : base(fixture) { } + + // Applies the policy over two chains that should land in one group and asserts both see the same + // aggregate version space. The distinguishing input of each scenario is the handler pair (its routes); + // this collapses the otherwise-identical assertion shape they share. + private void AssertSharedVersionSpace( + HttpChain a, + HttpChain b, + params ApiVersion[] union + ) + { + VersioningHarness.Apply(a, b); + a.GroupModel().SupportedApiVersions.ShouldBe(union, ignoreOrder: true); + b.GroupModel().SupportedApiVersions.ShouldBe(union, ignoreOrder: true); + } + + private static readonly ApiVersion[] V1AndV2 = [new(1, 0), new(2, 0)]; + + [Fact] + public void same_route_with_distinct_versions_forms_one_group() => + AssertSharedVersionSpace( + VersioningHarness.ChainFor(x => x.Get()), + VersioningHarness.ChainFor(x => x.Get()), + V1AndV2 + ); + + // Route comparison is OrdinalIgnoreCase. + [Fact] + public void routes_differing_only_by_case_form_one_group() => + AssertSharedVersionSpace( + VersioningHarness.ChainFor(x => x.Get()), + VersioningHarness.ChainFor(x => x.Get()), + V1AndV2 + ); + + // Leading/trailing slashes are normalized away before grouping. + [Fact] + public void routes_differing_only_by_slashes_form_one_group() => + AssertSharedVersionSpace( + VersioningHarness.ChainFor(x => x.Get()), + VersioningHarness.ChainFor(x => x.Get()), + V1AndV2 + ); + + [Fact] + public void distinct_routes_form_distinct_groups() + { + var orders = VersioningHarness.ChainFor(x => x.Get()); + var customers = VersioningHarness.ChainFor(x => x.Get()); + + VersioningHarness.Apply(orders, customers); + + orders.GroupModel().SupportedApiVersions.ShouldBe(new[] { new ApiVersion(1, 0) }); + customers.GroupModel().SupportedApiVersions.ShouldBe(new[] { new ApiVersion(2, 0) }); + } + + // The HTTP verb is deliberately not part of the grouping key, so GET + POST at one route share a + // version space. This cross-verb over-advertisement is the accepted tradeoff of route-only grouping. + [Fact] + public void same_route_with_different_verbs_forms_one_group() => + AssertSharedVersionSpace( + VersioningHarness.ChainFor(x => x.Get()), + VersioningHarness.ChainFor(x => x.Post()), + V1AndV2 + ); + + [Fact] + public void neutral_chain_does_not_contribute_to_versioned_group() + { + var neutral = VersioningHarness.ChainFor(x => x.Get()); + var versioned = VersioningHarness.ChainFor(x => x.Get()); + + VersioningHarness.Apply(neutral, versioned); + + versioned.GroupModel().SupportedApiVersions.ShouldBe(new[] { new ApiVersion(1, 0) }); + neutral.VersionMetadata()!.IsApiVersionNeutral.ShouldBeTrue(); + } + + // "/" normalizes to the empty grouping key — one logical root route, one group. + [Fact] + public void root_route_endpoints_form_one_group() => + AssertSharedVersionSpace( + VersioningHarness.ChainFor(x => x.Get()), + VersioningHarness.ChainFor(x => x.Get()), + V1AndV2 + ); + + // URL-segment versioning: the version is a {version:apiVersion} route parameter, not template text, + // so both siblings normalize to the same grouping key. The bridge never rewrites routes. + [Fact] + public void shared_url_segment_template_forms_one_group() => + AssertSharedVersionSpace( + VersioningHarness.ChainFor(x => x.Get()), + VersioningHarness.ChainFor(x => x.Get()), + V1AndV2 + ); + + [Fact] + public void same_route_methods_on_one_class_form_one_group() => + AssertSharedVersionSpace( + VersioningHarness.ChainFor(x => x.Get()), + VersioningHarness.ChainFor(x => x.Post()), + V1AndV2 + ); + + // Grouping is by route, not declaring type: methods at different routes form separate groups. + [Fact] + public void different_route_methods_on_one_class_form_separate_groups() + { + var a = VersioningHarness.ChainFor(x => x.A()); + var b = VersioningHarness.ChainFor(x => x.B()); + + VersioningHarness.Apply(a, b); + + a.GroupModel().SupportedApiVersions.ShouldBe(new[] { new ApiVersion(1, 0) }); + b.GroupModel().SupportedApiVersions.ShouldBe(new[] { new ApiVersion(2, 0) }); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/HostlessAspVersioningContext.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/HostlessAspVersioningContext.cs new file mode 100644 index 000000000..b6dfe90ed --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/HostlessAspVersioningContext.cs @@ -0,0 +1,79 @@ +using System.Linq.Expressions; +using System.Text.Json; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Model; +using JasperFx.CodeGeneration.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Wolverine.Http.AspVersioning.Tests; + +public class HostlessAspVersioningFixture : IAsyncLifetime +{ + public HttpGraph Parent { get; private set; } = null!; + + public Task InitializeAsync() + { + Parent = BuildParent(); + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + Parent = null!; + return Task.CompletedTask; + } + + /// Build a host-free chain whose parent container can satisfy the Asp.Versioning finalizer. + public HttpChain ChainFor(Expression> expression) => + HttpChain.ChainFor(expression, Parent); + + /// Run the policy under test over . + public void Apply(params HttpChain[] chains) => + new AspVersioningPolicy().Apply(chains, new GenerationRules(), Parent.Container); + + /// + /// Run a single instance over + /// times. Used to assert the policy is idempotent — a re-run must not + /// attach a second ApiVersionSet or duplicate providers. + /// + public void ApplyRepeated(int times, params HttpChain[] chains) + { + var policy = new AspVersioningPolicy(); + for (var i = 0; i < times; i++) + policy.Apply(chains, new GenerationRules(), Parent.Container); + } + + private static HttpGraph BuildParent() + { + var registry = new ServiceCollection(); + registry.AddLogging(); + registry.AddApiVersioning(); + registry.AddSingleton(); + registry.AddTransient(); + registry.AddSingleton(); + registry.AddSingleton(registry); + // AspVersioningPolicy.validateConfiguration reads WolverineHttpOptions to detect a native-versioning + // conflict; a real MapWolverineEndpoints host always registers it, so the host-free parent must too + // (a default instance has ApiVersioning == null, i.e. no native versioning — the valid state here). + registry.AddSingleton(new WolverineHttpOptions()); + + var provider = registry.BuildServiceProvider(); + return new HttpGraph( + new WolverineOptions(), + provider.GetRequiredService() + ); + } +} + +[CollectionDefinition("hostless")] +public class HostlessAspVersioningCollection : ICollectionFixture; + +[Collection("hostless")] +public abstract class HostlessAspVersioningContext +{ + protected HostlessAspVersioningContext(HostlessAspVersioningFixture fixture) => + VersioningHarness = fixture; + + protected HostlessAspVersioningFixture VersioningHarness { get; } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/HttpChainExtensions.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/HttpChainExtensions.cs new file mode 100644 index 000000000..7aa8619ac --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/HttpChainExtensions.cs @@ -0,0 +1,50 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; + +namespace Wolverine.Http.AspVersioning.Tests; + +internal static class HttpChainExtensions +{ + /// Materialize the endpoint and read the attached (null when none). + public static ApiVersionMetadata? VersionMetadata(this HttpChain chain) => + chain.BuildEndpoint(RouteWarmup.Lazy).Metadata.GetMetadata(); + + /// How many instances are attached — used to assert "exactly once". + public static int VersionMetadataCount(this HttpChain chain) => + chain + .BuildEndpoint(RouteWarmup.Lazy) + .Metadata.GetOrderedMetadata() + .Count; + + /// + /// The instances on the built endpoint. A named ApiVersionSet + /// would inject one (TagsAttribute(setName)); the bridge builds an unnamed set, and Wolverine + /// adds no of its own, so this must stay empty. + /// + public static IReadOnlyList TagMetadata(this HttpChain chain) => + chain.BuildEndpoint(RouteWarmup.Lazy).Metadata.GetOrderedMetadata(); + + /// All metadata of type on the built endpoint. + public static IReadOnlyList MetadataOf(this HttpChain chain) + where T : class => chain.BuildEndpoint(RouteWarmup.Lazy).Metadata.GetOrderedMetadata(); + + /// + /// The group-wide (set) — the aggregate version space shared by + /// every chain in the route group. First component of . + /// + public static ApiVersionModel GroupModel(this HttpChain chain) + { + var (api, _) = chain.VersionMetadata()!; + return api; + } + + /// + /// The per-endpoint — the versions this specific chain declares, + /// implements, supports, and deprecates. Second component of . + /// + public static ApiVersionModel EndpointModel(this HttpChain chain) + { + var (_, endpoint) = chain.VersionMetadata()!; + return endpoint; + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/Parallelization.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/Parallelization.cs new file mode 100644 index 000000000..9884c6041 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/Parallelization.cs @@ -0,0 +1 @@ +[assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly)] diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/AspVersioningIntegrationContext.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/AspVersioningIntegrationContext.cs new file mode 100644 index 000000000..b56277041 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/AspVersioningIntegrationContext.cs @@ -0,0 +1,155 @@ +using Alba; +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Wolverine.Http.AspVersioning.Tests.Parity; + +/// +/// One shared Alba host for every integration tier, mirroring the shared-fixture convention in +/// Wolverine.Http.Tests (AppFixture + IntegrationContext + +/// [Collection("integration")]). +/// +public class AspVersioningAppFixture : ParityFixture +{ + public override Task InitializeAsync() => + BuildHost( + services => + { + services + .AddApiVersioning(options => + { + options.ReportApiVersions = true; + + options.ApiVersionReader = ApiVersionReader.Combine( + new QueryStringApiVersionReader(), + new HeaderApiVersionReader("api-version"), + new MediaTypeApiVersionReader() + ); + + // Version-keyed sunset policy (null name = unnamed set, which is what the bridge + // builds and what the native twin uses) so the sunset twins can assert Sunset/Link + // header parity. 5.0 is unique to the sunset twins, so no other endpoint is affected. + var sunset = options.Policies.Sunset(null, new ApiVersion(5, 0)); + sunset.SetEffectiveDate( + new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero) + ); + sunset + .Link(new Uri("https://example.com/sunset")) + .Title("Sunset") + .Type("text/html"); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; // "v1", "v2", "v4", ... + options.SubstituteApiVersionInUrl = false; + }); + + services.AddEndpointsApiExplorer(); + + services.AddSwaggerGen(options => + { + options.DocInclusionPredicate((docName, api) => api.GroupName == docName); + // The supported-wins twins register two endpoints at the SAME version (8.0) on + // GET /wolverine/conflict, so that version's document has a duplicate path+method. + // This is Swashbuckle's prescribed resolver for it. + options.ResolveConflictingActions(descriptions => descriptions.First()); + options.OperationFilter(); + }); + services.ConfigureOptions(); + }, + app => + { + app.UseSwagger(); + + // Native minimal-API twins for the parity tier — registered with their own ApiVersionSets, + // exactly as a vanilla Asp.Versioning consumer would (see ParityEndpoints). + ParityEndpoints.MapNative(app); + } + ); +} + +[CollectionDefinition("integration")] +public class AspVersioningIntegrationCollection : ICollectionFixture; + +/// +/// Base class for the integration tiers. Hands tests the shared host and the Scenario / +/// service-resolution helpers, matching Wolverine.Http.Tests.IntegrationContext. +/// +[Collection("integration")] +public abstract class AspVersioningIntegrationContext +{ + protected AspVersioningIntegrationContext(AspVersioningAppFixture fixture) => + Host = fixture.Host; + + protected IAlbaHost Host { get; } + + protected Task Scenario(Action configure) => + Host.Scenario(configure); + + protected T Service() + where T : notnull => Host.Services.GetRequiredService(); + + /// The API-explorer group name Asp.Versioning assigns to . + protected string GroupNameFor(ApiVersion version) => + Service() + .ApiVersionDescriptions.Single(d => d.ApiVersion == version) + .GroupName; +} + +/// +/// Canonical Asp.Versioning + Swashbuckle recipe: register one Swagger document per discovered API +/// version, named by the API explorer's group name. +/// +internal sealed class ConfigureVersionedSwaggerDocs : IConfigureOptions +{ + private readonly IApiVersionDescriptionProvider _provider; + + public ConfigureVersionedSwaggerDocs(IApiVersionDescriptionProvider provider) => + _provider = provider; + + public void Configure(SwaggerGenOptions options) + { + foreach (var description in _provider.ApiVersionDescriptions) + { + options.SwaggerDoc( + description.GroupName, + new OpenApiInfo + { + Title = "AspVersioning Integration Tests", + Version = description.ApiVersion.ToString(), + } + ); + } + } +} + +/// Marks operations belonging to a deprecated API version as deprecated in the document. +internal sealed class DeprecatedVersionOperationFilter : IOperationFilter +{ + private readonly IApiVersionDescriptionProvider _provider; + + public DeprecatedVersionOperationFilter(IApiVersionDescriptionProvider provider) => + _provider = provider; + + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var groupName = context.ApiDescription.GroupName; + if (groupName is null) + { + return; + } + + var description = _provider.ApiVersionDescriptions.FirstOrDefault(d => + d.GroupName == groupName + ); + if (description is { IsDeprecated: true }) + { + operation.Deprecated = true; + } + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/DedicatedHostParityTests.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/DedicatedHostParityTests.cs new file mode 100644 index 000000000..cba9277c5 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/DedicatedHostParityTests.cs @@ -0,0 +1,182 @@ +using Alba; +using Asp.Versioning; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace Wolverine.Http.AspVersioning.Tests.Parity; + +// --------------------------------------------------------------------------------------------------- +// Parity for two Asp.Versioning knobs that are GLOBAL ApiVersioningOptions settings and therefore need +// their own hosts (they change behavior app-wide): a media-type-ONLY reader (whose miss path is +// 415/406, not 400) and AssumeDefaultVersionWhenUnspecified. Each host registers a native /native/* twin +// beside the Wolverine /wolverine/* twin and asserts identical behavior. +// --------------------------------------------------------------------------------------------------- + +internal static class ParityProbe +{ + public static async Task<(int Status, string Body)> Get( + IAlbaHost host, + Action configure + ) + { + var result = await host.Scenario(x => + { + configure(x); + x.IgnoreStatusCode(); + }); + return (result.Context.Response.StatusCode, (await result.ReadAsTextAsync()).Trim()); + } +} + +// ---------- Media-type-only host (miss path is 415/406) ---------- + +public class MediaTypeOnlyParityFixture : ParityFixture +{ + public override Task InitializeAsync() => + BuildHost( + services => + services.AddApiVersioning(options => + { + options.ReportApiVersions = true; + options.ApiVersionReader = new MediaTypeApiVersionReader(); // media-type ONLY → miss = 415/406 + }), + app => + ParityEndpoints.MapTwoVersionRoute(app, "/native/mtonly", "mtonly-v1", "mtonly-v2") + ); +} + +[CollectionDefinition("mediatype-only")] +public class MediaTypeOnlyParityCollection : ICollectionFixture; + +public class WolverineMediaTypeOnlyV1Endpoint +{ + [WolverineGet("/wolverine/mtonly")] + [ApiVersion("1.0")] + public string Get() => "mtonly-v1"; +} + +public class WolverineMediaTypeOnlyV2Endpoint +{ + [WolverineGet("/wolverine/mtonly")] + [ApiVersion("2.0")] + public string Get() => "mtonly-v2"; +} + +[Collection("mediatype-only")] +public class MediaTypeOnlyParityTests +{ + private readonly IAlbaHost _host; + + public MediaTypeOnlyParityTests(MediaTypeOnlyParityFixture fixture) => _host = fixture.Host; + + // A valid media-type version routes identically on both twins. + [Fact] + public async Task media_type_version_routes_identically() + { + var native = await ParityProbe.Get( + _host, + x => + { + x.Get.Url("/native/mtonly"); + x.WithRequestHeader("Accept", "application/json;v=1.0"); + } + ); + var wolverine = await ParityProbe.Get( + _host, + x => + { + x.Get.Url("/wolverine/mtonly"); + x.WithRequestHeader("Accept", "application/json;v=1.0"); + } + ); + + native.Status.ShouldBe(200); + wolverine.Status.ShouldBe(native.Status); + wolverine.Body.ShouldBe(native.Body); + wolverine.Body.ShouldBe("mtonly-v1"); + } + + // Under a media-type-ONLY reader, an unsupported media-type version takes Asp.Versioning's rejection + // path. The assertion is parity: whatever status the library chooses (400/406/415), the Wolverine twin + // returns the same one. + [Fact] + public async Task media_type_unsupported_version_error_matches_native() + { + var native = await ParityProbe.Get( + _host, + x => + { + x.Get.Url("/native/mtonly"); + x.WithRequestHeader("Accept", "application/json;v=9.0"); + } + ); + var wolverine = await ParityProbe.Get( + _host, + x => + { + x.Get.Url("/wolverine/mtonly"); + x.WithRequestHeader("Accept", "application/json;v=9.0"); + } + ); + + wolverine.Status.ShouldBe(native.Status); + native.Status.ShouldBeGreaterThanOrEqualTo(400); + } +} + +// ---------- AssumeDefaultVersionWhenUnspecified host ---------- + +public class AssumeDefaultParityFixture : ParityFixture +{ + public override Task InitializeAsync() => + BuildHost( + services => + services.AddApiVersioning(options => + { + options.ReportApiVersions = true; + options.AssumeDefaultVersionWhenUnspecified = true; + options.DefaultApiVersion = new ApiVersion(1, 0); + }), + app => + ParityEndpoints.MapTwoVersionRoute(app, "/native/assumedefault", "ad-v1", "ad-v2") + ); +} + +[CollectionDefinition("assume-default")] +public class AssumeDefaultParityCollection : ICollectionFixture; + +public class WolverineAssumeDefaultV1Endpoint +{ + [WolverineGet("/wolverine/assumedefault")] + [ApiVersion("1.0")] + public string Get() => "ad-v1"; +} + +public class WolverineAssumeDefaultV2Endpoint +{ + [WolverineGet("/wolverine/assumedefault")] + [ApiVersion("2.0")] + public string Get() => "ad-v2"; +} + +[Collection("assume-default")] +public class AssumeDefaultParityTests +{ + private readonly IAlbaHost _host; + + public AssumeDefaultParityTests(AssumeDefaultParityFixture fixture) => _host = fixture.Host; + + // With AssumeDefaultVersionWhenUnspecified = true, a request specifying no version routes to the + // default (1.0) on both twins (rather than the 400 the default config returns). + [Fact] + public async Task unspecified_version_routes_to_default_on_both() + { + var native = await ParityProbe.Get(_host, x => x.Get.Url("/native/assumedefault")); + var wolverine = await ParityProbe.Get(_host, x => x.Get.Url("/wolverine/assumedefault")); + + native.Status.ShouldBe(200); + wolverine.Status.ShouldBe(native.Status); + wolverine.Body.ShouldBe(native.Body); + wolverine.Body.ShouldBe("ad-v1"); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/NativeParityTests.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/NativeParityTests.cs new file mode 100644 index 000000000..071f2cef7 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/NativeParityTests.cs @@ -0,0 +1,345 @@ +using Alba; +using Shouldly; + +namespace Wolverine.Http.AspVersioning.Tests.Parity; + +/// +/// The focused parity tier: for each logical resource a vanilla Asp.Versioning minimal-API endpoint +/// (/native/*) and its Wolverine twin (/wolverine/*) are registered against the same host +/// (see ). Each test hits both and asserts the surfaces the bridge owns — +/// routing/body, version-report headers, error status — are identical. +/// +/// Content-Type is intentionally not compared: the version reader is orthogonal to the bridge, and the +/// base Content-Type differs by charset between a native Results.Text and a Wolverine string +/// result independently of versioning. +/// +public class NativeParityTests : AspVersioningIntegrationContext +{ + public NativeParityTests(AspVersioningAppFixture fixture) + : base(fixture) { } + + private sealed record Captured( + int Status, + string Body, + string Supported, + string Deprecated, + string Sunset, + string Link, + string ContentType + ); + + private Task Get(string url) => Capture(x => x.Get.Url(url), assertOk: true); + + // Error-path variant: does not assert 200, so the twins can be compared on responses that fail. + private Task GetRaw(string url) => Capture(x => x.Get.Url(url), assertOk: false); + + private async Task Capture(Action configure, bool assertOk) + { + var result = await Scenario(x => + { + configure(x); + if (assertOk) + { + x.StatusCodeShouldBeOk(); + } + else + { + x.IgnoreStatusCode(); + } + }); + + var headers = result.Context.Response.Headers; + return new Captured( + result.Context.Response.StatusCode, + (await result.ReadAsTextAsync()).Trim(), + headers["api-supported-versions"].ToString(), + headers["api-deprecated-versions"].ToString(), + headers["Sunset"].ToString(), + headers["Link"].ToString(), + result.Context.Response.ContentType ?? "" + ); + } + + // The individual version tokens of a comma-delimited version header. Membership and equality checks + // run on these rather than the raw string, so a substring can't satisfy them (e.g. "11.0" must not + // count as containing "1.0"); equality is order-insensitive via ShouldBe(..., ignoreOrder: true). + private static string[] Tokens(string headerValue) => + headerValue.Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ); + + // A v1 request reaches the v1 endpoint and a v2 request reaches v2, identically to native — the + // foundational proof that Asp.Versioning's matcher honors the metadata the bridge attaches. + [Theory] + [InlineData("1.0", "orders-v1")] + [InlineData("2.0", "orders-v2")] + public async Task requested_version_routes_and_body_matches_native( + string version, + string expected + ) + { + var native = await Get($"/native/orders?api-version={version}"); + var wolverine = await Get($"/wolverine/orders?api-version={version}"); + + wolverine.Status.ShouldBe(native.Status); + wolverine.Body.ShouldBe(native.Body); + wolverine.Body.ShouldBe(expected); + } + + // The api-supported-versions / api-deprecated-versions headers are identical between the twins. + [Fact] + public async Task version_report_headers_match_native() + { + var native = await Get("/native/orders?api-version=2.0"); + var wolverine = await Get("/wolverine/orders?api-version=2.0"); + + Tokens(wolverine.Supported).ShouldBe(Tokens(native.Supported), ignoreOrder: true); + Tokens(wolverine.Deprecated).ShouldBe(Tokens(native.Deprecated), ignoreOrder: true); + } + + // 1.0 is reported deprecated and excluded from supported on both twins (supported-wins). + [Fact] + public async Task deprecated_version_reported_identically() + { + var native = await Get("/native/orders?api-version=1.0"); + var wolverine = await Get("/wolverine/orders?api-version=1.0"); + + wolverine.Body.ShouldBe(native.Body); + Tokens(wolverine.Supported).ShouldBe(Tokens(native.Supported), ignoreOrder: true); + Tokens(wolverine.Deprecated).ShouldBe(Tokens(native.Deprecated), ignoreOrder: true); + + // Substantive pin: 1.0 deprecated, 2.0 supported, on both. + Tokens(wolverine.Deprecated).ShouldContain("1.0"); + Tokens(native.Deprecated).ShouldContain("1.0"); + Tokens(wolverine.Supported).ShouldContain("2.0"); + Tokens(native.Supported).ShouldContain("2.0"); + Tokens(wolverine.Supported).ShouldNotContain("1.0"); + Tokens(native.Supported).ShouldNotContain("1.0"); + } + + // A version-neutral route is reachable and behaves identically on both twins. + [Fact] + public async Task version_neutral_route_behaves_identically() + { + var native = await Get("/native/health"); + var wolverine = await Get("/wolverine/health"); + + wolverine.Status.ShouldBe(native.Status); + wolverine.Body.ShouldBe(native.Body); + wolverine.Body.ShouldBe("health-ok"); + } + + // An unversioned route serves normally and emits no version headers on both. + [Fact] + public async Task unversioned_route_untouched_on_both() + { + var native = await Get("/native/ping"); + var wolverine = await Get("/wolverine/ping"); + + wolverine.Status.ShouldBe(native.Status); + wolverine.Body.ShouldBe(native.Body); + wolverine.Supported.ShouldBeNullOrEmpty(); + native.Supported.ShouldBeNullOrEmpty(); + } + + // An unsupported version yields the same error status and api-supported-versions header on both. + // Bodies are ProblemDetails with a per-request traceId, so status + header are compared, not the body. + [Fact] + public async Task unsupported_version_error_matches_native() + { + var native = await GetRaw("/native/orders?api-version=9.0"); + var wolverine = await GetRaw("/wolverine/orders?api-version=9.0"); + + wolverine.Status.ShouldBe(native.Status); + wolverine.Status.ShouldBeGreaterThanOrEqualTo(400); + Tokens(wolverine.Supported).ShouldBe(Tokens(native.Supported), ignoreOrder: true); + } + + // With AssumeDefaultVersionWhenUnspecified = false (the default), a request with no version behaves + // identically on both twins (vanilla Asp.Versioning returns 400). + [Fact] + public async Task missing_version_behaves_identically_to_native() + { + var native = await GetRaw("/native/orders"); + var wolverine = await GetRaw("/wolverine/orders"); + + // Substantive pin (not just twin-equality): the default config rejects a versionless request. + native.Status.ShouldBeGreaterThanOrEqualTo(400); + wolverine.Status.ShouldBe(native.Status); + Tokens(wolverine.Supported).ShouldBe(Tokens(native.Supported), ignoreOrder: true); + } + + // The `api-version` request-header reader routes and reports identically. + [Fact] + public async Task header_reader_routes_and_reports_identically() + { + var native = await Capture( + x => + { + x.Get.Url("/native/orders"); + x.WithRequestHeader("api-version", "2.0"); + }, + assertOk: true + ); + var wolverine = await Capture( + x => + { + x.Get.Url("/wolverine/orders"); + x.WithRequestHeader("api-version", "2.0"); + }, + assertOk: true + ); + + wolverine.Body.ShouldBe(native.Body); + wolverine.Body.ShouldBe(ParityPayloads.OrdersV2); + Tokens(wolverine.Supported).ShouldBe(Tokens(native.Supported), ignoreOrder: true); + } + + // The media-type reader (an `Accept` `v=` parameter) routes identically and echoes the negotiated + // version into the response Content-Type on both. Only the version echo is asserted, not the full + // Content-Type: the base value differs by charset (native `text/plain; charset=utf-8` vs Wolverine + // `text/plain`), which is unrelated to versioning. + [Fact] + public async Task media_type_reader_routes_and_echoes_version_identically() + { + var native = await Capture( + x => + { + x.Get.Url("/native/orders"); + x.WithRequestHeader("Accept", "application/json;v=2.0"); + }, + assertOk: true + ); + var wolverine = await Capture( + x => + { + x.Get.Url("/wolverine/orders"); + x.WithRequestHeader("Accept", "application/json;v=2.0"); + }, + assertOk: true + ); + + wolverine.Body.ShouldBe(native.Body); + wolverine.Body.ShouldBe(ParityPayloads.OrdersV2); + wolverine.ContentType.ShouldContain("v=2.0"); + native.ContentType.ShouldContain("v=2.0"); + } + + // A version-keyed sunset policy emits identical Sunset + Link headers on both. The bridge builds an + // unnamed set and the native twin uses one too, so a null-named/version-keyed policy applies to both. + [Fact] + public async Task sunset_policy_headers_match_native() + { + var native = await Get("/native/sunset?api-version=5.0"); + var wolverine = await Get("/wolverine/sunset?api-version=5.0"); + + wolverine.Sunset.ShouldBe(native.Sunset); + wolverine.Sunset.ShouldNotBeNullOrEmpty(); + wolverine.Link.ShouldBe(native.Link); + wolverine.Link.ShouldContain("https://example.com/sunset"); + } + + // An advertised (implemented-by-no-one) version folds into api-supported-versions identically on both. + [Fact] + public async Task advertised_version_reported_identically() + { + var native = await Get("/native/advertised?api-version=3.0"); + var wolverine = await Get("/wolverine/advertised?api-version=3.0"); + + Tokens(wolverine.Supported).ShouldBe(Tokens(native.Supported), ignoreOrder: true); + Tokens(wolverine.Supported).ShouldContain("3.0"); + Tokens(wolverine.Supported).ShouldContain("3.9"); + } + + // Two distinct versions (query + header) is ambiguous and yields the same error status on both. + [Fact] + public async Task ambiguous_version_error_matches_native() + { + var native = await Capture( + x => + { + x.Get.Url("/native/orders?api-version=1.0"); + x.WithRequestHeader("api-version", "2.0"); + }, + assertOk: false + ); + var wolverine = await Capture( + x => + { + x.Get.Url("/wolverine/orders?api-version=1.0"); + x.WithRequestHeader("api-version", "2.0"); + }, + assertOk: false + ); + + wolverine.Status.ShouldBe(native.Status); + wolverine.Status.ShouldBeGreaterThanOrEqualTo(400); + } + + // A malformed version string yields the same error status on both. + [Fact] + public async Task malformed_version_error_matches_native() + { + var native = await GetRaw("/native/orders?api-version=not-a-version"); + var wolverine = await GetRaw("/wolverine/orders?api-version=not-a-version"); + + wolverine.Status.ShouldBe(native.Status); + wolverine.Status.ShouldBeGreaterThanOrEqualTo(400); + } + + // [MapToApiVersion] authoring parity: a class-level [ApiVersion] declaring {1.0, 2.0} with per-method + // [MapToApiVersion] routes identically to a native shared-set + per-endpoint MapToApiVersion, and + // reports the same supported versions. + [Theory] + [InlineData("1.0", "mapto-v1")] + [InlineData("2.0", "mapto-v2")] + public async Task map_to_api_version_routes_and_body_matches_native( + string version, + string expected + ) + { + var native = await Get($"/native/mapto?api-version={version}"); + var wolverine = await Get($"/wolverine/mapto?api-version={version}"); + + wolverine.Status.ShouldBe(native.Status); + wolverine.Body.ShouldBe(native.Body); + wolverine.Body.ShouldBe(expected); + Tokens(wolverine.Supported).ShouldBe(Tokens(native.Supported), ignoreOrder: true); + } + + // /wolverine/conflict declares 8.0 supported on one sibling and 8.0 deprecated on another; + // supported-wins folds 8.0 into api-supported-versions and out of api-deprecated-versions. The native + // twin declares the resolved state directly (8.0 + 8.1 supported), so the version headers must match. + // 8.1 is the unambiguous probe; the headers describe the whole shared set regardless. + [Fact] + public async Task supported_wins_reported_identically_to_native() + { + var native = await Get("/native/conflict?api-version=8.1"); + var wolverine = await Get("/wolverine/conflict?api-version=8.1"); + + wolverine.Body.ShouldBe(native.Body); + Tokens(wolverine.Supported).ShouldBe(Tokens(native.Supported), ignoreOrder: true); + Tokens(wolverine.Deprecated).ShouldBe(Tokens(native.Deprecated), ignoreOrder: true); + + // Substantive pin: 8.0 is reported supported, never deprecated, on both. + Tokens(wolverine.Supported).ShouldContain("8.0"); + Tokens(native.Supported).ShouldContain("8.0"); + Tokens(wolverine.Deprecated).ShouldNotContain("8.0"); + Tokens(native.Deprecated).ShouldNotContain("8.0"); + } + + // The matcher populates IApiVersioningFeature for a Wolverine endpoint identically to a native one: + // both /native/feature and /wolverine/feature (each serving {1.0, 2.0}) echo back the resolved version. + [Theory] + [InlineData("1.0")] + [InlineData("2.0")] + public async Task resolved_version_feature_matches_native(string version) + { + var native = await Get($"/native/feature?api-version={version}"); + var wolverine = await Get($"/wolverine/feature?api-version={version}"); + + wolverine.Body.ShouldBe(native.Body); + wolverine.Body.ShouldBe(version); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/OpenApiParityTests.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/OpenApiParityTests.cs new file mode 100644 index 000000000..3c47ef155 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/OpenApiParityTests.cs @@ -0,0 +1,228 @@ +using System.Text.Json; +using Alba; +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Shouldly; + +namespace Wolverine.Http.AspVersioning.Tests.Parity; + +/// +/// OpenAPI / ApiExplorer parity: for each logical resource a vanilla Asp.Versioning minimal-API endpoint +/// (/native/*) and its Wolverine twin (/wolverine/*) are registered against the same host, +/// and each test asserts the version-partitioning facts the bridge determines — which paths land in which +/// per-version document, deprecation flags, path substitution — are identical between the two. The package +/// adds no OpenAPI-specific code: the attached flows through Wolverine's +/// ApiExplorer descriptions into Asp.Versioning's VersionedApiDescriptionProvider. +/// +/// Assertions are route-scoped (filter ApiDescription by RelativePath) so they are unaffected +/// by the assembly-wide version space every discovered endpoint shares. +/// +public class OpenApiParityTests : AspVersioningIntegrationContext +{ + public OpenApiParityTests(AspVersioningAppFixture fixture) + : base(fixture) { } + + // The set of per-version document group names in which a route (by its ApiExplorer RelativePath, no + // leading slash) appears. + private HashSet GroupsFor(string relativePath) => + Service() + .ApiDescriptionGroups.Items.Where(g => + g.Items.Any(d => d.RelativePath == relativePath && d.HttpMethod == "GET") + ) + .Select(g => g.GroupName) + .ToHashSet(); + + private bool RouteInGroup(string relativePath, string? groupName) => + GroupsFor(relativePath).Contains(groupName); + + // The described parameter names for a route within a given version's group. + private List ParametersFor(string relativePath, ApiVersion version) + { + var groupName = GroupNameFor(version); + return Service() + .ApiDescriptionGroups.Items.Single(g => g.GroupName == groupName) + .Items.First(d => d.RelativePath == relativePath && d.HttpMethod == "GET") + .ParameterDescriptions.Select(p => p.Name) + .ToList(); + } + + // The "get" operation JsonElement for a route in a given version's rendered Swashbuckle document. The + // JsonDocument is intentionally not disposed: the returned JsonElement roots it, and disposing would + // invalidate the element. + private async Task GetOperation(ApiVersion version, string path) + { + var groupName = GroupNameFor(version); + var result = await Scenario(x => + { + x.Get.Url($"/swagger/{groupName}/swagger.json"); + x.StatusCodeShouldBeOk(); + }); + + var doc = JsonDocument.Parse(await result.ReadAsTextAsync()); + return doc.RootElement.GetProperty("paths").GetProperty(path).GetProperty("get"); + } + + // Versioned Wolverine endpoints reach the versioned ApiExplorer and partition identically to native: + // the orders twins appear in the same set of per-version document groups, which is exactly {v1, v2}. + [Fact] + public void openapi_version_groups_match_native() + { + var nativeGroups = GroupsFor("native/orders"); + var wolverineGroups = GroupsFor("wolverine/orders"); + + nativeGroups.ShouldNotBeEmpty(); + wolverineGroups.ShouldBe(nativeGroups, ignoreOrder: true); + + // Completeness: the orders twins live in exactly their resolved version groups {v1, v2}. + var expected = new HashSet + { + GroupNameFor(new ApiVersion(1, 0)), + GroupNameFor(new ApiVersion(2, 0)), + }; + nativeGroups.ShouldBe(expected, ignoreOrder: true); + } + + // Per-version exclusivity: an endpoint mapped only to v1 appears in the v1 group and NOT the v2 group, + // identically for the native and Wolverine twins. + [Fact] + public void v1only_group_membership_matches_native() + { + var v1 = GroupNameFor(new ApiVersion(1, 0)); + var v2 = GroupNameFor(new ApiVersion(2, 0)); + + GroupsFor("wolverine/v1only").ShouldBe(GroupsFor("native/v1only"), ignoreOrder: true); + + GroupsFor("native/v1only").ShouldContain(v1); + GroupsFor("native/v1only").ShouldNotContain(v2); + GroupsFor("wolverine/v1only").ShouldContain(v1); + GroupsFor("wolverine/v1only").ShouldNotContain(v2); + } + + // A version-neutral endpoint appears in EVERY version's document group, identically for both twins. + [Fact] + public void neutral_endpoint_in_every_group_matches_native() + { + var provider = Service(); + + foreach (var description in provider.ApiVersionDescriptions) + { + RouteInGroup("native/health", description.GroupName) + .ShouldBeTrue( + $"native neutral endpoint missing from document '{description.GroupName}'" + ); + RouteInGroup("wolverine/health", description.GroupName) + .ShouldBeTrue( + $"wolverine neutral endpoint missing from document '{description.GroupName}'" + ); + } + + // And the twins occupy exactly the same groups. + GroupsFor("wolverine/health").ShouldBe(GroupsFor("native/health"), ignoreOrder: true); + } + + // A deprecated version still produces a document and its operation is flagged deprecated, identically + // on both twins; a supported version's operation is not flagged. Uses the uniquely-deprecated 10.0 + // twins and the supported 2.0 orders twins. (Swashbuckle only emits `deprecated` when true.) + [Fact] + public async Task deprecated_operation_marked_matches_native() + { + Service() + .ApiVersionDescriptions.Single(d => d.ApiVersion == new ApiVersion(10, 0)) + .IsDeprecated.ShouldBeTrue(); + + var nativeDeprecated = await GetOperation(new ApiVersion(10, 0), "/native/deprecated"); + var wolverineDeprecated = await GetOperation( + new ApiVersion(10, 0), + "/wolverine/deprecated" + ); + nativeDeprecated.GetProperty("deprecated").GetBoolean().ShouldBeTrue(); + wolverineDeprecated.GetProperty("deprecated").GetBoolean().ShouldBeTrue(); + + var nativeCurrent = await GetOperation(new ApiVersion(2, 0), "/native/orders"); + var wolverineCurrent = await GetOperation(new ApiVersion(2, 0), "/wolverine/orders"); + nativeCurrent.TryGetProperty("deprecated", out _).ShouldBeFalse(); + wolverineCurrent.TryGetProperty("deprecated", out _).ShouldBeFalse(); + } + + // The supported-wins version (8.0) folds to supported, so its version description is NOT deprecated and + // its document operation is not marked deprecated — the same fold the header parity proves, verified in + // the document, on both twins. + [Fact] + public async Task supported_wins_document_not_deprecated_matches_native() + { + Service() + .ApiVersionDescriptions.Single(d => d.ApiVersion == new ApiVersion(8, 0)) + .IsDeprecated.ShouldBeFalse(); + + var native = await GetOperation(new ApiVersion(8, 0), "/native/conflict"); + var wolverine = await GetOperation(new ApiVersion(8, 0), "/wolverine/conflict"); + native.TryGetProperty("deprecated", out _).ShouldBeFalse(); + wolverine.TryGetProperty("deprecated", out _).ShouldBeFalse(); + } + + // An advertised-only version (implemented by no endpoint) yields NO version document: 3.9 is advertised + // by the advertised twins but served by no one, so Asp.Versioning's ApiExplorer bucketizer drops it. + // Both twins appear only in the v3 group they actually implement. + [Fact] + public void advertised_only_version_yields_no_document_matches_native() + { + Service() + .ApiVersionDescriptions.Select(d => d.ApiVersion) + .ShouldNotContain(new ApiVersion(3, 9)); + + var v3 = GroupNameFor(new ApiVersion(3, 0)); + GroupsFor("wolverine/advertised") + .ShouldBe(GroupsFor("native/advertised"), ignoreOrder: true); + GroupsFor("native/advertised").ShouldBe(new HashSet { v3 }, ignoreOrder: true); + } + + // The bridge does no native-style route rewriting: a query-versioned route keeps its literal path in the + // document (no auto "/v1/" prefix), identically for both twins. + [Fact] + public async Task query_versioned_route_path_is_literal_matches_native() + { + var groupName = GroupNameFor(new ApiVersion(1, 0)); + var result = await Scenario(x => + { + x.Get.Url($"/swagger/{groupName}/swagger.json"); + x.StatusCodeShouldBeOk(); + }); + + using var doc = JsonDocument.Parse(await result.ReadAsTextAsync()); + var paths = doc.RootElement.GetProperty("paths"); + + paths.TryGetProperty("/native/orders", out _).ShouldBeTrue(); + paths.TryGetProperty("/wolverine/orders", out _).ShouldBeTrue(); + paths.TryGetProperty("/v1/native/orders", out _).ShouldBeFalse(); + paths.TryGetProperty("/v1/wolverine/orders", out _).ShouldBeFalse(); + } + + // The version reader's parameter ("api-version", the default query-string reader) is described on the + // versioned operation for both twins. + [Fact] + public void versioned_operation_advertises_api_version_parameter_matches_native() + { + ParametersFor("native/orders", new ApiVersion(1, 0)).ShouldContain("api-version"); + ParametersFor("wolverine/orders", new ApiVersion(1, 0)).ShouldContain("api-version"); + } + + // End-to-end through Swashbuckle: each version document renders and contains BOTH twins' shared orders + // route — the attached metadata flows all the way to a concrete document, identically for both. + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task rendered_document_contains_both_twins(int major) + { + var groupName = GroupNameFor(new ApiVersion(major, 0)); + var result = await Scenario(x => + { + x.Get.Url($"/swagger/{groupName}/swagger.json"); + x.StatusCodeShouldBeOk(); + }); + + var doc = await result.ReadAsTextAsync(); + doc.ShouldContain("/native/orders"); + doc.ShouldContain("/wolverine/orders"); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/ParityEndpoints.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/ParityEndpoints.cs new file mode 100644 index 000000000..577d0ae20 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/ParityEndpoints.cs @@ -0,0 +1,303 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Wolverine.Http.AspVersioning.Tests.Parity; + +// --------------------------------------------------------------------------------------------------- +// Parity twins. Each logical resource is registered twice against the shared host: once as a vanilla +// Asp.Versioning minimal-API endpoint under /native/*, and once as a Wolverine endpoint under +// /wolverine/* carrying the equivalent [ApiVersion] attributes. Bodies are drawn from ParityPayloads so +// a native endpoint and its Wolverine twin return byte-identical content for the same version. +// +// Reserved API versions (assembly-wide). Every parity host includes THIS whole assembly, so all +// endpoints share one version space per route and one global IApiVersionDescriptionProvider. Tests that +// query that provider by version (OpenApiParityTests' `.Single(d => d.ApiVersion == X)`) need the +// version's *global* aggregate state to be unambiguous, so each number below is reserved to a single +// scenario. Serving one of these from a new endpoint would change its global state and break those +// assertions (often as a confusing `.Single()` throw). Pick a fresh number for new scenarios. +// +// 1.0, 2.0 current/previous pair, reused across routes (provider assertions on it are route-scoped) +// 3.0 advertised twins (served) +// 3.9 advertised-ONLY (advertised by the advertised twins, served by no one → yields no document) +// 5.0 sunset twins (drives the version-keyed Sunset/Link policy) +// 8.0, 8.1 conflict twins (supported-wins reconciliation) +// 10.0 deprecated twins (the whole 10.0 group is deprecated) +// --------------------------------------------------------------------------------------------------- + +internal static class ParityPayloads +{ + public const string OrdersV1 = "orders-v1"; + public const string OrdersV2 = "orders-v2"; + public const string Health = "health-ok"; + public const string Ping = "ping"; + public const string Sunset = "sunset-v5"; + public const string Advertised = "advertised-v3"; + public const string V1Only = "v1only"; + public const string MapToV1 = "mapto-v1"; + public const string MapToV2 = "mapto-v2"; + public const string ConflictSupported = "conflict-supported"; + public const string ConflictDeprecated = "conflict-deprecated"; + public const string ConflictProbe = "conflict-probe"; + public const string Deprecated = "deprecated-v10"; +} + +/// +/// The native (vanilla Asp.Versioning minimal-API) half of the parity twins: one ApiVersionSet +/// per (verb, route) declaring exactly the versions that route implements, so native's +/// api-supported-versions lines up with the bridge's per-route grouping. +/// +internal static class ParityEndpoints +{ + private static readonly ApiVersion V1 = new(1, 0); + private static readonly ApiVersion V2 = new(2, 0); + private static readonly ApiVersion V3 = new(3, 0); + private static readonly ApiVersion V3_9 = new(3, 9); + private static readonly ApiVersion V5 = new(5, 0); + private static readonly ApiVersion V8 = new(8, 0); + private static readonly ApiVersion V8_1 = new(8, 1); + private static readonly ApiVersion V10 = new(10, 0); + + public static void MapNative(IEndpointRouteBuilder app) + { + // /native/orders — v1.0 deprecated, v2.0 current (single set spanning the route). + var ordersSet = app.NewApiVersionSet() + .HasApiVersion(V2) + .HasDeprecatedApiVersion(V1) + .ReportApiVersions() + .Build(); + + app.MapGet("/native/orders", () => Results.Text(ParityPayloads.OrdersV1)) + .WithApiVersionSet(ordersSet) + .MapToApiVersion(V1); + app.MapGet("/native/orders", () => Results.Text(ParityPayloads.OrdersV2)) + .WithApiVersionSet(ordersSet) + .MapToApiVersion(V2); + + // /native/health — version neutral. Asp.Versioning requires a version set before + // IsApiVersionNeutral is applied, mirroring the bridge (attach set, then mark neutral). + var neutralSet = app.NewApiVersionSet().Build(); + app.MapGet("/native/health", () => Results.Text(ParityPayloads.Health)) + .WithApiVersionSet(neutralSet) + .IsApiVersionNeutral(); + + // /native/ping — completely unversioned (no version metadata at all). + app.MapGet("/native/ping", () => Results.Text(ParityPayloads.Ping)); + + // /native/sunset — serves 5.0 only; the host's version-keyed sunset policy for 5.0 emits the + // Sunset + Link headers. 5.0 is unique to the sunset twins. + var sunsetSet = app.NewApiVersionSet().HasApiVersion(V5).ReportApiVersions().Build(); + app.MapGet("/native/sunset", () => Results.Text(ParityPayloads.Sunset)) + .WithApiVersionSet(sunsetSet) + .MapToApiVersion(V5); + + // /native/advertised — implements 3.0, advertises 3.9 (implemented by no one). Advertised folds + // into api-supported-versions, so the header reports both 3.0 and 3.9. + var advertisedSet = app.NewApiVersionSet() + .HasApiVersion(V3) + .AdvertisesApiVersion(V3_9) + .ReportApiVersions() + .Build(); + app.MapGet("/native/advertised", () => Results.Text(ParityPayloads.Advertised)) + .WithApiVersionSet(advertisedSet) + .MapToApiVersion(V3); + + // /native/v1only — serves 1.0 only, on its own route (per-version exclusivity twin for the + // OpenAPI grouping parity: appears in the v1 document group and NOT the v2 one). + var v1OnlySet = app.NewApiVersionSet().HasApiVersion(V1).ReportApiVersions().Build(); + app.MapGet("/native/v1only", () => Results.Text(ParityPayloads.V1Only)) + .WithApiVersionSet(v1OnlySet) + .MapToApiVersion(V1); + + // /native/mapto — one set declaring {1.0, 2.0} spanning the route, two endpoints each + // MapToApiVersion'd. The Wolverine twin is one class whose class-level [ApiVersion] declares the + // same version space with per-method [MapToApiVersion]. + var mapToSet = app.NewApiVersionSet() + .HasApiVersion(V1) + .HasApiVersion(V2) + .ReportApiVersions() + .Build(); + app.MapGet("/native/mapto", () => Results.Text(ParityPayloads.MapToV1)) + .WithApiVersionSet(mapToSet) + .MapToApiVersion(V1); + app.MapGet("/native/mapto", () => Results.Text(ParityPayloads.MapToV2)) + .WithApiVersionSet(mapToSet) + .MapToApiVersion(V2); + + // /native/conflict — declares the resolved state a correct consumer would author: 8.0 and 8.1 both + // supported. The Wolverine twin reaches the same state via supported-wins (one sibling declares 8.0 + // supported, another declares 8.0 deprecated). 8.1 is the unambiguous probe. 8.0/8.1 are unique to + // the conflict twins. + var conflictSet = app.NewApiVersionSet() + .HasApiVersion(V8) + .HasApiVersion(V8_1) + .ReportApiVersions() + .Build(); + app.MapGet("/native/conflict", () => Results.Text(ParityPayloads.ConflictSupported)) + .WithApiVersionSet(conflictSet) + .MapToApiVersion(V8); + app.MapGet("/native/conflict", () => Results.Text(ParityPayloads.ConflictProbe)) + .WithApiVersionSet(conflictSet) + .MapToApiVersion(V8_1); + + // /native/deprecated — serves 10.0 as the only (deprecated) version, so the 10.0 document group is + // deprecated and its operation is marked deprecated. 10.0 is unique to the deprecated twins. + var deprecatedSet = app.NewApiVersionSet() + .HasDeprecatedApiVersion(V10) + .ReportApiVersions() + .Build(); + app.MapGet("/native/deprecated", () => Results.Text(ParityPayloads.Deprecated)) + .WithApiVersionSet(deprecatedSet) + .MapToApiVersion(V10); + + // /native/feature — serves {1.0, 2.0} and echoes the version the matcher resolved (read off + // IApiVersioningFeature), to prove the feature is populated identically for a Wolverine endpoint. + var featureSet = app.NewApiVersionSet() + .HasApiVersion(V1) + .HasApiVersion(V2) + .ReportApiVersions() + .Build(); + app.MapGet( + "/native/feature", + (HttpContext context) => + Results.Text( + context + .Features.Get() + ?.RequestedApiVersion?.ToString() + ?? "none" + ) + ) + .WithApiVersionSet(featureSet) + .MapToApiVersion(V1) + .MapToApiVersion(V2); + } + + // A native twin for the dedicated-host tiers: one set declaring {1.0, 2.0} spanning the route, two + // endpoints each mapped to its version. Route template + bodies are the only per-host difference. + public static void MapTwoVersionRoute( + IEndpointRouteBuilder app, + string route, + string v1Body, + string v2Body + ) + { + var set = app.NewApiVersionSet() + .HasApiVersion(V1) + .HasApiVersion(V2) + .ReportApiVersions() + .Build(); + + app.MapGet(route, () => Results.Text(v1Body)).WithApiVersionSet(set).MapToApiVersion(V1); + app.MapGet(route, () => Results.Text(v2Body)).WithApiVersionSet(set).MapToApiVersion(V2); + } +} + +// --------------------------------------------------------------------------------------------------- +// Wolverine half of the twins. Public so Wolverine HTTP discovery maps them. +// --------------------------------------------------------------------------------------------------- + +public class WolverineOrdersV1ParityEndpoint +{ + [WolverineGet("/wolverine/orders")] + [ApiVersion("1.0", Deprecated = true)] + public string Get() => ParityPayloads.OrdersV1; +} + +public class WolverineOrdersV2ParityEndpoint +{ + [WolverineGet("/wolverine/orders")] + [ApiVersion("2.0")] + public string Get() => ParityPayloads.OrdersV2; +} + +public class WolverineHealthParityEndpoint +{ + [WolverineGet("/wolverine/health")] + [ApiVersionNeutral] + public string Get() => ParityPayloads.Health; +} + +public class WolverinePingParityEndpoint +{ + [WolverineGet("/wolverine/ping")] + public string Get() => ParityPayloads.Ping; +} + +public class WolverineSunsetParityEndpoint +{ + [WolverineGet("/wolverine/sunset")] + [ApiVersion("5.0")] + public string Get() => ParityPayloads.Sunset; +} + +public class WolverineAdvertisedParityEndpoint +{ + [WolverineGet("/wolverine/advertised")] + [ApiVersion("3.0")] + [AdvertiseApiVersions("3.9")] + public string Get() => ParityPayloads.Advertised; +} + +public class WolverineV1OnlyParityEndpoint +{ + [WolverineGet("/wolverine/v1only")] + [ApiVersion("1.0")] + public string Get() => ParityPayloads.V1Only; +} + +// One class whose class-level [ApiVersion] declares the version space, with per-method [MapToApiVersion] +// selecting each subset. The two methods share one route, so the bridge folds them into one set declaring +// {1.0, 2.0} — matching the native /native/mapto set. +[ApiVersion("1.0")] +[ApiVersion("2.0")] +public class WolverineMapToParityEndpoint +{ + [WolverineGet("/wolverine/mapto")] + [MapToApiVersion("1.0")] + public string GetV1() => ParityPayloads.MapToV1; + + [WolverineGet("/wolverine/mapto")] + [MapToApiVersion("2.0")] + public string GetV2() => ParityPayloads.MapToV2; +} + +// /wolverine/conflict — 8.0 is supported by one sibling and deprecated by another; supported-wins folds +// 8.0 into the supported bucket (never both). 8.1 is an unambiguous probe. The resolved state matches the +// native /native/conflict set (8.0 + 8.1 supported). +public class WolverineConflictSupportedEndpoint +{ + [WolverineGet("/wolverine/conflict")] + [ApiVersion("8.0")] + public string Get() => ParityPayloads.ConflictSupported; +} + +public class WolverineConflictDeprecatedEndpoint +{ + [WolverineGet("/wolverine/conflict")] + [ApiVersion("8.0", Deprecated = true)] + public string Get() => ParityPayloads.ConflictDeprecated; +} + +public class WolverineConflictProbeEndpoint +{ + [WolverineGet("/wolverine/conflict")] + [ApiVersion("8.1")] + public string Get() => ParityPayloads.ConflictProbe; +} + +public class WolverineDeprecatedParityEndpoint +{ + [WolverineGet("/wolverine/deprecated")] + [ApiVersion("10.0", Deprecated = true)] + public string Get() => ParityPayloads.Deprecated; +} + +public class WolverineFeatureParityEndpoint +{ + [WolverineGet("/wolverine/feature")] + [ApiVersion("1.0")] + [ApiVersion("2.0")] + public string Get(HttpContext context) => + context.Features.Get()?.RequestedApiVersion?.ToString() ?? "none"; +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/ParityFixture.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/ParityFixture.cs new file mode 100644 index 000000000..9bf00a4ae --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/ParityFixture.cs @@ -0,0 +1,50 @@ +using Alba; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Wolverine.Http.AspVersioning.Tests.Parity; + +/// +/// Base for the parity hosts. Owns the Wolverine + AspVersioning wiring every host shares — +/// conventional discovery off with this assembly included, AddWolverineHttp(), +/// MapWolverineEndpoints(o => o.UseAspVersioning()), and host teardown. Subclasses supply only +/// their Asp.Versioning/service configuration and register their native /native/* twins. +/// +public abstract class ParityFixture : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + protected async Task BuildHost( + Action configureServices, + Action configureApp + ) + { + var builder = WebApplication.CreateBuilder(Array.Empty()); + + configureServices(builder.Services); + + builder.Host.UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery(); + opts.Discovery.IncludeAssembly(GetType().Assembly); + }); + builder.Services.AddWolverineHttp(); + + return Host = await AlbaHost.For( + builder, + app => + { + configureApp(app); + app.MapWolverineEndpoints(opts => opts.UseAspVersioning()); + } + ); + } + + public abstract Task InitializeAsync(); + + public async Task DisposeAsync() + { + await Host.StopAsync(); + await Host.DisposeAsync(); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/UrlSegmentParityTests.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/UrlSegmentParityTests.cs new file mode 100644 index 000000000..8e0b4215b --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/Parity/UrlSegmentParityTests.cs @@ -0,0 +1,115 @@ +using Alba; +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace Wolverine.Http.AspVersioning.Tests.Parity; + +// --------------------------------------------------------------------------------------------------- +// URL-segment parity. Asp.Versioning does NOT rewrite routes — URL-segment versioning is the developer +// authoring a {version:apiVersion} route parameter plus the `apiVersion` constraint that AddApiVersioning +// registers. The bridge supports it exactly like a vanilla minimal API: the user writes the segment, +// sibling versions share one template, and UrlSegmentApiVersionReader selects by the requested segment. +// This host proves a Wolverine endpoint at "/wolverine/v{version:apiVersion}/things" behaves like its +// native twin. +// --------------------------------------------------------------------------------------------------- + +public class UrlSegmentParityFixture : ParityFixture +{ + public override Task InitializeAsync() => + BuildHost( + services => + { + services + .AddApiVersioning(options => + { + options.ReportApiVersions = true; + options.ApiVersionReader = new UrlSegmentApiVersionReader(); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + // Substitute the concrete version into described paths: "/native/v1/things", not "/v{version}/things". + options.SubstituteApiVersionInUrl = true; + }); + + services.AddEndpointsApiExplorer(); + }, + // The version lives in a {version:apiVersion} route parameter, so both siblings share one template. + app => + ParityEndpoints.MapTwoVersionRoute( + app, + "/native/v{version:apiVersion}/things", + "things-v1", + "things-v2" + ) + ); +} + +[CollectionDefinition("urlsegment")] +public class UrlSegmentParityCollection : ICollectionFixture; + +// Wolverine URL-segment twins: two classes sharing one {version:apiVersion} template, each declaring its +// own version. The `version` route parameter is intentionally NOT a handler argument. +public class WolverineUrlSegmentV1ParityEndpoint +{ + [WolverineGet("/wolverine/v{version:apiVersion}/things")] + [ApiVersion("1.0")] + public string Get() => "things-v1"; +} + +public class WolverineUrlSegmentV2ParityEndpoint +{ + [WolverineGet("/wolverine/v{version:apiVersion}/things")] + [ApiVersion("2.0")] + public string Get() => "things-v2"; +} + +[Collection("urlsegment")] +public class UrlSegmentParityTests +{ + private readonly IAlbaHost _host; + + public UrlSegmentParityTests(UrlSegmentParityFixture fixture) => _host = fixture.Host; + + // The version in the URL segment routes to the matching handler, identically on both twins. + [Theory] + [InlineData(1, "things-v1")] + [InlineData(2, "things-v2")] + public async Task url_segment_routes_and_body_match_native(int major, string expected) + { + var native = await _host.Scenario(x => + { + x.Get.Url($"/native/v{major}/things"); + x.StatusCodeShouldBeOk(); + }); + (await native.ReadAsTextAsync()).Trim().ShouldBe(expected); + + var wolverine = await _host.Scenario(x => + { + x.Get.Url($"/wolverine/v{major}/things"); + x.StatusCodeShouldBeOk(); + }); + (await wolverine.ReadAsTextAsync()).Trim().ShouldBe(expected); + } + + // ApiExplorer substitutes the concrete version into the described path for both twins + // (SubstituteApiVersionInUrl = true); no unsubstituted "{version}" token leaks into either. + [Fact] + public void openapi_substitutes_concrete_version_for_both_twins() + { + var paths = _host + .Services.GetRequiredService() + .ApiDescriptionGroups.Items.SelectMany(g => g.Items) + .Where(d => d.HttpMethod == "GET") + .Select(d => d.RelativePath) + .ToList(); + + paths.ShouldContain("native/v1/things"); + paths.ShouldContain("native/v2/things"); + paths.ShouldContain("wolverine/v1/things"); + paths.ShouldContain("wolverine/v2/things"); + paths.ShouldNotContain(p => p != null && p.Contains("{version}")); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/PolicyOutputTests.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/PolicyOutputTests.cs new file mode 100644 index 000000000..d391bb838 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/PolicyOutputTests.cs @@ -0,0 +1,217 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Builder; +using Shouldly; + +namespace Wolverine.Http.AspVersioning.Tests; + +// ---------- Fixture handlers ---------- + +// One chain per provider role, all sharing route "/roles" so they land in one group/set. +[ApiVersion("1.0")] +internal class ServedRoleHandler +{ + [WolverineGet("/roles")] + public string Served() => "served"; +} + +[ApiVersion("2.0", Deprecated = true)] +internal class DeprecatedRoleHandler +{ + [WolverineGet("/roles")] + public string Deprecated() => "deprecated"; +} + +[ApiVersion("3.0")] +internal class MappedRoleHandler +{ + [MapToApiVersion("3.0")] + [WolverineGet("/roles")] + public string Mapped() => "mapped"; +} + +[AdvertiseApiVersions("4.0")] +internal class AdvertisedRoleHandler +{ + [WolverineGet("/roles")] + public string Advertised() => "advertised"; +} + +[AdvertiseApiVersions("5.0", Deprecated = true)] +internal class AdvertisedDeprecatedRoleHandler +{ + [WolverineGet("/roles")] + public string AdvertisedDeprecated() => "advertised-deprecated"; +} + +[ApiVersionNeutral] +internal class NeutralRoleHandler +{ + [WolverineGet("/neutral-role")] + public string Get() => "neutral"; +} + +internal class UnversionedRoleHandler +{ + [WolverineGet("/unversioned-role")] + public string Get() => "unversioned"; +} + +[ApiVersion("1.0")] +internal class IdempotencyHandler +{ + [WolverineGet("/idempotency")] + public string Get() => "v1"; +} + +// Pre-existing unrelated metadata for the non-destructive check. +internal sealed class UnrelatedMetadataMarker; + +[ApiVersion("1.0")] +internal class TagsProbeHandler +{ + [WolverineGet("/tags-probe")] + public string Get() => "v1"; +} + +[ApiVersion("1.0")] +[ApiVersion("2.0")] +internal class UniqueOperationIdHandler +{ + [MapToApiVersion("1.0")] + [WolverineGet("/unique-operation-id")] + public string GetV1() => "v1"; + + [MapToApiVersion("2.0")] + [WolverineGet("/unique-operation-id")] + public string GetV2() => "v2"; +} + +// ---------- Tests ---------- + +/// +/// Runs the full over a realistic set of chains and asserts on the +/// metadata that gets attached. Stops at "the correct metadata is present"; does not assert how the +/// matcher later interprets it. +/// +public class PolicyOutputTests : HostlessAspVersioningContext +{ + public PolicyOutputTests(HostlessAspVersioningFixture fixture) : base(fixture) { } + + // Per-chain provider roles land in the right ApiVersionModel buckets. Advertised versions fold into + // Supported (advertised-deprecated into Deprecated) — ApiVersionModel has no separate advertised list + // — so the advertised role shows up as bucket membership that is absent from DeclaredApiVersions. + [Fact] + public void per_chain_provider_roles_are_correct() + { + var served = VersioningHarness.ChainFor(x => x.Served()); + var deprecated = VersioningHarness.ChainFor(x => x.Deprecated()); + var mapped = VersioningHarness.ChainFor(x => x.Mapped()); + var advertised = VersioningHarness.ChainFor(x => x.Advertised()); + var advertisedDeprecated = VersioningHarness.ChainFor(x => + x.AdvertisedDeprecated() + ); + + VersioningHarness.Apply(served, deprecated, mapped, advertised, advertisedDeprecated); + + // served → None role: implemented + supported + var servedModel = served.EndpointModel(); + servedModel.SupportedApiVersions.ShouldContain(new ApiVersion(1, 0)); + servedModel.ImplementedApiVersions.ShouldContain(new ApiVersion(1, 0)); + + // served-deprecated → Deprecated role + deprecated.EndpointModel().DeprecatedApiVersions.ShouldContain(new ApiVersion(2, 0)); + + // mapped → Mapped role + mapped.VersionMetadata()!.IsMappedTo(new ApiVersion(3, 0)).ShouldBeTrue(); + + // advertised → folds into the supported bucket but is NOT declared + advertised.EndpointModel().SupportedApiVersions.ShouldContain(new ApiVersion(4, 0)); + advertised.EndpointModel().DeclaredApiVersions.ShouldNotContain(new ApiVersion(4, 0)); + + // advertised-deprecated → folds into the deprecated bucket, likewise not declared + advertisedDeprecated + .EndpointModel() + .DeprecatedApiVersions.ShouldContain(new ApiVersion(5, 0)); + advertisedDeprecated + .EndpointModel() + .DeclaredApiVersions.ShouldNotContain(new ApiVersion(5, 0)); + } + + // A neutral chain gets neutral metadata. + [Fact] + public void neutral_chain_has_neutral_metadata() + { + var chain = VersioningHarness.ChainFor(x => x.Get()); + + VersioningHarness.Apply(chain); + + chain.VersionMetadata()!.IsApiVersionNeutral.ShouldBeTrue(); + } + + // An unversioned chain is left untouched — no version metadata. + [Fact] + public void unversioned_chain_is_untouched() + { + var chain = VersioningHarness.ChainFor(x => x.Get()); + + VersioningHarness.Apply(chain); + + chain.VersionMetadata().ShouldBeNull(); + } + + // Applying the policy is idempotent and non-destructive: exactly one ApiVersionMetadata after a + // re-run (a single apply is the baseline), and pre-existing unrelated metadata survives. Exercises + // the policy's _processedChains guard. + [Fact] + public void rerunning_apply_is_idempotent_and_non_destructive() + { + var chain = VersioningHarness.ChainFor(x => x.Get()); + + var marker = new UnrelatedMetadataMarker(); + chain.Metadata.WithMetadata(marker); + + VersioningHarness.ApplyRepeated(2, chain); + + chain.VersionMetadataCount().ShouldBe(1); + chain.MetadataOf().ShouldContain(marker); + } + + // The bridge builds the shared set with a null name, so no version-set-derived TagsAttribute is + // injected (a named set would add TagsAttribute(name) and pollute OpenAPI tags). Host-free because + // Wolverine's baseline tags differ from a native minimal-API endpoint's — only the absence of a + // version-set-derived tag is meaningful. + [Fact] + public void no_version_set_derived_tags_are_attached() + { + var chain = VersioningHarness.ChainFor(x => x.Get()); + + VersioningHarness.Apply(chain); + + chain.TagMetadata().ShouldBeEmpty(); + } + + [Fact] + public void application_services_are_enabled_on_versioned_chains() + { + var versionedChain = VersioningHarness.ChainFor(x => x.Served()); + var unversionedChain = VersioningHarness.ChainFor(x => x.Get()); + + VersioningHarness.Apply(versionedChain, unversionedChain); + + versionedChain.RequiresApplicationServices.ShouldBeTrue(); + unversionedChain.RequiresApplicationServices.ShouldBeFalse(); + } + + [Fact] + public void conflicting_chains_have_unique_operation_ids() + { + var v1Chain = VersioningHarness.ChainFor(x => x.GetV1()); + var v2Chain = VersioningHarness.ChainFor(x => x.GetV2()); + + VersioningHarness.Apply(v1Chain, v2Chain); + + v1Chain.HasExplicitOperationId.ShouldBeTrue(); + v2Chain.HasExplicitOperationId.ShouldBeTrue(); + v1Chain.OperationId.ShouldNotBe(v2Chain.OperationId); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/SetBuildingTests.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/SetBuildingTests.cs new file mode 100644 index 000000000..55f60a5e2 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/SetBuildingTests.cs @@ -0,0 +1,163 @@ +using Asp.Versioning; +using Shouldly; + +namespace Wolverine.Http.AspVersioning.Tests; + +// ---------- Fixture handlers ---------- + +[ApiVersion("1.0")] +[ApiVersion("2.0", Deprecated = true)] +internal class MixedSupportedDeprecatedHandler +{ + [WolverineGet("/mixed")] + public string Get() => "mixed"; +} + +[ApiVersion("1.0")] +internal class SupportedSiblingHandler +{ + [WolverineGet("/supported-wins")] + public string Get() => "supported"; +} + +[ApiVersion("1.0", Deprecated = true)] +internal class DeprecatedSiblingHandler +{ + [WolverineGet("/supported-wins")] + public string Get() => "deprecated"; +} + +[AdvertiseApiVersions("3.0")] +[AdvertiseApiVersions("4.0", Deprecated = true)] +internal class AdvertisedFoldHandler +{ + [WolverineGet("/advertised-fold")] + public string Get() => "advertised"; +} + +[ApiVersionNeutral] +internal class NeutralSetGetHandler +{ + [WolverineGet("/all-neutral")] + public string Get() => "neutral-1"; +} + +[ApiVersionNeutral] +internal class NeutralSetPostHandler +{ + [WolverinePost("/all-neutral")] + public string Post() => "neutral-2"; +} + +internal class UnversionedSetHandler +{ + [WolverineGet("/unversioned-set")] + public string Get() => "unversioned"; +} + +[ApiVersion("1.0")] +internal class DuplicateFirstHandler +{ + [WolverineGet("/duplicate")] + public string Get() => "first"; +} + +[ApiVersion("1.0")] +internal class DuplicateSecondHandler +{ + [WolverineGet("/duplicate")] + public string Get() => "second"; +} + +// ---------- Tests ---------- + +/// +/// Bucketing into supported/deprecated and construction of the per-group +/// . Single-chain bucketing lives in +/// ; cross-chain set decisions live in +/// and are observed through the attached set/metadata. +/// +public class SetBuildingTests : HostlessAspVersioningContext +{ + public SetBuildingTests(HostlessAspVersioningFixture fixture) : base(fixture) { } + + // A chain serving 1.0 and deprecated-2.0 partitions into the two buckets on the VersionedChain. + [Fact] + public void supported_and_deprecated_partition_correctly() + { + var chain = VersioningHarness.ChainFor(x => x.Get()); + + var vc = VersionedChain.FromHttpChain(chain); + vc.Supported.Select(r => r.Version).ShouldBe(new[] { new ApiVersion(1, 0) }); + vc.Deprecated.Select(r => r.Version).ShouldBe(new[] { new ApiVersion(2, 0) }); + } + + // Same version supported by one chain and deprecated by another → supported wins; it appears only in + // the supported bucket (matches Asp.Versioning's own deprecated.ExceptWith(supported) merge). + [Fact] + public void supported_wins_over_deprecated_for_same_version() + { + var supported = VersioningHarness.ChainFor(x => x.Get()); + var deprecated = VersioningHarness.ChainFor(x => x.Get()); + + VersioningHarness.Apply(supported, deprecated); + + var group = supported.GroupModel(); + group.SupportedApiVersions.ShouldContain(new ApiVersion(1, 0)); + group.DeprecatedApiVersions.ShouldNotContain(new ApiVersion(1, 0)); + } + + // Advertised folds into the supported/deprecated buckets. Asp.Versioning's read-side ApiVersionModel + // has no separate advertised list, so the one signal that 3.0/4.0 were advertised (not declared) is + // their absence from DeclaredApiVersions. + [Fact] + public void advertised_folds_into_supported_and_deprecated_buckets() + { + var chain = VersioningHarness.ChainFor(x => x.Get()); + + VersioningHarness.Apply(chain); + + var group = chain.GroupModel(); + group.SupportedApiVersions.ShouldContain(new ApiVersion(3, 0)); + group.DeprecatedApiVersions.ShouldContain(new ApiVersion(4, 0)); + group.DeclaredApiVersions.ShouldNotContain(new ApiVersion(3, 0)); + group.DeclaredApiVersions.ShouldNotContain(new ApiVersion(4, 0)); + } + + // An all-neutral group builds no set; each chain gets neutral metadata. + [Fact] + public void all_neutral_group_builds_no_set() + { + var first = VersioningHarness.ChainFor(x => x.Get()); + var second = VersioningHarness.ChainFor(x => x.Post()); + + VersioningHarness.Apply(first, second); + + first.VersionMetadata()!.IsApiVersionNeutral.ShouldBeTrue(); + second.VersionMetadata()!.IsApiVersionNeutral.ShouldBeTrue(); + } + + // An unversioned chain is filtered out before the policy attaches anything. + [Fact] + public void unversioned_group_builds_no_metadata() + { + var chain = VersioningHarness.ChainFor(x => x.Get()); + + VersioningHarness.Apply(chain); + + chain.VersionMetadata().ShouldBeNull(); + } + + // Duplicate identical contributions collapse to one entry without error, so the supported/deprecated + // fold is only needed for the conflict case (supported_wins_over_deprecated), not plain dedup. + [Fact] + public void duplicate_identical_contributions_collapse_without_error() + { + var first = VersioningHarness.ChainFor(x => x.Get()); + var second = VersioningHarness.ChainFor(x => x.Get()); + + Should.NotThrow(() => VersioningHarness.Apply(first, second)); + + first.GroupModel().SupportedApiVersions.ShouldBe(new[] { new ApiVersion(1, 0) }); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/Usings.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/Usings.cs new file mode 100644 index 000000000..c802f4480 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/ValidationTests.cs b/src/Http/Wolverine.Http.AspVersioning.Tests/ValidationTests.cs new file mode 100644 index 000000000..cc3ad982e --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/ValidationTests.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Model; +using JasperFx.CodeGeneration.Services; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace Wolverine.Http.AspVersioning.Tests; + +/// +/// The bridge's fail-fast configuration guards (AspVersioningPolicy.validateConfiguration) and the +/// idempotence of the public entry point. +/// +public class ValidationTests +{ + // Build the host-free parent container the policy inspects. Mirrors VersioningHarness, but lets each + // test choose whether Asp.Versioning is configured and which WolverineHttpOptions is registered. + private static IServiceContainer Container( + bool addApiVersioning, + WolverineHttpOptions httpOptions + ) + { + var registry = new ServiceCollection(); + if (addApiVersioning) + registry.AddApiVersioning(); + registry.AddSingleton(); + registry.AddTransient(); + registry.AddSingleton(); + registry.AddSingleton(registry); + registry.AddSingleton(httpOptions); + + return registry.BuildServiceProvider().GetRequiredService(); + } + + private static void Apply(IServiceContainer container) => + new AspVersioningPolicy().Apply([], new GenerationRules(), container); + + // Guard 1: forgetting AddApiVersioning() is the most likely first-run mistake; the bridge fails fast + // with a message that names the fix rather than letting the endpoint finalizer blow up cryptically. + [Fact] + public void throws_when_AddApiVersioning_was_not_called() + { + var container = Container(addApiVersioning: false, new WolverineHttpOptions()); + + var ex = Should.Throw(() => Apply(container)); + ex.Message.ShouldContain("AddApiVersioning()"); + } + + // Guard 2: the native and Asp.Versioning integrations are mutually exclusive; enabling both is a + // configuration error the bridge rejects up front. + [Fact] + public void throws_when_native_versioning_is_also_enabled() + { + var httpOptions = new WolverineHttpOptions(); + httpOptions.UseApiVersioning(_ => { }); // turns on Wolverine's native versioning + + var container = Container(addApiVersioning: true, httpOptions); + + var ex = Should.Throw(() => Apply(container)); + ex.Message.ShouldContain("cannot be enabled simultaneously"); + } + + // UseAspVersioning() registers the policy on the first call and is a no-op afterward. + [Fact] + public void use_asp_versioning_is_idempotent() + { + var httpOptions = new WolverineHttpOptions(); + + httpOptions.UseAspVersioning(); + httpOptions.UseAspVersioning(); + + httpOptions.Policies.Count(p => p is AspVersioningPolicy).ShouldBe(1); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning.Tests/Wolverine.Http.AspVersioning.Tests.csproj b/src/Http/Wolverine.Http.AspVersioning.Tests/Wolverine.Http.AspVersioning.Tests.csproj new file mode 100644 index 000000000..10e27a58e --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning.Tests/Wolverine.Http.AspVersioning.Tests.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + false + + false + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/src/Http/Wolverine.Http.AspVersioning/AdvertisedVersionResolver.cs b/src/Http/Wolverine.Http.AspVersioning/AdvertisedVersionResolver.cs new file mode 100644 index 000000000..ae15ae426 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning/AdvertisedVersionResolver.cs @@ -0,0 +1,41 @@ +using System.Reflection; +using Asp.Versioning; +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.AspVersioning; + +internal static class AdvertisedVersionResolver +{ + /// + /// Resolves all API versions advertised by a handler method. + /// + /// The handler method. + /// + /// An ordered, distinct list of ; empty when no advertise + /// version attributes are present. + /// + public static IReadOnlyList ResolveAdvertised(MethodInfo method) + { + var mergedAttributes = getMergedAdvertiseAttributes(method) + .Cast() + .ToList(); + var versions = mergedAttributes.SelectMany(attr => attr.Versions).Distinct().ToList(); + + return ApiVersionResolver.BuildResolutions(versions, mergedAttributes); + } + + private static IEnumerable getMergedAdvertiseAttributes( + MethodInfo method + ) + { + // Unlike [ApiVersion] and [MapToApiVersion], [AdvertiseApiVersions] is additive across method + // and class. We can just merge them without any special logic. + return method + .GetCustomAttributes(inherit: false) + .Concat( + method.DeclaringType?.GetCustomAttributes( + inherit: false + ) ?? [] + ); + } +} diff --git a/src/Http/Wolverine.Http.AspVersioning/AspVersioningPolicy.cs b/src/Http/Wolverine.Http.AspVersioning/AspVersioningPolicy.cs new file mode 100644 index 000000000..124587673 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning/AspVersioningPolicy.cs @@ -0,0 +1,152 @@ +using Asp.Versioning; +using Asp.Versioning.Builder; +using JasperFx; +using JasperFx.CodeGeneration; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Wolverine.Http.AspVersioning; + +internal sealed class AspVersioningPolicy : IHttpPolicy +{ + // Chains this policy instance has already attached versioning metadata to. Guards against a + // second Apply(...) over the same chains re-attaching a duplicate ApiVersionSet or re-emitting + // MapToApiVersion. Mirrors the native ApiVersioningPolicy._processedChains guard. + private readonly HashSet _processedChains = []; + + public void Apply( + IReadOnlyList chains, + GenerationRules rules, + IServiceContainer container + ) + { + validateConfiguration(container); + + var versionedChainGroups = chains + .Select(VersionedChain.FromHttpChain) + .Where(c => c.HasVersioningInfo) + .GroupBy( + c => normalizeRoute(c.Chain.RoutePattern?.RawText), + StringComparer.OrdinalIgnoreCase + ); + + foreach (var group in versionedChainGroups) + applyVersioningMetadata([.. group]); + } + + /// + /// Applies versioning metadata to a group of versioned chains that share the same normalized route. + /// + /// The versioned chains to apply metadata to. + private void applyVersioningMetadata(IReadOnlyList versionedChains) + { + var versionSet = buildVersionSet(versionedChains); + + foreach (var vc in versionedChains) + { + // Idempotency: never touch a chain twice. Without this, a repeated Apply would attach a + // second WithApiVersionSet convention (the finalizer then sees two sets) and duplicate + // MapToApiVersion calls. + if (!_processedChains.Add(vc.Chain)) + continue; + + vc.Chain.RequiresApplicationServices = true; + + if (!vc.Chain.HasExplicitOperationId) + vc.Chain.SetExplicitOperationId(vc.Chain.OperationId); + + vc.Chain.WithApiVersionSet(versionSet); + + if (vc.IsVersionNeutral) + { + vc.Chain.IsApiVersionNeutral(); + continue; + } + + // Map this chain to every version it serves. Supported-vs-deprecated and advertised are + // all properties of the shared set built above — MapToApiVersion inherits roles from the + // set and (unlike HasApiVersion/HasDeprecatedApiVersion) does not mutate it, so there is + // no per-chain role reconciliation. Advertise-only chains serve nothing, emit nothing, + // and inherit the set's full space (incl. the advertised fold). + foreach (var served in vc.Supported.Concat(vc.Deprecated)) + vc.Chain.MapToApiVersion(served.Version); + } + } + + /// + /// Builds an to express the versioning semantics of a group of versioned + /// chains that share the same normalized route. + /// + /// The versioned chains used to build the set. + /// The constructed . + private static ApiVersionSet buildVersionSet(IReadOnlyList versionedChains) + { + // Pass null so that we don't clutter the user's OpenAPI docs with internal tags + var versionSetBuilder = new ApiVersionSetBuilder(null); + + var supported = versionedChains + .SelectMany(vc => vc.Supported) + .Select(r => r.Version) + .ToHashSet(); + var deprecated = versionedChains + .SelectMany(vc => vc.Deprecated) + .Select(r => r.Version) + .ToHashSet(); + var advertised = versionedChains + .SelectMany(vc => vc.Advertised) + .Select(r => r.Version) + .ToHashSet(); + var advertisedDeprecated = versionedChains + .SelectMany(vc => vc.AdvertisedDeprecated) + .Select(r => r.Version) + .ToHashSet(); + + // Supported wins over deprecated for the same version anywhere in the group — a version supported + // (served or advertised) by any sibling must not also be reported deprecated. + var allSupported = new HashSet(supported); + allSupported.UnionWith(advertised); + deprecated.ExceptWith(allSupported); + advertisedDeprecated.ExceptWith(allSupported); + + // Seed each version under its role so the set model keeps the advertised lists distinct + // (AdvertisedApiVersions / DeprecatedAdvertisedApiVersions) while still folding them into + // SupportedApiVersions / DeprecatedApiVersions for the response headers. + foreach (var version in supported) + versionSetBuilder.HasApiVersion(version); + + foreach (var version in deprecated) + versionSetBuilder.HasDeprecatedApiVersion(version); + + foreach (var version in advertised) + versionSetBuilder.AdvertisesApiVersion(version); + + foreach (var version in advertisedDeprecated) + versionSetBuilder.AdvertisesDeprecatedApiVersion(version); + + return versionSetBuilder.Build(); + } + + // Fail fast if Asp.Versioning is not configured or if the user has also enabled Wolverine's + // native API versioning. + private static void validateConfiguration(IServiceContainer container) + { + if (container.Services.GetService>() is null) + throw new InvalidOperationException( + "Asp.Versioning is not configured. Ensure that you call `AddApiVersioning()` " + + "when configuring your application services." + ); + + if (container.Services.GetRequiredService().ApiVersioning is not null) + throw new InvalidOperationException( + "Wolverine's native API versioning (`UseApiVersioning()`) and " + + "Asp.Versioning integration (`UseAspVersioning()`) cannot be enabled simultaneously. " + + "Please choose one or the other." + ); + } + + // Route grouping key: OrdinalIgnoreCase after trimming leading/trailing slashes. A null, empty, + // or whitespace route — and the root route "/" — all normalize to the empty string. + private static string normalizeRoute(string? rawRoutePattern) => + string.IsNullOrWhiteSpace(rawRoutePattern) ? string.Empty : rawRoutePattern.Trim('/'); +} diff --git a/src/Http/Wolverine.Http.AspVersioning/AssemblyAttributes.cs b/src/Http/Wolverine.Http.AspVersioning/AssemblyAttributes.cs new file mode 100644 index 000000000..8479ec6e8 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Wolverine.Http.AspVersioning.Tests")] \ No newline at end of file diff --git a/src/Http/Wolverine.Http.AspVersioning/VersionedChain.cs b/src/Http/Wolverine.Http.AspVersioning/VersionedChain.cs new file mode 100644 index 000000000..7abf5e0f7 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning/VersionedChain.cs @@ -0,0 +1,44 @@ +using Wolverine.Http.ApiVersioning; + +namespace Wolverine.Http.AspVersioning; + +/// +/// Encapsulates an , its version-neutral status, and its API version buckets +/// (supported, deprecated, advertised, and advertised deprecated). +/// +internal sealed class VersionedChain +{ + public VersionedChain( + HttpChain chain, + IReadOnlyCollection versions, + IReadOnlyCollection advertised, + bool isVersionNeutral + ) + { + Chain = chain; + IsVersionNeutral = isVersionNeutral; + HasVersioningInfo = isVersionNeutral || versions.Count > 0 || advertised.Count > 0; + + Supported = [.. versions.Where(r => !r.IsDeprecated)]; + Deprecated = [.. versions.Where(r => r.IsDeprecated)]; + Advertised = [.. advertised.Where(r => !r.IsDeprecated)]; + AdvertisedDeprecated = [.. advertised.Where(r => r.IsDeprecated)]; + } + + public HttpChain Chain { get; } + public bool HasVersioningInfo { get; } + public bool IsVersionNeutral { get; } + + public IReadOnlyList Supported { get; } + public IReadOnlyList Deprecated { get; } + public IReadOnlyList Advertised { get; } + public IReadOnlyList AdvertisedDeprecated { get; } + + public static VersionedChain FromHttpChain(HttpChain chain) => + new( + chain, + ApiVersionResolver.ResolveVersions(chain.Method.Method), + AdvertisedVersionResolver.ResolveAdvertised(chain.Method.Method), + ApiVersionNeutralResolver.Resolve(chain.Method.Method) + ); +} diff --git a/src/Http/Wolverine.Http.AspVersioning/Wolverine.Http.AspVersioning.csproj b/src/Http/Wolverine.Http.AspVersioning/Wolverine.Http.AspVersioning.csproj new file mode 100644 index 000000000..5d5b188fe --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning/Wolverine.Http.AspVersioning.csproj @@ -0,0 +1,23 @@ + + + + Asp.Versioning integration for Wolverine HTTP Endpoints + WolverineFx.Http.AspVersioning + net10.0 + false + false + false + false + false + true + + + + + + + + + + + diff --git a/src/Http/Wolverine.Http.AspVersioning/WolverineHttpOptionsExtensions.cs b/src/Http/Wolverine.Http.AspVersioning/WolverineHttpOptionsExtensions.cs new file mode 100644 index 000000000..46cc1ebc3 --- /dev/null +++ b/src/Http/Wolverine.Http.AspVersioning/WolverineHttpOptionsExtensions.cs @@ -0,0 +1,19 @@ +namespace Wolverine.Http.AspVersioning; + +public static class WolverineHttpOptionsExtensions +{ + /// + /// Enable API versioning support via integration with Asp.Versioning.Http. The first call + /// registers an AspVersioningPolicy that applies versioning semantics at bootstrap time. + /// Subsequent calls are no-ops. + /// + /// + /// An exception will be thrown during startup if Asp.Versioning is not configured or if Wolverine's + /// native API versioning (UseApiVersioning()) is also enabled. + /// + public static void UseAspVersioning(this WolverineHttpOptions httpOptions) + { + if (!httpOptions.Policies.Any(policy => policy is AspVersioningPolicy)) + httpOptions.AddPolicy(); + } +} diff --git a/src/Http/Wolverine.Http.Tests/ApiVersioning/MultiVersionResolverTests.cs b/src/Http/Wolverine.Http.Tests/ApiVersioning/MultiVersionResolverTests.cs index cca80647b..06e3ff6a4 100644 --- a/src/Http/Wolverine.Http.Tests/ApiVersioning/MultiVersionResolverTests.cs +++ b/src/Http/Wolverine.Http.Tests/ApiVersioning/MultiVersionResolverTests.cs @@ -56,6 +56,14 @@ internal class BothApiVersionAndMapToHandler public void Handle() { } } +[ApiVersion("1.0", Deprecated = true)] +[ApiVersion("2.0")] +internal class ClassDeprecatedWithMapToHandler +{ + [MapToApiVersion("1.0")] + public void Handle() { } +} + public class MultiVersionResolverTests { private static MethodInfo MethodOf(string name) @@ -104,6 +112,18 @@ public void mapto_filters_class_level_versions_to_listed_subset() versions[0].Version.ShouldBe(new ApiVersion(2, 0)); } + [Fact] + public void mapto_inherits_class_level_deprecation() + { + var method = MethodOf(nameof(ClassDeprecatedWithMapToHandler.Handle)); + var versions = ApiVersionResolver.ResolveVersions(method); + + // The [MapToApiVersion] subset carries the Deprecated flag declared on the class-level [ApiVersion]. + versions.Count.ShouldBe(1); + versions[0].Version.ShouldBe(new ApiVersion(1, 0)); + versions[0].IsDeprecated.ShouldBeTrue(); + } + [Fact] public void mapto_listing_version_not_on_class_throws_naming_both() { diff --git a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs index a7eab839a..2384bb4a9 100644 --- a/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs +++ b/src/Http/Wolverine.Http/ApiVersioning/ApiVersionResolver.cs @@ -78,18 +78,29 @@ public static IReadOnlyList ResolveVersions(MethodInfo met /// /// Builds resolutions for the given in order, marking each as - /// deprecated when any attribute in declares the version - /// with . Used by every branch of - /// : class-only, method-only, and [MapToApiVersion] filtering. + /// deprecated when any provider in declares the version + /// with the flag. /// - private static IReadOnlyList BuildResolutions( + /// The API versions to build resolutions for. + /// + /// The API version providers whose flag drives + /// per-version deprecation (e.g. [ApiVersion], [MapToApiVersion], + /// [AdvertiseApiVersions]). + /// + /// A read-only list of API version resolutions. + public static IReadOnlyList BuildResolutions( IReadOnlyCollection versions, - IReadOnlyCollection deprecationSource) + IReadOnlyCollection versionProviders + ) { var result = new List(versions.Count); foreach (var version in versions) { - var isDeprecated = deprecationSource.Any(a => a.Deprecated && a.Versions.Contains(version)); + var isDeprecated = versionProviders.Any(a => + a.Options.HasFlag(ApiVersionProviderOptions.Deprecated) + && a.Versions.Contains(version) + ); + result.Add(new ApiVersionResolution(version, isDeprecated)); } return result; diff --git a/src/Http/Wolverine.Http/AssemblyAttributes.cs b/src/Http/Wolverine.Http/AssemblyAttributes.cs index c187ad821..6b9d71e0d 100644 --- a/src/Http/Wolverine.Http/AssemblyAttributes.cs +++ b/src/Http/Wolverine.Http/AssemblyAttributes.cs @@ -7,6 +7,9 @@ // RunJasperFxCommands() in consuming applications. See OpenApiCommand. [assembly: JasperFxAssembly] +[assembly: InternalsVisibleTo("Wolverine.Http.AspVersioning")] +[assembly: InternalsVisibleTo("Wolverine.Http.AspVersioning.Tests")] + [assembly: InternalsVisibleTo("Wolverine.Http.Tests")] // WolverineFx.Http.Newtonsoft's UseNewtonsoftJsonForSerialization extension // needs to call HttpGraph.UseNewtonsoftJson(INewtonsoftHttpCodeGen) and diff --git a/src/Http/Wolverine.Http/HttpChain.EndpointBuilder.cs b/src/Http/Wolverine.Http/HttpChain.EndpointBuilder.cs index 099fa791b..d5a49d820 100644 --- a/src/Http/Wolverine.Http/HttpChain.EndpointBuilder.cs +++ b/src/Http/Wolverine.Http/HttpChain.EndpointBuilder.cs @@ -23,6 +23,16 @@ public partial class HttpChain : IEndpointConventionBuilder // ReSharper disable once InconsistentNaming public RouteHandlerBuilder Metadata { get; } + /// + /// Indicates whether the endpoint builder for this chain requires access to the application's + /// service provider. Default value is . + /// + /// + /// If , the RouteEndpointBuilder used to build this chain's endpoint + /// will be instantiated with the service provider exposed by the parent . + /// + internal bool RequiresApplicationServices { get; set; } + public void Add(Action convention) { _builderConfigurations.Add(convention); @@ -94,7 +104,10 @@ public RouteEndpoint BuildEndpoint(RouteWarmup warmup) var builder = new RouteEndpointBuilder(requestDelegate, RoutePattern!, Order) { - DisplayName = DisplayName + DisplayName = DisplayName, + ApplicationServices = RequiresApplicationServices + ? _parent.Container.Services + : EmptyServiceProvider.Instance // equivalent to not passing a value at all }; establishResourceTypeMetadata(builder); @@ -183,6 +196,15 @@ public void Apply(EndpointBuilder builder, MethodInfo method) T.PopulateMetadata(method, builder); } } + + // Copied directly from `Microsoft.AspNetCore.Builder.EndpointBuilder`. Serves as the default + // value of `RouteEndpointBuilder.ApplicationServices` when the endpoint does not require the + // application's service provider. + private sealed class EmptyServiceProvider : IServiceProvider + { + public static EmptyServiceProvider Instance { get; } = new EmptyServiceProvider(); + public object? GetService(Type serviceType) => null; + } } internal class ProducesProblemDetailsResponseTypeMetadata : IProducesResponseTypeMetadata diff --git a/src/Http/Wolverine.Http/HttpChain.cs b/src/Http/Wolverine.Http/HttpChain.cs index 736d3422d..91a7f52eb 100644 --- a/src/Http/Wolverine.Http/HttpChain.cs +++ b/src/Http/Wolverine.Http/HttpChain.cs @@ -859,9 +859,9 @@ public HttpElementVariable GetOrCreateHeaderVariable(IFromHeaderMetadata metadat /// /// 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. + /// as the endpoint name in the ASP.NET Core routing infrastructure. This is used by policies + /// that need to disambiguate endpoints who share the same handler method name (e.g. + /// ). /// internal void SetExplicitOperationId(string operationId) { diff --git a/wolverine.slnx b/wolverine.slnx index 5d526d438..49eb7f5be 100644 --- a/wolverine.slnx +++ b/wolverine.slnx @@ -44,6 +44,12 @@ + + + + + + From 9b596bc0a301c45ebaef42121b79472ff06dfab3 Mon Sep 17 00:00:00 2001 From: Brandon K Date: Mon, 6 Jul 2026 11:09:01 -0400 Subject: [PATCH 2/2] fix doc comment grammar --- src/Http/Wolverine.Http/HttpChain.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Http/Wolverine.Http/HttpChain.cs b/src/Http/Wolverine.Http/HttpChain.cs index 91a7f52eb..7c4d81037 100644 --- a/src/Http/Wolverine.Http/HttpChain.cs +++ b/src/Http/Wolverine.Http/HttpChain.cs @@ -1,8 +1,3 @@ -using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; -using System.Reflection; -using System.Text.Json; -using System.Text.RegularExpressions; using Asp.Versioning; using JasperFx; using JasperFx.CodeGeneration; @@ -10,7 +5,6 @@ using JasperFx.CodeGeneration.Model; using JasperFx.CodeGeneration.Services; using JasperFx.Core; -using Wolverine.Http.Antiforgery; using JasperFx.Core.Reflection; using JasperFx.Descriptors; using Microsoft.AspNetCore.Builder; @@ -20,7 +14,13 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; using Wolverine.Configuration; +using Wolverine.Http.Antiforgery; using Wolverine.Http.CodeGen; using Wolverine.Http.ContentNegotiation; using Wolverine.Http.Metadata; @@ -860,7 +860,7 @@ public HttpElementVariable GetOrCreateHeaderVariable(IFromHeaderMetadata metadat /// /// 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 policies - /// that need to disambiguate endpoints who share the same handler method name (e.g. + /// that need to disambiguate endpoints that share the same handler method name (e.g. /// ). /// internal void SetExplicitOperationId(string operationId)