Add [IntroducedInApiVersion] attribute (closes #1183)#1184
Conversation
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>
There was a problem hiding this comment.
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.
| 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 ); | ||
| } | ||
| } |
| 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 ); |
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>
|
Thanks for the review. Pushed three fixes addressing all five comments:
Framework tests: 3350 -> 3355 passing. External reproducer: 15/15 still passing. |
There was a problem hiding this comment.
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
IIntroducedInApiVersionProvideris detected and converted intoIntroducedInApiVersionMetadata, butTryGetApiVersionsdoesn't treatApiVersionProviderOptions.Introducedas 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 theApiVersionSet(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 setendpointModel.DeclaredApiVersionsto{ 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,
};
|
|
||
| result.Add( EndpointType ); | ||
|
|
||
| if ( EndpointType == UserDefined ) | ||
| if ( EndpointType is UserDefined or IntroducedLater ) | ||
| { | ||
| result.Add( ApiVersion ); | ||
| } | ||
|
|
||
| if ( EndpointType == IntroducedLater ) | ||
| { | ||
| result.Add( StatusCode ); | ||
| } | ||
|
|
||
| return result.ToHashCode(); | ||
| } |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Round-2 fixes pushed:
Also pushed Framework tests: 3355 -> 3359 passing. External reproducer: 15 -> 16/16 (added a minimal-API |
| 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>
1c80e03 to
c1207f8
Compare
|
Self-correction: the original Force-pushed If you'd prefer |
| /// 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>
|
Added the OpenAPI demo to
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 |
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>
|
Round-6 fixes pushed:
Strict TDD on each. Representative failing tests + observed initial failures (full text in commit bodies):
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>
|
CodeQL doc finding addressed in |
| emptyVersions = []; | ||
| endpointModel = new( | ||
| declaredVersions: mapped, | ||
| declaredVersions: effectiveMapped, | ||
| supportedVersions: inheritedSupported, | ||
| deprecatedVersions: inheritedDeprecated, |
| emptyVersions = []; | ||
| endpointModel = new( | ||
| declaredVersions: mapped, | ||
| declaredVersions: effectiveMapped, | ||
| supportedVersions: apiModel.SupportedApiVersions, | ||
| deprecatedVersions: apiModel.DeprecatedApiVersions, | ||
| advertisedVersions: emptyVersions, | ||
| deprecatedAdvertisedVersions: emptyVersions ); |
| public interface IActionConventionBuilder : IIntroducedInApiVersionConventionBuilder, IApiVersionConvention<ActionModel> | ||
| { |
| public interface IActionConventionBuilder<out T> : IIntroducedInApiVersionConventionBuilder | ||
| #if NETFRAMEWORK |
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>
|
Round-7 fixes pushed:
Strict TDD on each. Representative red phases (full text in commit bodies):
Framework tests: 3377 → 3386 passing (9 new this round across both convention paths plus the binary-compat pin). External reproducer: 16/16 still passing. |
| /// <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" ); | ||
|
|
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>
|
Two more fixes pushed:
TDD on each (representative red phases in commit bodies):
Framework tests: 3386 → 3394 passing (8 new across these two fixes). External reproducer: 16/16 still passing. |
| 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>
|
Addressed in Fix: hoist the call to a single invocation, then iterate TDD followed. The red phase used reference equality as the pin:
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. |
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
?api-version=vwherev < introducedInandvis declared on the controller404)400)?api-version=vwherev >= introducedIn?api-version=vwherevis unknown to the serverUnsupportedApiVersionStatusCode(default400)/openapi/v<earlier>.jsonAsp.Versioning.Mvc.ApiExplorerfilters via the action's effective version set/openapi/v<introducedIn-or-later>.jsonThe status code is configurable per-attribute (
StatusCode = ...); settingStatusCode = IntroducedInApiVersionAttribute.UseConfiguredStatusCode(the constant0) defers tooptions.UnsupportedApiVersionStatusCodeso the team can keep one global story if they prefer.Example (from
BasicExample)When v4.0 is later added to the controller's
[ApiVersion]declarations,GetModernbecomes reachable for v4.0 with no further changes;GetLegacywould have to be hand-edited to[MapToApiVersion(2.0, 3.0, 4.0)](or the appropriate subset). TheExamples.httpfile inBasicExampleexercises 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.ApplyAsync→ClientErrorEndpointBuilder) and the fast path (INodeBuilderPolicy.BuildJumpTable→ApiVersionPolicyJumpTable.GetDestination) are wired to the same shared helperIntroducedInApiVersionStatusCode.TryGetso they cannot diverge.Routing
EndpointType.IntroducedLaterand a correspondingRouteDestinationslot.ApiVersionMatcherPolicy.GetEdgesadds anIntroducedLateredge for every(version < introducedIn)∩controller.declared, carrying the per-attribute status code.ApiVersionPolicyJumpTable.GetDestinationconsultsIntroducedLaterdestinations only after the normal version lookup misses, so valid<introducedIn>actions still win and unknown versions still fall through toUnsupported(400).EdgeBuilderresolvesStatusCode = 0tooptions.UnsupportedApiVersionStatusCodeat edge-construction time, so noIntroducedLateredge ever carries an invalid 0 status.Conventions
ActionApiVersionConventionBuilderBase.MergeAttributesWithConventionscollectsIIntroducedInApiVersionProviderattributes alongside the existingMappedprovider path.ExpandMappedVersionscomputes{ v ∈ controller.declared : v ≥ introducedIn }and feeds it into the action'sendpointModel.declaredVersions. The result is whatAsp.Versioning.Mvc.ApiExploreralready groups by — so the OpenAPI filtering "just works" without ApiExplorer-specific code.[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.Public API surface
IntroducedInApiVersionAttribute : ApiVersionsBaseAttribute, IIntroducedInApiVersionProviderIIntroducedInApiVersionProviderIntroducedInApiVersionMetadataApiVersionProviderOptions.Introducedenum valueEquals/GetHashCodeoverridden symmetrically to includeStatusCode.XML docs include an
<example>block showing the controller-declared scoping and a<seealso cref="MapToApiVersionAttribute" />so the relationship is discoverable.Tests
IntroducedInApiVersionAttributeTest.csIntroducedInApiVersionConventionTest.cs[MapToApiVersion];[ApiVersionNeutral]controller (introduced metadata ignored, no exception).ApiVersionMatcherPolicyTest.csStatusCode = 0resolves to global default, multi-version forward-compat.ApiVersionMatcherPolicyTest.csApplyAsync, 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 realWebApplicationFactory. 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
Each is a focused unit; happy to squash on merge or restructure further.
Open questions for the maintainer
IntroducedInApiVersionreads well to me; the convention builder method isIntroducedIn(ApiVersion, int statusCode = 404). Is there a name you'd prefer?AddedInApiVersionandSinceApiVersionwere both considered.404on the basis that the URI-as-resource model wants "endpoint didn't exist in this version" to read as "not found." Open to405,410, or making it require an explicit value.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?[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 existingIControllerConvention/IActionConventioninfrastructure. 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.