Skip to content

feat(http): multi-version handlers + [MapToApiVersion] support#2662

Closed
outofrange-consulting wants to merge 6 commits into
JasperFx:mainfrom
outofrange-consulting:feature/http-multiversion-handlers
Closed

feat(http): multi-version handlers + [MapToApiVersion] support#2662
outofrange-consulting wants to merge 6 commits into
JasperFx:mainfrom
outofrange-consulting:feature/http-multiversion-handlers

Conversation

@outofrange-consulting

Copy link
Copy Markdown
Contributor

Follow-up to #2633.

Summary

Wolverine.Http now expands handler chains that declare multiple API versions into one endpoint per version at bootstrap, before any IHttpPolicy runs. A single handler method can serve several versions (via repeated [ApiVersion] attributes or class-level [ApiVersion] declarations) while keeping per-version metadata, routes, and deprecation/sunset policies isolated to each clone.

[MapToApiVersion] is honoured on methods of classes that advertise multiple versions: the listed subset is registered, with strict validation that every mapped version exists at class level. Mixing [ApiVersion] and [MapToApiVersion] on the same method fails fast with a descriptive error.

Floats Asp.Versioning.Abstractions to [10.0.0,11.0.0).

Implementation notes

  • HttpChain.CloneForVersion(ApiVersion, bool isDeprecated) is the per-version expansion primitive. Each clone gets a fresh MethodCall (sharing throws "Frame chain re-arranged" from JasperFx codegen). Per-clone OperationId is suffixed with _v{sanitised-version-string} to keep ASP.NET Core's "globally unique endpoint name" invariant; sanitisation is regex-based ([^A-Za-z0-9]_) so date-based versions like 2024-01-01 produce _v2024_01_01 instead of breaking the rule.
  • Each clone's metadata bag is filtered via a Metadata.Add callback that scrubs [ApiVersion] / [MapToApiVersion] attributes mismatched with the clone's own version. Without this, downstream OpenAPI tooling (Swashbuckle's WolverineApiVersioningSwaggerOperationFilter) would report each clone as implementing all sibling versions.
  • MultiVersionExpansion.ExpandInPlace runs in HttpGraph.DiscoverEndpoints before any IHttpPolicy so middleware composition applies uniformly to all clones. Single-version chains are left to ApiVersioningPolicy.ResolveAttributes — no duplicated work.
  • ApiVersionHeaderWriter now reads ApiVersionMetadata.ImplementedApiVersions from the matched endpoint when emitting api-supported-versions. Falls back to options.SunsetPolicies.Keys ∪ DeprecationPolicies.Keys for chains without per-endpoint metadata. The header therefore correctly reflects the sibling-version union for clones (e.g. v1 of a [ApiVersion("1.0"), ApiVersion("2.0"), ApiVersion("3.0")] handler emits api-supported-versions: 1.0, 2.0, 3.0).
  • ApiVersioningPolicy.AttachMetadata seeds ApiVersionModel(declared, supported, deprecated) per clone by grouping all chains by (verb, route-without-version-prefix). Cross-class merging at the same route is intentional (matches Asp.Versioning conventions) and documented in the method's <remarks>.

Risk areas (touches core abstractions)

This PR modifies HttpChain.cs and HttpGraph.cs — please review the expansion-before-policy ordering and the per-clone MethodCall cloning rationale carefully. The Metadata.Add filter callback in CloneForVersion is also load-bearing for OpenAPI correctness.

Test plan

  • MultiVersionExpansionTests — 13 unit tests (expansion mechanics, attribute filtering, sibling union, date-based versions, overlap detection across distinct handlers, [MapToApiVersion] single-version path, unversioned-alongside, cross-class union)
  • MultiVersionResolverTests — 138 lines covering attribute resolution and [MapToApiVersion] validation
  • multi_version_integration_tests — e2e via WolverineWebApi including multi_version_endpoint_emits_api_supported_versions_with_sibling_set (TDD-confirmed RED → GREEN), per-version deprecation propagation, isolated v4 attribute-only deprecation fixture (decoupled from Program.cs global Deprecate("1.0") to avoid false positives)
  • ApiVersionResolverTests migrated to ResolveVersions API
  • All ApiVersioning-suite tests green: 102/102

Sample app

  • WolverineWebApi/ApiVersioning/CustomersMultiVersionEndpoint.cs — three versions on one class
  • WolverineWebApi/ApiVersioning/CustomersV4AttributeDeprecatedEndpoint.cs — isolates per-version-attribute deprecation from options-driven deprecation

Docs

  • docs/guide/http/versioning.md extended with multi-version + [MapToApiVersion] sections
  • Per-version metadata semantics table including OperationId row (_v{sanitised-version-string})

Geoffrey MARC added 4 commits May 1, 2026 21:50
…ToApiVersion]

Wolverine.Http now expands handler chains that declare multiple API versions into
one endpoint per version at bootstrap time, before any IHttpPolicy runs. This lets
a single handler method serve several versions (via repeated [ApiVersion]
attributes or class-level [ApiVersion] declarations) while keeping per-version
metadata, routes, and deprecation/sunset policies isolated to each clone.

[MapToApiVersion] is honoured on methods of classes that advertise multiple
versions: the listed subset is registered, with strict validation that every
mapped version exists at class level. Mixing [ApiVersion] and [MapToApiVersion]
on the same method fails fast with a descriptive error.

Per-version OperationId is suffixed with the version to keep ASP.NET Core's
"endpoint names must be globally unique" invariant intact when handlers carry
explicit OperationIds. Each clone receives its own MethodCall instance so
JasperFx codegen wires frame chains independently.
Allow patch and minor updates within the 10.x line so security and bug fixes
flow through automatically while still pinning out of 11.x where breaking
changes might land.
…ne attributes, simplify resolver

ApiVersioningPolicy.AttachMetadata now groups expanded clones by (verb, route-without-prefix)
and seeds each clone's ApiVersionModel with the union of sibling versions in
SupportedApiVersions / DeprecatedApiVersions. Consumers that read ImplementedApiVersions
(api-supported-versions header generators, OpenAPI tooling) see the full sibling set
instead of just the one declared version per clone.

HttpChain.CloneForVersion now scrubs the per-clone endpoint Metadata of any
[ApiVersion] / [MapToApiVersion] attribute that does not match the clone's own version.
applyMetadata() copies every class- and method-level attribute onto each clone, which
made downstream tooling report each clone as implementing every sibling version.

HttpChain.CloneForVersion also sanitises the OperationId suffix with a regex that
keeps only ASCII alphanumerics, so date-based versions like 2024-01-01 produce a
legal identifier (_v2024_01_01) instead of leaking hyphens.

MultiVersionExpansion is renamed Expand -> ExpandInPlace to make the in-place mutation
intent explicit at the call site, and the single-version branch is removed -
ApiVersioningPolicy.ResolveAttributes already handles single-version chains via
ResolveVersions, so the duplication is gone.

ApiVersionResolver loses the throwing Resolve shim (callers now invoke
ResolveVersions(...).FirstOrDefault() directly) and gets a single BuildResolutions
helper that the class-only, method-only, and [MapToApiVersion] branches all call.

WolverineWebApi gains CustomersV4AttributeDeprecatedEndpoint, a v4 route with no
options.Deprecate("4.0") policy, used by the new integration test
v4_endpoint_deprecation_comes_from_attribute_alone to prove the attribute-driven
deprecation path works independently of the options-driven map. The pre-existing
v1 deprecation test was a false positive (Program.cs registers
options.Deprecate("1.0") globally), so it is renamed to clarify it pins down the
options-driven path; the v4 test pins down the attribute-only path.

Tests added: per-clone supported/deprecated split, per-clone endpoint metadata
attribute scrubbing, two distinct multi-version handlers overlapping at a
(verb, route, version) triple, [MapToApiVersion] producing exactly one chain,
unversioned handler alongside multi-version handlers, date-based version
operation-id sanitisation, attribute-only v4 deprecation.

ApiVersionResolverTests.cs migrated from Resolve shim to ResolveVersions API.
Dead Apply helper removed from MultiVersionExpansionTests.cs.
docs/guide/http/versioning.md per-version metadata table updated for OperationId
suffix, the ApiVersionMetadata sibling-union semantics, and the Endpoint.Metadata
attribute-filter behaviour.
…ghten policy diagnostics

The api-supported-versions response header now reads from the matched endpoint's
ApiVersionMetadata (seeded by ApiVersioningPolicy with the full sibling union at
the shared (verb, route-after-strip-prefix)) rather than the options-driven
SunsetPolicies + DeprecationPolicies key union. Chains without per-endpoint
metadata fall back to the previous options-based header.

Cross-class sibling merging in AttachMetadata is now documented in <remarks>
and pinned by an integration test (cross_class_chains_at_same_route_share_supported_versions)
plus the new multi_version_endpoint_emits_api_supported_versions_with_sibling_set
integration test. The duplicate-route diagnostic now uses the version-suffixed
OperationId (instead of the shared DisplayName) so distinct conflicting clones
are individually identifiable; the unversioned-policy error path keeps DisplayName
to preserve user-supplied diagnostic labels. ResolveAttributes now indexes the
resolver result explicitly after a count check, avoiding the
default(ApiVersionResolution) struct foot-gun.

The CloneForVersion attribute filter loop is condensed to one cast per item.
Date-based version assertions in MultiVersionExpansionTests use ShouldStartWith
to leave room for future Asp.Versioning suffix decorations. The duplicate-route
test now asserts both conflicting handler class names appear in the diagnostic.
The OperationId table in docs/guide/http/versioning.md is reworded to describe
the sanitised-version-string suffix and includes the date-based example inline.
@outofrange-consulting

Copy link
Copy Markdown
Contributor Author

@jeremydmiller — could you take a look when you have a moment? This PR touches the core HttpChain and HttpGraph abstractions:

  • HttpChain.CloneForVersion — new per-version expansion primitive. Each clone re-runs the HttpChain ctor (fresh MethodCall to avoid the JasperFx codegen "Frame chain re-arranged" exception) and applies a Metadata.Add callback to scrub sibling [ApiVersion] / [MapToApiVersion] attributes from each clone's metadata bag.
  • HttpGraph.DiscoverEndpointsMultiVersionExpansion.ExpandInPlace is invoked before any IHttpPolicy so middleware composition applies uniformly to all clones.

Two design calls I'd particularly value your eyes on:

  1. Per-clone MethodCall cloning vs. lighter clone path — every other ctor side-effect (attribute application, configure-method discovery, parameter matching, implied middleware) re-runs per clone. For a 5-version handler that's 5× the bootstrap cost. Comment in HttpChain.cs:294 explains why the MethodCall itself must be fresh; the rest of the deep clone is conservative. Worth optimising or fine as-is?
  2. Cross-class metadata merging at shared routeApiVersioningPolicy.AttachMetadata groups by (verb, route-without-version-prefix) across distinct handler classes (intentional per Asp.Versioning conventions; documented in <remarks> and pinned by cross_class_chains_at_same_route_share_supported_versions). Want to confirm this matches your read of the contract before it ships.

Tests cover the load-bearing pieces (TDD RED → GREEN trail on multi_version_endpoint_emits_api_supported_versions_with_sibling_set); 102/102 ApiVersioning suite green.

@jeremydmiller

Copy link
Copy Markdown
Member

@outofrange-consulting I'm going to let you deal w/ the merge conflicts:)

Geoffrey MARC added 2 commits May 4, 2026 21:09
Merge upstream/main into feature/http-multiversion-handlers. Conflicts
isolated to src/Http/Wolverine.Http/ApiVersioning/ApiVersioningPolicy.cs
where upstream introduced [ApiVersionNeutral] support while this branch
introduced multi-version expansion. Resolutions:

- ResolveAttributes: skip resolver work when multi-version expansion has
  already assigned ApiVersion (avoids picking versions[0] on a multi-
  version method); otherwise check ApiVersionNeutralResolver.Resolve and
  fall through to ResolveVersions for single-version chains.
- DetectConflicts: keep upstream's describe() callback for both versioned
  and neutral collision messages; keep this branch's OperationId-based
  naming so version-suffixed clones remain individually identifiable.
- AttachMetadata docstring: combine upstream's neutral-chain rationale
  with this branch's sibling-grouping rationale.
- Helpers: keep both StripVersionPrefix (sibling grouping) and
  EnsureExplicitOperationId (deduped from per-branch usage).

Build green; 102 ApiVersioning tests pass.
…guard

Conflict resolution in 5804e93 re-ordered ResolveAttributes such that
the early `chain.ApiVersion is not null` skip ran *before* the neutrality
check. That broke `method_level_neutral_clears_prior_fluent_apiversion_assignment`
on CI: a chain with fluent `HasApiVersion(...)` set and a method-level
`[ApiVersionNeutral]` would skip the resolver entirely and never get
`IsApiVersionNeutral = true` / its stale fluent version cleared.

Reverse the order so neutrality runs first. Multi-version clones cannot
be neutral (their underlying method declares one or more `[ApiVersion]`,
so `ApiVersionNeutralResolver.Resolve` returns false), so reaching the
neutrality check on a clone is a no-op. The pre-assigned guard then
preserves the clone's already-correct version (and any genuine fluent
single-version assignment).
@outofrange-consulting

Copy link
Copy Markdown
Contributor Author

Superseded by #2683 (combined with #2661 and rebased as requested).

jeremydmiller pushed a commit that referenced this pull request May 7, 2026
…ordering

Squashed from PR #2662 review iterations:
- tighten multi-version metadata isolation and per-clone attribute filtering
- emit per-endpoint api-supported-versions header using sibling version union
- reorder neutrality check before pre-assigned ApiVersion guard
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants