Skip to content

Add [IntroducedInApiVersion] attribute (closes #1183)#1184

Open
xavierjohn wants to merge 26 commits into
dotnet:mainfrom
xavierjohn:feature/introduced-in-api-version
Open

Add [IntroducedInApiVersion] attribute (closes #1183)#1184
xavierjohn wants to merge 26 commits into
dotnet:mainfrom
xavierjohn:feature/introduced-in-api-version

Conversation

@xavierjohn
Copy link
Copy Markdown
Collaborator

@xavierjohn xavierjohn commented May 10, 2026

Closes #1183.

Summary

Adds a new [IntroducedInApiVersion("X")] attribute that declares an action came into existence in version X and is implicitly part of every controller-declared version >= X. This addresses the "endpoint introduced in a later version" use case that today requires a per-endpoint v1 stub action with [MapToApiVersion] + [ApiExplorerSettings(IgnoreApi = true)].

The attribute is interpreted as a filter over the controller's declared version set — { v ∈ controller.declared : v ≥ introducedIn } — so when the controller adds a new [ApiVersion] declaration in a later release, the action picks it up automatically. No per-action edit required, no codebase-wide sweep across previous [MapToApiVersion] decorations.

Behaviour

Request Action Behaviour
?api-version=v where v < introducedIn and v is declared on the controller Per-attribute status (default 404) Distinct from "version unknown" (still 400)
?api-version=v where v >= introducedIn Reaches the action Normal routing
?api-version=v where v is unknown to the server Configured UnsupportedApiVersionStatusCode (default 400) Global behaviour unchanged
OpenAPI /openapi/v<earlier>.json Action absent Asp.Versioning.Mvc.ApiExplorer filters via the action's effective version set
OpenAPI /openapi/v<introducedIn-or-later>.json Action present

The status code is configurable per-attribute (StatusCode = ...); setting StatusCode = IntroducedInApiVersionAttribute.UseConfiguredStatusCode (the constant 0) defers to options.UnsupportedApiVersionStatusCode so the team can keep one global story if they prefer.

Example (from BasicExample)

[ApiVersion(1.0)]
[ApiVersion(2.0)]
[ApiVersion(3.0)]
[Route("api/v{version:apiVersion}/[controller]")]
public class MultiVersionedController : ControllerBase
{
    [HttpGet]
    public string Get(ApiVersion version) => "Version " + version;

    // Exact-match. v1 → 400, v2 → 200, v3 → 400.
    [HttpGet("legacy"), MapToApiVersion(2.0)]
    public string GetLegacy(ApiVersion version) => "Legacy " + version;

    // From v2 onward. v1 → 404, v2 → 200, v3 → 200 — auto.
    [HttpGet("modern"), IntroducedInApiVersion(2.0)]
    public string GetModern(ApiVersion version) => "Modern " + version;
}

When v4.0 is later added to the controller's [ApiVersion] declarations, GetModern becomes reachable for v4.0 with no further changes; GetLegacy would have to be hand-edited to [MapToApiVersion(2.0, 3.0, 4.0)] (or the appropriate subset). The Examples.http file in BasicExample exercises the contrast across v1/v2/v3 so the behavioural difference is one click apart in HTTP-file tooling.

[MapToApiVersion] semantics are intentionally unchanged — it's still the right tool for the genuinely-version-pinned case (e.g., "this action exists in v2 only and was removed in v3"). The two attributes coexist on the same controller and have non-overlapping semantics.

Implementation

The implementation is split across abstractions, conventions, and routing. Both the slow path (IEndpointSelectorPolicy.ApplyAsyncClientErrorEndpointBuilder) and the fast path (INodeBuilderPolicy.BuildJumpTableApiVersionPolicyJumpTable.GetDestination) are wired to the same shared helper IntroducedInApiVersionStatusCode.TryGet so they cannot diverge.

Routing

  • New EndpointType.IntroducedLater and a corresponding RouteDestination slot.
  • ApiVersionMatcherPolicy.GetEdges adds an IntroducedLater edge for every (version < introducedIn)controller.declared, carrying the per-attribute status code.
  • ApiVersionPolicyJumpTable.GetDestination consults IntroducedLater destinations only after the normal version lookup misses, so valid <introducedIn> actions still win and unknown versions still fall through to Unsupported (400).
  • EdgeBuilder resolves StatusCode = 0 to options.UnsupportedApiVersionStatusCode at edge-construction time, so no IntroducedLater edge ever carries an invalid 0 status.

Conventions

  • ActionApiVersionConventionBuilderBase.MergeAttributesWithConventions collects IIntroducedInApiVersionProvider attributes alongside the existing Mapped provider path.
  • ExpandMappedVersions computes { v ∈ controller.declared : v ≥ introducedIn } and feeds it into the action's endpointModel.declaredVersions. The result is what Asp.Versioning.Mvc.ApiExplorer already groups by — so the OpenAPI filtering "just works" without ApiExplorer-specific code.
  • For multiple [IntroducedInApiVersion] declarations on the same action (e.g., via fluent convention builder), the latest introduced version wins. This is documented in code; tests pin the behaviour.
  • Version-neutral controllers ignore introduced metadata at convention build time (no exception, no effect). Documented and tested.

Public API surface

  • IntroducedInApiVersionAttribute : ApiVersionsBaseAttribute, IIntroducedInApiVersionProvider
  • IIntroducedInApiVersionProvider
  • IntroducedInApiVersionMetadata
  • ApiVersionProviderOptions.Introduced enum value

Equals/GetHashCode overridden symmetrically to include StatusCode.

XML docs include an <example> block showing the controller-declared scoping and a <seealso cref="MapToApiVersionAttribute" /> so the relationship is discoverable.

Tests

Surface Where What
Attribute equality, hashing, parser overloads IntroducedInApiVersionAttributeTest.cs Same version + same status → equal. Same version + different status → not equal + different hashes. Different version + same status → not equal. Multiple constructor shapes (string, double, year/month/day, ApiVersion).
Conventions IntroducedInApiVersionConventionTest.cs Single declaration; multiple declarations (latest wins); combined with [MapToApiVersion]; [ApiVersionNeutral] controller (introduced metadata ignored, no exception).
Routing — fast path ApiVersionMatcherPolicyTest.cs Edge generation, jump-table destinations, StatusCode = 0 resolves to global default, multi-version forward-compat.
Routing — slow path ApiVersionMatcherPolicyTest.cs Same scenarios via ApplyAsync, asserting fast and slow paths produce identical outcomes.

Total framework tests: 3350 passing (was 3342 on main; 8 new tests added by this PR).

An external runnable reproducer (xUnit project against Asp.Versioning.Mvc 10.0.0) exercises the same scenarios end-to-end against a real WebApplicationFactory. All scenarios pass against this branch's packages. Happy to share the project as a zip attachment or push it to a public repo on request.

Commits

112373d Add introduced-in API version attribute
061b136 Fix introduced-in version routing
991b14c Fix introduced-in attribute equality and docs
ead80dd Align introduced-in routing status selection
469e4f5 Define introduced-in convention edge cases
94c0814 Demonstrate IntroducedInApiVersion in BasicExample

Each is a focused unit; happy to squash on merge or restructure further.

Open questions for the maintainer

  1. NamingIntroducedInApiVersion reads well to me; the convention builder method is IntroducedIn(ApiVersion, int statusCode = 404). Is there a name you'd prefer? AddedInApiVersion and SinceApiVersion were both considered.
  2. Default status code — defaulting to 404 on the basis that the URI-as-resource model wants "endpoint didn't exist in this version" to read as "not found." Open to 405, 410, or making it require an explicit value.
  3. AllowMultiple = false — the attribute itself is single-use per action; the convention builder accepts multiple declarations and uses "latest wins." Is that the right call, or should the convention builder also restrict to one?
  4. Companion [DeprecatedInApiVersion]? — out of scope for this PR. Mentioning only because the symmetry is natural and you may want the design space considered before this lands. Happy to follow up separately.

Related discussion

This PR was preceded by an earlier exchange on the issue thread covering the framework's representation-vs-identity model and the practical case for declarative range semantics. The implementation respects the position laid out there: [MapToApiVersion] keeps its exact-match behaviour; [IntroducedInApiVersion] is an additive declarative convenience, not a replacement, and it composes with the existing IControllerConvention / IActionConvention infrastructure. If you'd still prefer this live in user code via the public extension surface rather than as a first-class attribute, I'm happy to repackage it that way; this PR is the "first-class attribute" version for your consideration.

xavierjohn and others added 6 commits May 9, 2026 22:52
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Route fast-path introduced-later matches to the configured introduced-in endpoint instead of unsupported-version handling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a third API version (3.0) to MultiVersionedController and a new
[IntroducedInApiVersion(2.0)]-decorated action alongside the existing
[MapToApiVersion(2.0)] action so the contrast is visible in one file:

- /multiversioned/legacy with [MapToApiVersion(2.0)] is exact-match;
  v1 and v3 callers receive the configured UnsupportedApiVersionStatusCode.
- /multiversioned/modern with [IntroducedInApiVersion(2.0)] is from-v2-onward;
  v1 callers receive the per-attribute status (default 404), v2 and v3
  callers reach the action, and adding a future v4 to the controller's
  [ApiVersion] declarations extends the action automatically.

Examples.http exercises both actions across all three versions so the
behavioural difference is one click apart in HTTP-file tooling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@xavierjohn xavierjohn requested a review from Copilot May 10, 2026 05:55
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a new [IntroducedInApiVersion] attribute and supporting plumbing so actions can be treated as “available from version X onward” within a controller’s declared version set, improving ergonomics versus per-action [MapToApiVersion] stubs.

Changes:

  • Add new public abstractions (IntroducedInApiVersionAttribute, metadata, provider option) and propagate introduced-version metadata through conventions and endpoint metadata.
  • Extend routing policy (fast + slow paths) to return an “introduced later” client error endpoint for controller-declared versions earlier than the action’s introduced version.
  • Add unit tests and update the BasicExample to demonstrate the new behavior.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs Collect introduced-version attributes and expand them into effective action mappings.
src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs Adds convention-level tests for introduced-version expansion and metadata emission.
src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs Adds routing tests for introduced-later selection (jump table + selector).
src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs Uses expanded mappings and attaches introduced metadata to MVC action endpoints.
src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiVersionCollator.cs Preserves introduced metadata when collating endpoint models.
src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs Adds storage for introduced-later destinations in jump tables.
src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs Centralizes “introduced later” status-code selection across metadata sources.
src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs Adds a dedicated endpoint that returns the introduced-later status code.
src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs Introduces a new endpoint type for “introduced later” routing decisions.
src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs Adds status-code awareness for introduced-later edges.
src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs Emits introduced-later edges and resolves StatusCode=0 to configured unsupported status code.
src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs Selects the introduced-later endpoint on the slow path when appropriate.
src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs Consults introduced-later destinations after normal version lookup misses.
src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs Builds introduced-later edges/destinations and includes introduced scenarios in version enumeration.
src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs Converts introduced providers to IntroducedInApiVersionMetadata and passes into ApiVersionMetadata.
src/AspNet/WebApi/src/Asp.Versioning.WebApi/Conventions/ActionApiVersionConventionBuilderBase.cs Mirrors MVC convention behavior for ASP.NET Web API.
src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs Adds abstraction-level tests for constructor parsing and equality/hashing.
src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionMetadata.cs Adds a public metadata type representing introduced-in version and status code.
src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs Adds the public attribute API and status-code semantics.
src/Abstractions/src/Asp.Versioning.Abstractions/IIntroducedInApiVersionProvider.cs Adds provider interface for introduced metadata (includes status code).
src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionProviderOptions.cs Adds Introduced provider option value.
src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionMetadata.cs Extends ApiVersionMetadata to carry introduced metadata.
examples/AspNetCore/WebApi/BasicExample/Examples.http Demonstrates legacy exact-match vs introduced-from behavior across versions.
examples/AspNetCore/WebApi/BasicExample/Controllers/MultiVersionedController.cs Updates example controller to show [IntroducedInApiVersion] on an action.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +134 to +154
var versions = mapped is null ? [] : new HashSet<ApiVersion>( mapped );

var effectiveIntroduced = introduced[0].Version;

for ( var i = 1; i < introduced.Count; i++ )
{
if ( introduced[i].Version > effectiveIntroduced )
{
effectiveIntroduced = introduced[i].Version;
}
}

for ( var i = 0; i < declaredVersions.Count; i++ )
{
var declaredVersion = declaredVersions[i];

if ( declaredVersion >= effectiveIntroduced )
{
versions.Add( declaredVersion );
}
}
Comment on lines 36 to 43
var introducedInApiVersionStatusCode = GetIntroducedInApiVersionStatusCode();

if ( introducedInApiVersionStatusCode > 0 )
{
return new IntroducedInApiVersionEndpoint( introducedInApiVersionStatusCode );
}

return new UnsupportedApiVersionEndpoint( options );
case EndpointType.IntroducedLater:
routePatterns ??= [.. state.RoutePatterns];
introducedLater ??= new();
introducedLater.TryAdd( state.ApiVersion, edge.Destination );
xavierjohn and others added 3 commits May 9, 2026 23:10
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@xavierjohn
Copy link
Copy Markdown
Collaborator Author

xavierjohn commented May 10, 2026

Thanks for the review. Pushed three fixes addressing all five comments:

  • CodeQL Equals-with-is5a80710 adds an exact-type check (GetType() == obj.GetType()); test introduced_in_api_version_attribute_should_not_equal_derived_type.
  • CodeQL foreach -> Where — folded into 865f35d via OfType<…>() in the relevant scan.
  • ExpandMappedVersions runtime crash on [IntroducedInApiVersion]-only path658a0f6 always materializes HashSet<ApiVersion> (the ICollection<ApiVersion> return type was binding [] to an array). New regression test apply_to_should_not_throw_when_action_has_only_introduced_version pins this path which the previous test suite didn't exercise.
  • Slow-path / fast-path divergence on StatusCode = 0865f35d resolves 0 -> options.UnsupportedApiVersionStatusCode inside the slow-path builder (mirroring what EdgeBuilder does at edge-construction time), so both paths now return the same IntroducedInApiVersionEndpoint with the same resolved code. New slow-path test apply_should_use_configured_status_code_for_introduced_status_code_zero.
  • Nondeterministic introduced-later destination dedup865f35d picks the SMALLEST status code when multiple introduced-later edges share an ApiVersion (deterministic, matches the 'latest introduced version wins' semantics already in place for the convention path). New test jump_table_should_use_smallest_status_code_for_same_introduced_version.

Framework tests: 3350 -> 3355 passing. External reproducer: 15/15 still passing.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs:193

  • IIntroducedInApiVersionProvider is detected and converted into IntroducedInApiVersionMetadata, but TryGetApiVersions doesn't treat ApiVersionProviderOptions.Introduced as a mapping option. As a result, an endpoint that only uses [IntroducedInApiVersion] (or .WithMetadata(new IntroducedInApiVersionAttribute(...))) will still have a derived endpoint model and can implicitly map to all versions in the ApiVersionSet (including versions earlier than the introduction), which defeats the feature. Consider applying the same expansion/filtering logic used by the MVC/WebApi conventions here (compute the effective introduced version and set endpointModel.DeclaredApiVersions to { v ∈ apiModel.Declared : v >= introducedIn }, optionally unioning any explicit mapped versions).
            if ( item is IIntroducedInApiVersionProvider introduced )
            {
                var introducedVersions = introduced.Versions;

                for ( var j = 0; j < introducedVersions.Count; j++ )
                {
                    metadata.Add( new IntroducedInApiVersionMetadata( introducedVersions[j], introduced.StatusCode ) );
                }
            }

            metadata.RemoveAt( i );

            var versions = provider.Versions;
            var target = provider.Options switch
            {
                None => supported ??= [],
                Mapped => mapped ??= [],
                Deprecated => deprecated ??= [],
                Advertised => advertised ??= [],
                Advertised | Deprecated => deprecatedAdvertised ??= [],
                _ => default,
            };

Comment on lines 72 to 86

result.Add( EndpointType );

if ( EndpointType == UserDefined )
if ( EndpointType is UserDefined or IntroducedLater )
{
result.Add( ApiVersion );
}

if ( EndpointType == IntroducedLater )
{
result.Add( StatusCode );
}

return result.ToHashCode();
}
xavierjohn and others added 2 commits May 10, 2026 07:32
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@xavierjohn
Copy link
Copy Markdown
Collaborator Author

xavierjohn commented May 10, 2026

Round-2 fixes pushed:

  • Minimal-API endpoints not filtering on [IntroducedInApiVersion]aac5a7d adds an IIntroducedInApiVersionConvention carrier and an IntroducedInApiVersion(...) extension method on IEndpointConventionBuilder (mirroring the existing MapToApiVersion(...) overload shape). EndpointBuilderFinalizer.Build now applies the same { v ∈ apiModel.declared : v >= introducedIn } expansion the MVC convention does, so an introduced-in-only endpoint no longer derives from the full api version set. New tests cover (a) the convention path, (b) the expansion math, (c) explicit HasApiVersion precedence over the introduced range.
  • EdgeKey.Equals hash-only equality — confirmed; 86b2f1f replaces with direct field comparison (EndpointType, ApiVersion, and StatusCode only when EndpointType == IntroducedLater). New test pins distinct IntroducedLater keys with different status codes to non-equal.

Also pushed 1c80e03 adding the same [IntroducedInApiVersion] vs [MapToApiVersion] contrast to MinimalApiExample/Program.cs as in BasicExample, so the minimal-API surface is demonstrated in the examples folder too. Examples.http exercises both endpoints across v1/v2/v3.

Framework tests: 3355 -> 3359 passing. External reproducer: 15 -> 16/16 (added a minimal-API Claim_13 to pin the consuming surface that triggered finding A).

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 28 out of 28 changed files in this pull request and generated 1 comment.

Comment on lines +55 to +81
for ( var i = 0; i < candidates.Count; i++ )
{
ref readonly var candidate = ref candidates[i];
var metadata = candidate.Endpoint.Metadata.GetMetadata<ApiVersionMetadata>();

if ( metadata is null )
{
continue;
}

metadata.Deconstruct( out var apiModel, out _ );

if ( !apiModel.DeclaredApiVersions.Contains( apiVersion ) )
{
continue;
}

if ( IntroducedInApiVersionStatusCode.TryGet( candidate.Endpoint, metadata, apiVersion, out var statusCode ) )
{
if ( statusCode == IntroducedInApiVersionAttribute.UseConfiguredStatusCode )
{
statusCode = options.UnsupportedApiVersionStatusCode;
}

return statusCode;
}
}
Mirrors the BasicExample contrast on the minimal-API surface:

- /weatherforecast/legacy with .HasApiVersion(2.0) is exact-match;
  v1 and v3 callers receive the configured UnsupportedApiVersionStatusCode.
- /weatherforecast/modern with .IntroducedInApiVersion(2.0) is from-v2-onward;
  v1 callers receive the per-attribute status (default 404), v2 and v3
  callers reach the endpoint, and adding a future v4 to the api version
  set extends the endpoint automatically.

A small /weatherforecast/v3 endpoint declares v3.0 so the api version
set contains v1, v2, and v3 — that is the set [IntroducedInApiVersion]
filters against. Examples.http exercises both endpoints across all
three versions so the behavioural difference is one click apart in
HTTP-file tooling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@xavierjohn xavierjohn force-pushed the feature/introduced-in-api-version branch from 1c80e03 to c1207f8 Compare May 10, 2026 14:58
@xavierjohn
Copy link
Copy Markdown
Collaborator Author

xavierjohn commented May 10, 2026

Self-correction: the original MinimalApiExample change (commit 1c80e03) used app.NewVersionedApi() (the implicit version-set group) plus a .IntroducedInApiVersion(...) endpoint with no companion .HasApiVersion(...) declaration. End-to-end testing showed v2/v3 callers got 400 UnsupportedApiVersion instead of reaching the endpoint — the introduced-in expansion couldn't see the group's collated version set in time.

Force-pushed c1207f8 switching the demo to the explicit NewApiVersionSet(...).Build() pattern (the same one the verify project uses). Verified end-to-end against a running instance: v1 -> 404, v2 -> 200, v3 -> 200 for /multiversioned/modern; v1 -> 400, v2 -> 200, v3 -> 400 for /multiversioned/legacy. Demonstrates the contrast cleanly.

If you'd prefer .IntroducedInApiVersion(...) to also work without an explicit version set in the NewVersionedApi() group case, that's a separate behaviour gap I can investigate — but it might be a deliberate consequence of how the implicit group collates versions, in which case the docs/extension XML should call it out. Happy to follow up with whichever direction you prefer.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 28 out of 28 changed files in this pull request and generated 1 comment.

Comment on lines +39 to +41
/// Gets a value indicating whether any explicit API version mappings are configured.
/// </summary>
/// <value>True if explicit API version mappings are configured; otherwise, false.</value>
Adds a multi-versioned controller alongside the V1/V2/V3-folder controllers
so the per-version OpenAPI surface visually shows what [IntroducedInApiVersion]
does vs. [MapToApiVersion]:

- v1.json contains only the shared GET; both /legacy and /modern are absent.
- v2.json contains the shared GET plus /legacy plus /modern.
- v3.json contains the shared GET plus /modern; /legacy is absent because
  [MapToApiVersion(2.0)] is exact-match, while [IntroducedInApiVersion(2.0)]
  expanded to v2 and v3 automatically when v3 was added to the controller.

Users can open /scalar/v1 vs /scalar/v3 in the dev environment to see the
action appear/disappear without writing any [ApiExplorerSettings] stubs.

Verified end-to-end: routing 9/9 status codes match, OpenAPI v1/v2/v3.json
paths arrays match the documented expectations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@xavierjohn
Copy link
Copy Markdown
Collaborator Author

xavierjohn commented May 10, 2026

Added the OpenAPI demo to OpenApiExample (commit 0690b02). A new MultiVersionedController declaring v1.0/v2.0/v3.0 sits alongside the V1/V2/V3-folder controllers; one action uses [MapToApiVersion(2.0)] and one uses [IntroducedInApiVersion(2.0)]. The per-version OpenAPI documents now visually show the difference:

Doc /api/MultiVersioned /legacy /modern
v1.json (absent) (absent)
v2.json
v3.json (absent — exact-match) ✓ (forward-compat)

Verified end-to-end against a running instance: 9/9 status codes match (200 / 200 / 200 / 200 / 200 / 200 / 400 / 400 / 404 / 404 across the v1/v2/v3 × shared/legacy/modern grid) and the three openapi/vN.json paths arrays match the table above. Users can open /scalar/v1 vs /scalar/v3 in dev to see the action appear/disappear without any [ApiExplorerSettings] stubs.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 34 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs: Language not supported

xavierjohn and others added 2 commits May 10, 2026 10:59
TDD failing tests:
- jump_table_should_write_requested_url_segment_in_problem_details_for_introduced_endpoint
Observed initial failure message verbatim:
Expected root.GetProperty( "detail").GetString() to be a match with the expectation, but it differs at index 165:
 (actual)
  ".version ''."
  ".version '1'."
              (expected)
- url_segment_introduced_endpoint_should_write_same_problem_details_from_jump_table_and_apply
Observed initial failure message verbatim:
Expected ( ReadResponseBody( fast ) ) to be a match with the expectation, but it differs at index 317:
                   (actual)
  ".version \u0027\u0027.","code":"EndpointNotIntroduced"}"
  ".version \u00271\u0027.","code":"EndpointNotIntroduced"}"
                   (expected)
- url_segment_introduced_endpoint_should_use_latest_matching_introduced_version
Observed initial failure message verbatim:
Expected root.GetProperty( "detail").GetString() to be a match with the expectation, but it differs at index 165:
              (actual)
  ".version ''."
  ".version '1'."
              (expected)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
TDD failing tests:
- apply_to_should_expand_declared_api_versions_from_introduced_convention
- introduced_in_api_version_should_support_fluent_overloads
- introduced_convention_should_match_attribute_declared_versions
Observed initial failure message verbatim:
C:\GitHub\aspnet-api-versioning-gpt55\src\AspNetCore\WebApi\test\Asp.Versioning.Mvc.Tests\Conventions\ActionApiVersionConventionBuilderTest.cs(127,23): error CS1061: 'ActionApiVersionConventionBuilder' does not contain a definition for 'IntroducedInApiVersion' and no accessible extension method 'IntroducedInApiVersion' accepting a first argument of type 'ActionApiVersionConventionBuilder' could be found (are you missing a using directive or an assembly reference?)
C:\GitHub\aspnet-api-versioning-gpt55\src\AspNetCore\WebApi\test\Asp.Versioning.Mvc.Tests\Conventions\ActionApiVersionConventionBuilderTest.cs(152,23): error CS1061: 'ActionApiVersionConventionBuilder' does not contain a definition for 'IntroducedInApiVersion' and no accessible extension method 'IntroducedInApiVersion' accepting a first argument of type 'ActionApiVersionConventionBuilder' could be found (are you missing a using directive or an assembly reference?)
C:\GitHub\aspnet-api-versioning-gpt55\src\AspNetCore\WebApi\test\Asp.Versioning.Mvc.Tests\Conventions\ActionApiVersionConventionBuilderTest.cs(186,23): error CS1061: 'ActionApiVersionConventionBuilder' does not contain a definition for 'IntroducedInApiVersion' and no accessible extension method 'IntroducedInApiVersion' accepting a first argument of type 'ActionApiVersionConventionBuilder' could be found (are you missing a using directive or an assembly reference?)

Design: added IIntroducedInApiVersionConventionBuilder so IActionConventionBuilder references returned by fluent Action(...) helpers also get the same overload shape as MapToApiVersion, while concrete MVC/WebApi action builders expose the ApiVersion overload directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@xavierjohn
Copy link
Copy Markdown
Collaborator Author

Round-6 fixes pushed:

  • URL-segment fast path doesn't populate RawRequestedApiVersion before introduced rejection3db2ee1. The fast path's ApiVersionPolicyJumpTable.GetDestination now writes both RawRequestedApiVersion and RequestedApiVersion back to IApiVersioningFeature before returning the IntroducedLater destination when the version came from a URL segment. The slow path was already correct because the route constraint runs first; this brings the fast path into parity. The bug was uncaught because all prior parity tests used QueryStringApiVersionReader; new URL-segment-versioning tests (fast path standalone, fast/slow parity, latest-introduced-wins via URL segment) now mirror the existing query-string coverage.

  • MVC/WebApi convention builders missing fluent IntroducedInApiVersion(...)ff5ed87. Adds public fluent overloads to the MVC and classic-WebApi convention builders, mirroring the existing MapToApiVersion(...) overload shape (numeric, double, year/month/day, DateOnly, ApiVersion) plus an int statusCode parameter defaulting to IntroducedInApiVersionAttribute.DefaultStatusCode. The shape matches the minimal-API .IntroducedInApiVersion(...) extension exactly, so users get a consistent fluent experience across MVC, classic WebApi, and minimal APIs.

    Design choice (deviation): also introduced an IIntroducedInApiVersionConventionBuilder interface so the fluent results returned by Action(...) helpers (which return IActionConventionBuilder) also get the overloads, matching how MapToApiVersion(...) is currently structured.

Strict TDD on each. Representative failing tests + observed initial failures (full text in commit bodies):

  • jump_table_should_write_requested_url_segment_in_problem_details_for_introduced_endpointExpected detail to be ".version '1'.", but got ".version ''.". The empty quoted version was the smoking gun — RawRequestedApiVersion was unset when the problem detail formatter ran.
  • url_segment_introduced_endpoint_should_write_same_problem_details_from_jump_table_and_apply — fast/slow parity test on URL-segment versioning, same root cause.
  • apply_to_should_expand_declared_api_versions_from_introduced_convention — initially failed to compile: 'ActionApiVersionConventionBuilder' does not contain a definition for 'IntroducedInApiVersion'. The test was written first, the missing-API failure observed, then the API was added.

Framework tests: 3370 → 3377 passing (7 new). External reproducer: 16/16 still passing.

CodeQL flagged the extension<T>(...) block in ApiVersionConventionBuilderExtensions.cs
as missing a <summary> doc comment. Added one matching the prose style used
elsewhere in the file.

No behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@xavierjohn
Copy link
Copy Markdown
Collaborator Author

CodeQL doc finding addressed in 0eb7922 — added a <summary> to the extension block introduced in commit ff5ed87. Pure doc fix, no behavior change.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 42 out of 43 changed files in this pull request and generated 4 comments.

Files not reviewed (1)
  • src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs: Language not supported

Comment on lines 72 to 76
emptyVersions = [];
endpointModel = new(
declaredVersions: mapped,
declaredVersions: effectiveMapped,
supportedVersions: inheritedSupported,
deprecatedVersions: inheritedDeprecated,
Comment on lines 72 to 78
emptyVersions = [];
endpointModel = new(
declaredVersions: mapped,
declaredVersions: effectiveMapped,
supportedVersions: apiModel.SupportedApiVersions,
deprecatedVersions: apiModel.DeprecatedApiVersions,
advertisedVersions: emptyVersions,
deprecatedAdvertisedVersions: emptyVersions );
Comment on lines 19 to 20
public interface IActionConventionBuilder : IIntroducedInApiVersionConventionBuilder, IApiVersionConvention<ActionModel>
{
Comment on lines 17 to 18
public interface IActionConventionBuilder<out T> : IIntroducedInApiVersionConventionBuilder
#if NETFRAMEWORK
xavierjohn and others added 2 commits May 10, 2026 11:49
Failing tests added first:

- Asp.Versioning.Conventions.ActionApiVersionConventionBuilderTest.apply_to_should_intersect_supported_api_versions_with_introduced_convention (MVC): Expected model.SupportedApiVersions {1.0, 2.0, 3.0} to not contain 1.0.

- Asp.Versioning.Conventions.ActionApiVersionConventionBuilderTest.apply_to_should_intersect_supported_api_versions_with_introduced_convention (WebApi): Expected model.SupportedApiVersions {1.0, 2.0, 3.0} to not contain 1.0.

The fix intersects inherited supported/deprecated versions with introduced-expanded mapped versions only when introduced versions are present. MapToApiVersion-only actions keep inherited supported/deprecated versions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Added binary compatibility regression tests:

- Asp.Versioning.Conventions.ActionApiVersionConventionBuilderTest.custom_action_convention_builder_should_not_implement_introduced_contract

- Asp.Versioning.Conventions.ActionApiVersionConventionBuilderTTest.custom_action_convention_builder_should_not_implement_introduced_contract

Observed initial compile failure: error CS0535: 'ActionApiVersionConventionBuilderTest.CustomActionConventionBuilder' does not implement interface member 'IIntroducedInApiVersionConventionBuilder.IntroducedInApiVersion(ApiVersion, int)'

Observed initial compile failure: error CS0535: 'ActionApiVersionConventionBuilderTTest.CustomActionConventionBuilder' does not implement interface member 'IIntroducedInApiVersionConventionBuilder.IntroducedInApiVersion(ApiVersion, int)'

The built-in action convention builders now implement IIntroducedInApiVersionConventionBuilder directly while IActionConventionBuilder and IActionConventionBuilder<T> retain the main-compatible IMapToApiVersionConventionBuilder surface.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@xavierjohn
Copy link
Copy Markdown
Collaborator Author

Round-7 fixes pushed:

  • Convention builders' endpointModel.supportedVersions not intersected with introduced range6a455a3. Both ASP.NET Core MVC and classic ASP.NET WebApi convention builders now intersect inheritedSupported and inheritedDeprecated with effectiveMapped when introduced expansion is in play, mirroring what the minimal-API path (EndpointBuilderFinalizer.Build) already does correctly. For an action introduced in v2 on a controller declaring v1/v2/v3, the resulting endpointModel.SupportedApiVersions is now {2.0, 3.0} (not {1.0, 2.0, 3.0}), so api-supported-versions headers and metadata.Map(Explicit | Implicit) callers see a consistent reachable set. [MapToApiVersion]-only actions (no introduced expansion) keep the existing behavior — the intersection only happens on the introduced path.

  • IActionConventionBuilder / IActionConventionBuilder<T> binary-breaking change from round 61baa005. Reverted the addition of IIntroducedInApiVersionConventionBuilder to the inheritance chain of those two public interfaces. The concrete built-in ActionApiVersionConventionBuilder (and classic-WebApi equivalent) implement IIntroducedInApiVersionConventionBuilder directly, so the fluent IntroducedInApiVersion(...) extensions still work transparently when callers use the built-in builders. External implementers of IActionConventionBuilder have the same contract as main — no forced new member.

Strict TDD on each. Representative red phases (full text in commit bodies):

  • apply_to_should_intersect_supported_api_versions_with_introduced_conventionExpected model.SupportedApiVersions {1.0, 2.0, 3.0} to not contain 1.0. Added one for ASP.NET Core MVC and one mirrored for classic WebApi.
  • custom_action_convention_builder_should_not_implement_introduced_contract — used compile-failure-as-red-test: a stub CustomActionConventionBuilder that deliberately implements only the main-shape members. Initial compile failure: CS0535 'CustomActionConventionBuilder' does not implement interface member 'IIntroducedInApiVersionConventionBuilder.IntroducedInApiVersion(ApiVersion, int)'. After restoring the interface hierarchy, the stub compiles — pinning the no-binary-break property going forward.

Framework tests: 3377 → 3386 passing (9 new this round across both convention paths plus the binary-compat pin). External reproducer: 16/16 still passing.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs: Language not supported

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 42 out of 43 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs: Language not supported

Comment on lines +25 to +33
/// <summary>
/// Gets the problem details for an API endpoint introduced in a later API version.
/// </summary>
public static ProblemDetailsInfo Introduced =>
introduced ??= new(
"https://docs.api-versioning.org/problems#introduced",
"API endpoint not yet introduced",
"EndpointNotIntroduced" );

xavierjohn and others added 2 commits May 10, 2026 12:28
Failing tests added first:

- with_api_version_set_should_apply_introduced_version_to_explicit_supported_versions: Expected model.DeclaredApiVersions to be equal to {3.0}, but {1.0} differs at index 0.
- with_api_version_set_should_expand_mapped_versions_from_introduced_version: Expected model.DeclaredApiVersions to be equal to {2.0, 3.0, 4.0}, but {2.0} contains 2 item(s) less.
- with_api_version_set_should_mirror_mvc_when_supported_version_is_combined_with_introduced_version(version: 1): Expected model.DeclaredApiVersions to be equal to {3.0, 4.0}, but {1.0} contains 1 item(s) less.
- with_api_version_set_should_mirror_mvc_when_supported_version_is_combined_with_introduced_version(version: 4): Expected model.DeclaredApiVersions to be equal to {3.0, 4.0}, but {4.0} contains 1 item(s) less.
- with_api_version_set_should_mirror_mvc_when_deprecated_version_is_combined_with_introduced_version: Expected model.DeclaredApiVersions to be equal to {3.0, 4.0}, but {1.0} contains 1 item(s) less.

Semantics: MVC HasMappedVersions includes introduced versions, so HasApiVersion/HasDeprecatedApiVersion combined with IntroducedInApiVersion is treated as an introduced-and-later action mapping. Explicit supported/deprecated buckets do not narrow the expansion; the effective endpoint declared/implemented versions are inherited API versions greater than or equal to the latest introduced version, plus any MapToApiVersion versions. The old prefer-explicit minimal API test was updated because that assertion was the divergence.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Apps using AddErrorObjects format their API-versioning errors via
ErrorObjectWriter, but its CanWrite() only matched four problem types
(Unsupported, Unspecified, Invalid, Ambiguous). The Introduced type added
in commit 5ab4956 was not recognized, so apps configured for error-object
format would fall back to the default ProblemDetails writer for the new
IntroducedInApiVersion rejection responses, producing an inconsistent
wire format vs. the other API-versioning errors.

Failing test added first:

- ErrorObjectWriterTest.can_write_should_be_true_for_api_versioning_problem_types(type: "https://docs.api-versioning.org/problems#introduced") initially failed with: Expected result to be True, but found False.

Fix: include ProblemDetailsDefaults.Introduced.Type in the CanWrite()
disjunction. Existing parameterized test now covers the introduced type
as a regression pin alongside the other four.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@xavierjohn
Copy link
Copy Markdown
Collaborator Author

Two more fixes pushed:

  • Minimal-API path skipped introduced-in expansion when combined with HasApiVersion / MapToApiVersion / HasDeprecatedApiVersion (bbdb874) — code review surfaced this. EndpointBuilderFinalizer.Build only invoked ExpandIntroducedVersions in the buckets.AreEmpty branch, so any combination with explicit version declarations silently skipped the expansion. Real correctness consequence: .HasApiVersion(1.0).IntroducedInApiVersion(3.0) would actually run the user's delegate for v=1.0 instead of rejecting. Fix mirrors MVC's ExpandMappedVersions semantics — HasApiVersion/HasDeprecatedApiVersion combined with introduced-in is treated as an introduced-and-later mapping; explicit supported/deprecated buckets do not narrow the expansion. The previous "prefer explicit" minimal-API test was updated because that assertion was the divergence. Cross-surface symmetry test pinned: same (version set, declarations) produces identical endpointModel.DeclaredApiVersions whether configured via MVC convention or minimal-API extension.
  • ErrorObjectWriter.CanWrite didn't recognize ProblemDetailsDefaults.Introduced (513fa4c) — apps using AddErrorObjects got error-object format for Unsupported/Unspecified/Invalid/Ambiguous but fell through to standard ProblemDetails for the new Introduced type, creating an inconsistent wire format. Added Introduced to the CanWrite disjunction. Existing parameterized test extended with one more [InlineData] row pinning regression.

TDD on each (representative red phases in commit bodies):

  • with_api_version_set_should_apply_introduced_version_to_explicit_supported_versionsExpected model.DeclaredApiVersions to be equal to {3.0}, but {1.0} differs at index 0.
  • with_api_version_set_should_expand_mapped_versions_from_introduced_versionExpected model.DeclaredApiVersions to be equal to {2.0, 3.0, 4.0}, but {2.0} contains 2 item(s) less.
  • can_write_should_be_true_for_api_versioning_problem_types(type: "https://docs.api-versioning.org/problems#introduced")Expected result to be True, but found False.

Framework tests: 3386 → 3394 passing (8 new across these two fixes). External reproducer: 16/16 still passing.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 45 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs: Language not supported

Comment on lines +84 to +92
metadata = new( apiModel, endpointModel, name, GetIntroducedApiVersionMetadata() );
}

item.AddEndpointMetadata( metadata );

if ( !metadata.IsApiVersionNeutral )
{
AddIntroducedApiVersionMetadata( item.AddEndpointMetadata );
}
ApplyTo previously called GetIntroducedApiVersionMetadata twice: once when
constructing ApiVersionMetadata, and again indirectly via
AddIntroducedApiVersionMetadata. Each call sorted the introduced list and
allocated a fresh array, so the consolidated
ApiVersionMetadata.IntroducedInApiVersions and the standalone
IntroducedInApiVersionMetadata items in EndpointMetadata were two
separate sets of instances containing the same data.

Failing test added first:

- IntroducedInApiVersionConventionTest.apply_to_should_share_introduced_metadata_instances_across_endpoint_and_api_version_metadata initially failed with: Expected ReferenceEquals( consolidated[i], standalone[i] ) to be True because consolidated[0] and standalone[0] should be the same instance, but found False.

Fix: iterate the consolidated metadata.IntroducedInApiVersions list directly
when attaching the standalone endpoint metadata, so both views share the
same instances. Single sort, single allocation. The test now passes and pins
the sharing as a regression check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@xavierjohn
Copy link
Copy Markdown
Collaborator Author

Addressed in 0402e70. ActionApiVersionConventionBuilderBase.ApplyTo (MVC) previously called GetIntroducedApiVersionMetadata() twice — once to populate the consolidated ApiVersionMetadata.IntroducedInApiVersions, and again indirectly via AddIntroducedApiVersionMetadata(item.AddEndpointMetadata) to attach standalone IntroducedInApiVersionMetadata items. Each call sorted the list and allocated a fresh array, so the two views ended up as separate instances of the same data.

Fix: hoist the call to a single invocation, then iterate metadata.IntroducedInApiVersions directly to attach the standalone items. Both views now share the same instances (single sort, single allocation).

TDD followed. The red phase used reference equality as the pin:

  • apply_to_should_share_introduced_metadata_instances_across_endpoint_and_api_version_metadata initially failed with: Expected ReferenceEquals(consolidated[i], standalone[i]) to be True because consolidated[0] and standalone[0] should be the same instance, but found False.

After the fix it passes. The test stays as a regression check — if anyone reintroduces the duplicate evaluation, the test fails immediately.

Framework tests: 3394 → 3395 passing (+1). External reproducer: 16/16 still passing.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 45 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs: Language not supported

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.

Feature request: declarative attribute for endpoints introduced in a specific API version

3 participants