From 112373dbff70f15f12f46556fd9abf4a590eb18f Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sat, 9 May 2026 22:52:40 -0700 Subject: [PATCH 01/26] Add introduced-in API version attribute Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApiVersionMetadata.cs | 15 +- .../ApiVersionProviderOptions.cs | 7 + .../IIntroducedInApiVersionProvider.cs | 15 ++ .../IntroducedInApiVersionAttribute.cs | 84 +++++++++++ .../IntroducedInApiVersionMetadata.cs | 32 +++++ .../IntroducedInApiVersionAttributeTest.cs | 65 +++++++++ .../ActionApiVersionConventionBuilderBase.cs | 7 +- .../Builder/EndpointBuilderFinalizer.cs | 19 ++- .../Routing/ClientErrorEndpointBuilder.cs | 114 +++++++++++++++ .../Routing/IntroducedInApiVersionEndpoint.cs | 22 +++ .../Asp.Versioning.Mvc/ApiVersionCollator.cs | 2 +- .../ActionApiVersionConventionBuilderBase.cs | 8 +- .../IntroducedInApiVersionConventionTest.cs | 135 ++++++++++++++++++ .../ActionApiVersionConventionBuilderBase.cs | 125 +++++++++++++++- 14 files changed, 637 insertions(+), 13 deletions(-) create mode 100644 src/Abstractions/src/Asp.Versioning.Abstractions/IIntroducedInApiVersionProvider.cs create mode 100644 src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs create mode 100644 src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionMetadata.cs create mode 100644 src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs create mode 100644 src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionMetadata.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionMetadata.cs index 20358ef1..1ecd9b3d 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionMetadata.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionMetadata.cs @@ -21,11 +21,17 @@ public class ApiVersionMetadata /// The model for an entire API. /// The model defined for a specific API endpoint. /// The logical name of the API. - public ApiVersionMetadata( ApiVersionModel apiModel, ApiVersionModel endpointModel, string? name = default ) + /// The introduced API version metadata associated with the endpoint. + public ApiVersionMetadata( + ApiVersionModel apiModel, + ApiVersionModel endpointModel, + string? name = default, + IEnumerable? introducedInApiVersions = default ) { this.apiModel = apiModel; this.endpointModel = endpointModel; Name = name ?? string.Empty; + IntroducedInApiVersions = introducedInApiVersions?.ToArray() ?? []; } /// @@ -40,6 +46,7 @@ protected ApiVersionMetadata( ApiVersionMetadata other ) endpointModel = other.endpointModel; mergedModel = other.mergedModel; Name = other.Name; + IntroducedInApiVersions = other.IntroducedInApiVersions; } /// @@ -60,6 +67,12 @@ protected ApiVersionMetadata( ApiVersionMetadata other ) /// The logical name of the API. public string Name { get; } + /// + /// Gets the introduced API version metadata associated with the endpoint. + /// + /// A read-only list of introduced API version metadata. + public IReadOnlyList IntroducedInApiVersions { get; } + /// /// Gets a value indicating whether the API is version-neutral. /// diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionProviderOptions.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionProviderOptions.cs index a0f2b246..cdfddcf3 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionProviderOptions.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionProviderOptions.cs @@ -31,4 +31,11 @@ public enum ApiVersionProviderOptions /// Mapped API versions indicate that the defined API versions are used for only meant /// to be used for mapping purposes. This option should not typically be combined with other options. Mapped = 4, + + /// + /// Indicates the provided API versions describe when an API was introduced. + /// + /// Introduced API versions are expanded into mapped API versions from the controller-declared + /// API version set. + Introduced = 8, } \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IIntroducedInApiVersionProvider.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IIntroducedInApiVersionProvider.cs new file mode 100644 index 00000000..36be6628 --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IIntroducedInApiVersionProvider.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Defines the behavior of an API version provider that describes when an API was introduced. +/// +public interface IIntroducedInApiVersionProvider : IApiVersionProvider +{ + /// + /// Gets the HTTP status code returned when the requested API version is earlier than the introduced API version. + /// + /// The HTTP status code. + int StatusCode { get; } +} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs new file mode 100644 index 00000000..4af16e5f --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +#pragma warning disable IDE0079 +#pragma warning disable CA1019 +#pragma warning disable CA1033 +#pragma warning disable CA1813 + +namespace Asp.Versioning; + +using static System.AttributeTargets; +#if NETSTANDARD +using DateOnly = System.DateTime; +#endif + +/// +/// Represents metadata that describes the API version in which an API action was introduced. +/// +/// +/// The action is mapped to every API version declared by the containing controller that is greater than or equal to +/// the introduced API version. Requests for a controller-declared API version earlier than the introduced API version +/// are rejected using . +/// +[AttributeUsage( Method, AllowMultiple = false, Inherited = false )] +public class IntroducedInApiVersionAttribute : ApiVersionsBaseAttribute, IIntroducedInApiVersionProvider +{ + /// + /// The default HTTP status code used when a requested API version is earlier than the introduced API version. + /// + public const int DefaultStatusCode = 404; + + /// + /// Indicates that the configured the configured unsupported API version status code should be used. + /// + public const int UseConfiguredStatusCode = 0; + + /// + /// Initializes a new instance of the class. + /// + /// The API version. + protected IntroducedInApiVersionAttribute( ApiVersion version ) : base( version ) { } + + /// + /// Initializes a new instance of the class. + /// + /// The parser used to parse the specified versions. + /// The API version string. + protected IntroducedInApiVersionAttribute( IApiVersionParser parser, string version ) : base( parser, version ) { } + + /// + /// Initializes a new instance of the class. + /// + /// A numeric API version. + /// The status associated with the API version, if any. + public IntroducedInApiVersionAttribute( double version, string? status = default ) + : base( new ApiVersion( version, status ) ) { } + + /// + /// Initializes a new instance of the class. + /// + /// The version year. + /// The version month. + /// The version day. + /// The status associated with the API version, if any. + public IntroducedInApiVersionAttribute( int year, int month, int day, string? status = default ) + : base( new ApiVersion( new DateOnly( year, month, day ), status ) ) { } + + /// + /// Initializes a new instance of the class. + /// + /// The API version string. + public IntroducedInApiVersionAttribute( string version ) : base( version ) { } + + ApiVersionProviderOptions IApiVersionProvider.Options => ApiVersionProviderOptions.Introduced; + + /// + /// Gets or sets the HTTP status code returned when the requested API version is earlier than the introduced API version. + /// + /// The HTTP status code. The default value is 404. + /// Set the value to to use the configured unsupported API version status code. + public int StatusCode { get; set; } = DefaultStatusCode; + + /// + public override int GetHashCode() => HashCode.Combine( base.GetHashCode(), StatusCode ); +} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionMetadata.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionMetadata.cs new file mode 100644 index 00000000..e70f208e --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionMetadata.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Represents endpoint metadata that describes when an API action was introduced. +/// +public sealed class IntroducedInApiVersionMetadata +{ + /// + /// Initializes a new instance of the class. + /// + /// The API version in which the API action was introduced. + /// The HTTP status code returned for earlier controller-declared API versions. + public IntroducedInApiVersionMetadata( ApiVersion introducedIn, int statusCode ) + { + IntroducedIn = introducedIn; + StatusCode = statusCode; + } + + /// + /// Gets the API version in which the API action was introduced. + /// + /// The introduced API version. + public ApiVersion IntroducedIn { get; } + + /// + /// Gets the HTTP status code returned for earlier controller-declared API versions. + /// + /// The HTTP status code. + public int StatusCode { get; } +} \ No newline at end of file diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs new file mode 100644 index 00000000..b6a80556 --- /dev/null +++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +#if NETFRAMEWORK +using DateOnly = System.DateTime; +#endif + +public class IntroducedInApiVersionAttributeTest +{ + [Fact] + public void introduced_in_api_version_attribute_should_initialize_from_string() + { + // arrange + var expected = new ApiVersion( new DateOnly( 2026, 12, 1 ) ); + + // act + var attribute = new IntroducedInApiVersionAttribute( "2026-12-01" ); + + // assert + attribute.Versions[0].Should().Be( expected ); + } + + [Fact] + public void introduced_in_api_version_attribute_should_initialize_from_date() + { + // arrange + var expected = new ApiVersion( new DateOnly( 2026, 12, 1 ) ); + + // act + var attribute = new IntroducedInApiVersionAttribute( 2026, 12, 1 ); + + // assert + attribute.Versions[0].Should().Be( expected ); + } + + [Fact] + public void introduced_in_api_version_attribute_should_use_default_status_code() + { + // arrange + var provider = new IntroducedInApiVersionAttribute( "2026-12-01" ); + + // act + var statusCode = provider.StatusCode; + + // assert + statusCode.Should().Be( IntroducedInApiVersionAttribute.DefaultStatusCode ); + } + + [Fact] + public void introduced_in_api_version_attribute_should_allow_configured_status_code() + { + // arrange + var provider = new IntroducedInApiVersionAttribute( "2026-12-01" ) + { + StatusCode = IntroducedInApiVersionAttribute.UseConfiguredStatusCode, + }; + + // act + var statusCode = provider.StatusCode; + + // assert + statusCode.Should().Be( IntroducedInApiVersionAttribute.UseConfiguredStatusCode ); + } +} \ No newline at end of file diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Conventions/ActionApiVersionConventionBuilderBase.cs index e2d45c81..1b2eb26d 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -37,6 +37,7 @@ public virtual void ApplyTo( HttpActionDescriptor item ) IEnumerable emptyVersions; var inheritedSupported = apiModel.SupportedApiVersions; var inheritedDeprecated = apiModel.DeprecatedApiVersions; + var effectiveMapped = ExpandMappedVersions( apiModel.DeclaredApiVersions ); var noInheritedApiVersions = inheritedSupported.Count == 0 && inheritedDeprecated.Count == 0; @@ -57,7 +58,7 @@ public virtual void ApplyTo( HttpActionDescriptor item ) emptyVersions ); } } - else if ( mapped is null || mapped.Count == 0 ) + else if ( !HasMappedVersions ) { endpointModel = new( declaredVersions: SupportedVersions.Union( DeprecatedVersions ), @@ -70,14 +71,14 @@ public virtual void ApplyTo( HttpActionDescriptor item ) { emptyVersions = []; endpointModel = new( - declaredVersions: mapped, + declaredVersions: effectiveMapped, supportedVersions: apiModel.SupportedApiVersions, deprecatedVersions: apiModel.DeprecatedApiVersions, advertisedVersions: emptyVersions, deprecatedAdvertisedVersions: emptyVersions ); } - metadata = new( apiModel, endpointModel, name ); + metadata = new( apiModel, endpointModel, name, GetIntroducedApiVersionMetadata() ); } item.ApiVersionMetadata = metadata; diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs index df8c73f3..846fdb04 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs @@ -169,6 +169,16 @@ private static bool TryGetApiVersions( IList metadata, out ApiVersionBuc continue; } + 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; @@ -206,6 +216,7 @@ private static bool TryGetApiVersions( IList metadata, out ApiVersionBuc private static ApiVersionMetadata Build( IList metadata, ApiVersionSet versionSet, ApiVersioningOptions options ) { var name = versionSet.Name; + var introducedInApiVersions = metadata.OfType().ToArray(); ApiVersionModel? apiModel; if ( !TryGetApiVersions( metadata, out var buckets ) || @@ -213,10 +224,12 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v { if ( string.IsNullOrEmpty( name ) ) { - return ApiVersionMetadata.Neutral; + return introducedInApiVersions.Length == 0 + ? ApiVersionMetadata.Neutral + : new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, introducedInApiVersions: introducedInApiVersions ); } - return new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, name ); + return new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, name, introducedInApiVersions ); } ApiVersionModel endpointModel; @@ -269,7 +282,7 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v } } - return new( apiModel, endpointModel, name ); + return new( apiModel, endpointModel, name, introducedInApiVersions ); } private record struct ApiVersionBuckets( diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs index 9b594f04..8b1e0880 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs @@ -33,9 +33,123 @@ public Endpoint Build() return new UnspecifiedApiVersionEndpoint( logger, options, GetDisplayNames() ); } + var introducedInApiVersionStatusCode = GetIntroducedInApiVersionStatusCode(); + + if ( introducedInApiVersionStatusCode > 0 ) + { + return new IntroducedInApiVersionEndpoint( introducedInApiVersionStatusCode ); + } + return new UnsupportedApiVersionEndpoint( options ); } + private int GetIntroducedInApiVersionStatusCode() + { + var apiVersion = feature.RequestedApiVersion; + + if ( apiVersion is null ) + { + return 0; + } + + for ( var i = 0; i < candidates.Count; i++ ) + { + ref readonly var candidate = ref candidates[i]; + var metadata = candidate.Endpoint.Metadata.GetMetadata(); + + if ( metadata is null ) + { + continue; + } + + metadata.Deconstruct( out var apiModel, out _ ); + + if ( !apiModel.DeclaredApiVersions.Contains( apiVersion ) ) + { + continue; + } + + var introduced = metadata.IntroducedInApiVersions; + + for ( var j = 0; j < introduced.Count; j++ ) + { + if ( apiVersion < introduced[j].IntroducedIn ) + { + return introduced[j].StatusCode; + } + } + + introduced = candidate.Endpoint.Metadata.GetOrderedMetadata(); + + for ( var j = 0; j < introduced.Count; j++ ) + { + if ( apiVersion < introduced[j].IntroducedIn ) + { + return introduced[j].StatusCode; + } + } + + var reflectedIntroduced = GetIntroducedInApiVersions( candidate.Endpoint.Metadata ); + + if ( reflectedIntroduced is null ) + { + continue; + } + + for ( var j = 0; j < reflectedIntroduced.Count; j++ ) + { + if ( apiVersion < reflectedIntroduced[j].IntroducedIn ) + { + return reflectedIntroduced[j].StatusCode; + } + } + } + + return 0; + } + + [UnconditionalSuppressMessage( "ReflectionAnalysis", "IL2075", Justification = "The optional MVC action descriptor metadata is discovered by convention when present." )] + private static List? GetIntroducedInApiVersions( EndpointMetadataCollection metadata ) + { + var introduced = default( List ); + + for ( var i = 0; i < metadata.Count; i++ ) + { + if ( metadata[i] is IIntroducedInApiVersionProvider provider ) + { + Add( provider, ref introduced ); + continue; + } + + if ( metadata[i].GetType().GetProperty( "MethodInfo" )?.GetValue( metadata[i] ) is not System.Reflection.MethodInfo method ) + { + continue; + } + + foreach ( var attribute in method.GetCustomAttributes( inherit: false ) ) + { + if ( attribute is IIntroducedInApiVersionProvider introducedProvider ) + { + Add( introducedProvider, ref introduced ); + } + } + } + + return introduced; + } + + private static void Add( IIntroducedInApiVersionProvider provider, ref List? introduced ) + { + var versions = provider.Versions; + + introduced ??= []; + + for ( var i = 0; i < versions.Count; i++ ) + { + introduced.Add( new( versions[i], provider.StatusCode ) ); + } + } + private static string DisplayName( Endpoint endpoint ) { var displayName = endpoint.DisplayName; diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs new file mode 100644 index 00000000..4a7a805f --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Routing; + +using Microsoft.AspNetCore.Http; +using static Microsoft.AspNetCore.Http.EndpointMetadataCollection; + +internal sealed class IntroducedInApiVersionEndpoint : Endpoint +{ + private const string Name = " Introduced API Version"; + + internal IntroducedInApiVersionEndpoint( int statusCode ) + : base( + context => + { + context.Response.StatusCode = statusCode; + return Task.CompletedTask; + }, + Empty, + statusCode + Name ) + { } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiVersionCollator.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiVersionCollator.cs index d845f0d8..37e211d2 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiVersionCollator.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiVersionCollator.cs @@ -50,7 +50,7 @@ public virtual void OnProvidersExecuted( ActionDescriptorProviderContext context var (apiModel, endpointModel, name) = metadata; - metadata = new( apiModel, endpointModel.Aggregate( collatedModel ), name ); + metadata = new( apiModel, endpointModel.Aggregate( collatedModel ), name, metadata.IntroducedInApiVersions ); action.AddOrReplaceApiVersionMetadata( metadata ); } } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs index 8e18b0c2..47e99e6c 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -37,6 +37,7 @@ public virtual void ApplyTo( ActionModel item ) ApiVersion[] emptyVersions; var inheritedSupported = apiModel.SupportedApiVersions; var inheritedDeprecated = apiModel.DeprecatedApiVersions; + var effectiveMapped = ExpandMappedVersions( apiModel.DeclaredApiVersions ); var noInheritedApiVersions = inheritedSupported.Count == 0 && inheritedDeprecated.Count == 0; @@ -57,7 +58,7 @@ public virtual void ApplyTo( ActionModel item ) emptyVersions ); } } - else if ( mapped is null || mapped.Count == 0 ) + else if ( !HasMappedVersions ) { endpointModel = new( declaredVersions: SupportedVersions.Union( DeprecatedVersions ), @@ -70,16 +71,17 @@ public virtual void ApplyTo( ActionModel item ) { emptyVersions = []; endpointModel = new( - declaredVersions: mapped, + declaredVersions: effectiveMapped, supportedVersions: inheritedSupported, deprecatedVersions: inheritedDeprecated, advertisedVersions: emptyVersions, deprecatedAdvertisedVersions: emptyVersions ); } - metadata = new( apiModel, endpointModel, name ); + metadata = new( apiModel, endpointModel, name, GetIntroducedApiVersionMetadata() ); } item.AddEndpointMetadata( metadata ); + AddIntroducedApiVersionMetadata( item.AddEndpointMetadata ); } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs new file mode 100644 index 00000000..59d523e8 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs @@ -0,0 +1,135 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Conventions; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using System.Reflection; +using static Asp.Versioning.ApiVersionMapping; + +public class IntroducedInApiVersionConventionTest +{ + [Fact] + public void apply_to_should_expand_introduced_version_to_controller_declared_versions() + { + // arrange + var action = ApplyConventions( typeof( IntroducedController ), nameof( IntroducedController.GetIntroduced ) ); + var metadata = GetApiVersionMetadata( action ); + + // act + var model = metadata.Map( Explicit ); + + // assert + model.DeclaredApiVersions.Should().Equal( + new ApiVersion( new DateOnly( 2026, 12, 1 ) ), + new ApiVersion( new DateOnly( 2027, 6, 1 ) ) ); + } + + [Theory] + [InlineData( "2026-11-12", false )] + [InlineData( "2026-12-01", true )] + [InlineData( "2027-06-01", true )] + public void mapping_should_include_only_versions_on_or_after_introduced_version( string value, bool expected ) + { + // arrange + var action = ApplyConventions( typeof( IntroducedController ), nameof( IntroducedController.GetIntroduced ) ); + var metadata = GetApiVersionMetadata( action ); + var apiVersion = ApiVersionParser.Default.Parse( value ); + + // act + var mapped = metadata.IsMappedTo( apiVersion ); + + // assert + mapped.Should().Be( expected ); + } + + [Fact] + public void apply_to_should_add_status_code_metadata_for_introduced_version() + { + // arrange + var action = ApplyConventions( typeof( IntroducedController ), nameof( IntroducedController.GetIntroduced ) ); + + // act + var metadata = action.Selectors.Single().EndpointMetadata.OfType().Single(); + + // assert + metadata.IntroducedIn.Should().Be( new ApiVersion( new DateOnly( 2026, 12, 1 ) ) ); + metadata.StatusCode.Should().Be( IntroducedInApiVersionAttribute.DefaultStatusCode ); + } + + [Fact] + public void map_to_api_version_should_retain_exact_match_semantics() + { + // arrange + var action = ApplyConventions( typeof( IntroducedController ), nameof( IntroducedController.GetMapped ) ); + var metadata = GetApiVersionMetadata( action ); + + // act + var model = metadata.Map( Explicit ); + + // assert + model.DeclaredApiVersions.Should().Equal( new ApiVersion( new DateOnly( 2026, 12, 1 ) ) ); + metadata.IsMappedTo( ApiVersionParser.Default.Parse( "2027-06-01" ) ).Should().BeFalse(); + } + + [Fact] + public void api_explorer_mapping_should_exclude_versions_before_introduced_version() + { + // arrange + var action = ApplyConventions( typeof( IntroducedController ), nameof( IntroducedController.GetIntroduced ) ); + var metadata = GetApiVersionMetadata( action ); + + // act + var before = metadata.MappingTo( ApiVersionParser.Default.Parse( "2026-11-12" ) ); + var introduced = metadata.MappingTo( ApiVersionParser.Default.Parse( "2026-12-01" ) ); + var later = metadata.MappingTo( ApiVersionParser.Default.Parse( "2027-06-01" ) ); + + // assert + before.Should().Be( None ); + introduced.Should().Be( Explicit ); + later.Should().Be( Explicit ); + } + + private static ActionModel ApplyConventions( Type controllerType, string actionName ) + { + var controllerAttributes = controllerType.GetTypeInfo().GetCustomAttributes().Cast().ToArray(); + var controller = new ControllerModel( controllerType.GetTypeInfo(), controllerAttributes ) + { + ControllerName = controllerType.Name.Replace( "Controller", string.Empty, StringComparison.Ordinal ), + }; + + foreach ( var method in controllerType.GetRuntimeMethods().Where( method => method.DeclaringType == controllerType && method.IsPublic ) ) + { + var action = new ActionModel( method, method.GetCustomAttributes().Cast().ToArray() ) + { + Controller = controller, + }; + + controller.Actions.Add( action ); + } + + var builder = new ControllerApiVersionConventionBuilder( controllerType ); + builder.ApplyTo( controller ); + return controller.Actions.Single( action => action.ActionMethod.Name == actionName ); + } + + private static ApiVersionMetadata GetApiVersionMetadata( ActionModel action ) => + action.Selectors.Single().EndpointMetadata.OfType().Single(); + +#pragma warning disable CA1034 // Nested types should not be visible + + [ApiController] + [ApiVersion( 2026, 11, 12 )] + [ApiVersion( 2026, 12, 1 )] + [ApiVersion( 2027, 6, 1 )] + public sealed class IntroducedController : ControllerBase + { + [IntroducedInApiVersion( "2026-12-01" )] + public OkResult GetIntroduced() => Ok(); + + [MapToApiVersion( "2026-12-01" )] + public OkResult GetMapped() => Ok(); + } + +#pragma warning restore CA1034 // Nested types should not be visible +} \ No newline at end of file diff --git a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs index 99aa8402..cb195eba 100644 --- a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -10,6 +10,7 @@ namespace Asp.Versioning.Conventions; public abstract partial class ActionApiVersionConventionBuilderBase : ApiVersionConventionBuilderBase { private HashSet? mapped; + private List? introduced; /// /// Initializes a new instance of the class. @@ -23,7 +24,10 @@ public abstract partial class ActionApiVersionConventionBuilderBase : ApiVersion protected ActionApiVersionConventionBuilderBase( IControllerNameConvention namingConvention ) => NamingConvention = namingConvention; /// - protected override bool IsEmpty => ( mapped is null || mapped.Count == 0 ) && base.IsEmpty; + protected override bool IsEmpty => + ( mapped is null || mapped.Count == 0 ) && + ( introduced is null || introduced.Count == 0 ) && + base.IsEmpty; /// /// Gets the collection of API versions mapped to the current action. @@ -31,6 +35,18 @@ public abstract partial class ActionApiVersionConventionBuilderBase : ApiVersion /// A collection of mapped API versions. protected ICollection MappedVersions => mapped ??= []; + /// + /// Gets a value indicating whether any explicit API version mappings are configured. + /// + /// True if explicit API version mappings are configured; otherwise, false. + protected bool HasMappedVersions => ( mapped is not null && mapped.Count > 0 ) || ( introduced is not null && introduced.Count > 0 ); + + /// + /// Gets the collection of API versions in which the current action was introduced. + /// + /// A collection of introduced API versions. + protected ICollection IntroducedVersions => introduced ??= []; + /// /// Gets the controller naming convention associated with the builder. /// @@ -46,10 +62,115 @@ protected override void MergeAttributesWithConventions( IReadOnlyList at for ( var i = 0; i < attributes.Count; i++ ) { - if ( attributes[i] is IApiVersionProvider provider && provider.Options == Mapped ) + if ( attributes[i] is IIntroducedInApiVersionProvider introducedProvider ) + { + for ( var j = 0; j < introducedProvider.Versions.Count; j++ ) + { + IntroducedVersions.Add( new( introducedProvider.Versions[j], introducedProvider.StatusCode ) ); + } + } + else if ( attributes[i] is IApiVersionProvider provider && provider.Options == Mapped ) { MappedVersions.UnionWith( provider.Versions ); } } } + + /// + /// Adds the introduced API version metadata to the specified action. + /// + /// The callback used to add metadata. + protected void AddIntroducedApiVersionMetadata( Action add ) + { + ArgumentNullException.ThrowIfNull( add ); + + var metadata = GetIntroducedApiVersionMetadata(); + + for ( var i = 0; i < metadata.Length; i++ ) + { + add( metadata[i] ); + } + } + + /// + /// Gets the introduced API version metadata for the current action. + /// + /// The introduced API version metadata. + protected IntroducedInApiVersionMetadata[] GetIntroducedApiVersionMetadata() + { + if ( introduced is null || introduced.Count == 0 ) + { + return []; + } + + var metadata = new IntroducedInApiVersionMetadata[introduced.Count]; + + for ( var i = 0; i < introduced.Count; i++ ) + { + var item = introduced[i]; + metadata[i] = new( item.Version, item.StatusCode ); + } + + return metadata; + } + + /// + /// Expands introduced API versions into the effective mapped API versions. + /// + /// The API versions declared by the controller. + /// The effective mapped API versions. + protected ICollection ExpandMappedVersions( IReadOnlyList declaredVersions ) + { + ArgumentNullException.ThrowIfNull( declaredVersions ); + + if ( introduced is null || introduced.Count == 0 ) + { + return mapped ?? []; + } + + var versions = mapped is null ? [] : new HashSet( mapped ); + + for ( var i = 0; i < declaredVersions.Count; i++ ) + { + var declaredVersion = declaredVersions[i]; + + for ( var j = 0; j < introduced.Count; j++ ) + { + if ( declaredVersion >= introduced[j].Version ) + { + versions.Add( declaredVersion ); + break; + } + } + } + + return versions; + } + + /// + /// Represents the API version in which an action was introduced. + /// + protected sealed class IntroducedApiVersion + { + /// + /// Initializes a new instance of the class. + /// + /// The API version in which the action was introduced. + /// The status code for earlier controller-declared API versions. + public IntroducedApiVersion( ApiVersion version, int statusCode ) + { + Version = version; + StatusCode = statusCode; + } + + /// + /// Gets the API version in which the action was introduced. + /// + public ApiVersion Version { get; } + + /// + /// Gets the status code for earlier controller-declared API versions. + /// + public int StatusCode { get; } + } } \ No newline at end of file From 061b136a4a08d9adff156d991f11cdcbbb807b52 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sat, 9 May 2026 22:52:40 -0700 Subject: [PATCH 02/26] Fix introduced-in version routing 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> --- .../Routing/ApiVersionMatcherPolicy.cs | 21 ++++ .../Routing/ApiVersionPolicyJumpTable.cs | 5 + .../Routing/ClientErrorEndpointBuilder.cs | 76 +------------ .../Routing/EdgeBuilder.cs | 18 ++- .../Asp.Versioning.Http/Routing/EdgeKey.cs | 27 ++++- .../Routing/EndpointType.cs | 1 + .../IntroducedInApiVersionStatusCode.cs | 107 ++++++++++++++++++ .../Routing/RouteDestination.cs | 4 + .../Routing/ApiVersionMatcherPolicyTest.cs | 58 ++++++++++ 9 files changed, 241 insertions(+), 76 deletions(-) create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs index 137e598e..d3c8a29e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs @@ -108,6 +108,7 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList( capacity ); + var introducedLater = default( Dictionary ); var source = ApiVersionSource; var supported = default( SortedSet ); var deprecated = default( SortedSet ); @@ -146,6 +147,11 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList GetEdges( IReadOnlyList endpoints builder.Add( endpoint ); versionedEndpoints[count++] = (endpoint, model, metadata); versions.AddRange( model.DeclaredApiVersions ); + + if ( IntroducedInApiVersionStatusCode.HasIntroducedInApiVersion( endpoint, metadata ) ) + { + metadata.Deconstruct( out var apiModel, out _ ); + versions.AddRange( apiModel.DeclaredApiVersions ); + } } } @@ -212,6 +229,10 @@ public IReadOnlyList GetEdges( IReadOnlyList endpoints { builder.Add( endpoint, version, metadata ); } + else if ( IntroducedInApiVersionStatusCode.TryGet( endpoint, metadata, version, out var statusCode ) ) + { + builder.AddIntroducedLater( endpoint, version, statusCode, metadata ); + } } } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs index 29c93e3b..0b980398 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs @@ -109,6 +109,11 @@ public override int GetDestination( HttpContext httpContext ) return destination; } + if ( rejection.IntroducedLater.TryGetValue( apiVersion, out destination ) ) + { + return destination; + } + httpContext.Features.Set( policyFeature ); if ( versionsByMediaTypeOnly ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs index 8b1e0880..28c8ede2 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs @@ -69,87 +69,15 @@ private int GetIntroducedInApiVersionStatusCode() continue; } - var introduced = metadata.IntroducedInApiVersions; - - for ( var j = 0; j < introduced.Count; j++ ) - { - if ( apiVersion < introduced[j].IntroducedIn ) - { - return introduced[j].StatusCode; - } - } - - introduced = candidate.Endpoint.Metadata.GetOrderedMetadata(); - - for ( var j = 0; j < introduced.Count; j++ ) + if ( IntroducedInApiVersionStatusCode.TryGet( candidate.Endpoint, metadata, apiVersion, out var statusCode ) ) { - if ( apiVersion < introduced[j].IntroducedIn ) - { - return introduced[j].StatusCode; - } - } - - var reflectedIntroduced = GetIntroducedInApiVersions( candidate.Endpoint.Metadata ); - - if ( reflectedIntroduced is null ) - { - continue; - } - - for ( var j = 0; j < reflectedIntroduced.Count; j++ ) - { - if ( apiVersion < reflectedIntroduced[j].IntroducedIn ) - { - return reflectedIntroduced[j].StatusCode; - } + return statusCode; } } return 0; } - [UnconditionalSuppressMessage( "ReflectionAnalysis", "IL2075", Justification = "The optional MVC action descriptor metadata is discovered by convention when present." )] - private static List? GetIntroducedInApiVersions( EndpointMetadataCollection metadata ) - { - var introduced = default( List ); - - for ( var i = 0; i < metadata.Count; i++ ) - { - if ( metadata[i] is IIntroducedInApiVersionProvider provider ) - { - Add( provider, ref introduced ); - continue; - } - - if ( metadata[i].GetType().GetProperty( "MethodInfo" )?.GetValue( metadata[i] ) is not System.Reflection.MethodInfo method ) - { - continue; - } - - foreach ( var attribute in method.GetCustomAttributes( inherit: false ) ) - { - if ( attribute is IIntroducedInApiVersionProvider introducedProvider ) - { - Add( introducedProvider, ref introduced ); - } - } - } - - return introduced; - } - - private static void Add( IIntroducedInApiVersionProvider provider, ref List? introduced ) - { - var versions = provider.Versions; - - introduced ??= []; - - for ( var i = 0; i < versions.Count; i++ ) - { - introduced.Add( new( versions[i], provider.StatusCode ) ); - } - } - private static string DisplayName( Endpoint endpoint ) { var displayName = endpoint.DisplayName; diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs index 430159ba..20f0892e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs @@ -71,7 +71,19 @@ public void Add( RouteEndpoint endpoint, ApiVersion apiVersion, ApiVersionMetada } } + public void AddIntroducedLater( RouteEndpoint endpoint, ApiVersion apiVersion, int statusCode, ApiVersionMetadata metadata ) + { + var key = new EdgeKey( apiVersion, statusCode, metadata, routePatterns ); + + Add( ref key, new IntroducedInApiVersionEndpoint( statusCode ), endpoint.RoutePattern, once: true ); + } + private void Add( ref EdgeKey key, RouteEndpoint endpoint ) + { + Add( ref key, endpoint, endpoint.RoutePattern, once: false ); + } + + private void Add( ref EdgeKey key, Endpoint endpoint, RoutePattern routePattern, bool once ) { if ( keys.TryGetValue( key, out var existing ) ) { @@ -82,7 +94,6 @@ private void Add( ref EdgeKey key, RouteEndpoint endpoint ) keys.Add( key ); } - var routePattern = endpoint.RoutePattern; var needsRoutePattern = versionsByUrl && routePattern.HasVersionConstraint( constraintName ); if ( needsRoutePattern ) @@ -95,6 +106,11 @@ private void Add( ref EdgeKey key, RouteEndpoint endpoint ) edges.Add( key, endpoints = [] ); } + if ( once && endpoints.Count > 0 ) + { + return; + } + endpoints.Add( endpoint ); } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs index 4e24067a..bf8a6193 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs @@ -12,6 +12,7 @@ namespace Asp.Versioning.Routing; public readonly ApiVersionMetadata Metadata; public readonly HashSet RoutePatterns; public readonly EndpointType EndpointType; + public readonly int StatusCode; private EdgeKey( EndpointType endpointType, HashSet routePatterns ) { @@ -19,6 +20,7 @@ private EdgeKey( EndpointType endpointType, HashSet routePatterns Metadata = ApiVersionMetadata.Empty; RoutePatterns = routePatterns; EndpointType = endpointType; + StatusCode = 0; } internal EdgeKey( @@ -30,6 +32,20 @@ internal EdgeKey( Metadata = metadata; RoutePatterns = routePatterns; EndpointType = UserDefined; + StatusCode = 0; + } + + internal EdgeKey( + ApiVersion apiVersion, + int statusCode, + ApiVersionMetadata metadata, + HashSet routePatterns ) + { + ApiVersion = apiVersion; + Metadata = metadata; + RoutePatterns = routePatterns; + EndpointType = IntroducedLater; + StatusCode = statusCode; } internal static EdgeKey Ambiguous => new( EndpointType.Ambiguous, Set.Empty ); @@ -56,11 +72,16 @@ public override int GetHashCode() result.Add( EndpointType ); - if ( EndpointType == UserDefined ) + if ( EndpointType is UserDefined or IntroducedLater ) { result.Add( ApiVersion ); } + if ( EndpointType == IntroducedLater ) + { + result.Add( StatusCode ); + } + return result.ToHashCode(); } @@ -76,6 +97,10 @@ public override string ToString() { value = ApiVersion.ToString(); } + else if ( EndpointType == IntroducedLater ) + { + value = EndpointType + " " + ApiVersion + " (" + StatusCode + ")"; + } else { value = EndpointType.ToString(); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs index 73f0c1bd..f06f7345 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs @@ -12,4 +12,5 @@ internal enum EndpointType AssumeDefault, NotAcceptable, Unsupported, + IntroducedLater, } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs new file mode 100644 index 00000000..677a2d04 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs @@ -0,0 +1,107 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Routing; + +using Microsoft.AspNetCore.Http; +using System.Diagnostics.CodeAnalysis; + +internal static class IntroducedInApiVersionStatusCode +{ + internal static bool TryGet( Endpoint endpoint, ApiVersionMetadata metadata, ApiVersion apiVersion, out int statusCode ) + { + metadata.Deconstruct( out var apiModel, out _ ); + + if ( !apiModel.DeclaredApiVersions.Contains( apiVersion ) ) + { + statusCode = 0; + return false; + } + + if ( TryGet( metadata.IntroducedInApiVersions, apiVersion, out statusCode ) ) + { + return true; + } + + var endpointMetadata = endpoint.Metadata; + + if ( TryGet( endpointMetadata.GetOrderedMetadata(), apiVersion, out statusCode ) ) + { + return true; + } + + var reflectedIntroduced = GetIntroducedInApiVersions( endpointMetadata ); + + return reflectedIntroduced is not null && TryGet( reflectedIntroduced, apiVersion, out statusCode ); + } + + internal static bool HasIntroducedInApiVersion( Endpoint endpoint, ApiVersionMetadata metadata ) + { + if ( metadata.IntroducedInApiVersions.Count > 0 || + endpoint.Metadata.GetOrderedMetadata().Count > 0 ) + { + return true; + } + + return GetIntroducedInApiVersions( endpoint.Metadata ) is { Count: > 0 }; + } + + private static bool TryGet( + IReadOnlyList introduced, + ApiVersion apiVersion, + out int statusCode ) + { + for ( var i = 0; i < introduced.Count; i++ ) + { + if ( apiVersion < introduced[i].IntroducedIn ) + { + statusCode = introduced[i].StatusCode; + return true; + } + } + + statusCode = 0; + return false; + } + + [UnconditionalSuppressMessage( "ReflectionAnalysis", "IL2075", Justification = "The optional MVC action descriptor metadata is discovered by convention when present." )] + private static List? GetIntroducedInApiVersions( EndpointMetadataCollection metadata ) + { + var introduced = default( List ); + + for ( var i = 0; i < metadata.Count; i++ ) + { + if ( metadata[i] is IIntroducedInApiVersionProvider provider ) + { + Add( provider, ref introduced ); + continue; + } + + if ( metadata[i].GetType().GetProperty( "MethodInfo" )?.GetValue( metadata[i] ) is not System.Reflection.MethodInfo method ) + { + continue; + } + + foreach ( var attribute in method.GetCustomAttributes( inherit: false ) ) + { + if ( attribute is IIntroducedInApiVersionProvider introducedProvider ) + { + Add( introducedProvider, ref introduced ); + } + } + } + + return introduced; + } + + private static void Add( IIntroducedInApiVersionProvider provider, ref List? introduced ) + { + var versions = provider.Versions; + + introduced ??= []; + + for ( var i = 0; i < versions.Count; i++ ) + { + introduced.Add( new( versions[i], provider.StatusCode ) ); + } + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs index 39dcbb64..1a610fc3 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs @@ -2,6 +2,8 @@ namespace Asp.Versioning.Routing; +using System.Collections.Frozen; + internal struct RouteDestination { public readonly int Exit; @@ -12,6 +14,7 @@ internal struct RouteDestination public int UnsupportedMediaType; public int AssumeDefault; public int NotAcceptable; + public IReadOnlyDictionary IntroducedLater; public RouteDestination( int exit ) { @@ -23,5 +26,6 @@ public RouteDestination( int exit ) UnsupportedMediaType = exit; AssumeDefault = exit; NotAcceptable = exit; + IntroducedLater = FrozenDictionary.Empty; } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs index 3df00a29..8c6250d7 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs @@ -89,6 +89,64 @@ public void apply_should_use_400_endpoint_for_ambiguous_api_version() endpoint.DisplayName.Should().Be( "400 Ambiguous API Version" ); } + [Fact] + public async Task jump_table_should_use_introduced_endpoint_for_controller_declared_version_before_action() + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, "1.0" ); + feature.SetupProperty( f => f.RawRequestedApiVersions, ["1.0"] ); + + var options = new ApiVersioningOptions() + { + ApiVersionReader = new QueryStringApiVersionReader(), + }; + var policy = NewApiVersionMatcherPolicy( options ); + var httpContext = NewHttpContext( feature, queryParameters: new() { ["api-version"] = new( "1.0" ) } ); + var v1 = new ApiVersion( 1, 0 ); + var v2 = new ApiVersion( 2, 0 ); + var v3 = new ApiVersion( 3, 0 ); + var apiModel = new ApiVersionModel( + declaredVersions: [v1, v2, v3], + supportedVersions: [v1, v2, v3], + deprecatedVersions: [], + advertisedVersions: [], + deprecatedAdvertisedVersions: [] ); + var endpointModel = new ApiVersionModel( + declaredVersions: [v2, v3], + supportedVersions: [v2, v3], + deprecatedVersions: [], + advertisedVersions: [], + deprecatedAdvertisedVersions: [] ); + var routePattern = RoutePatternFactory.Parse( "api/values" ); + var builder = new RouteEndpointBuilder( Limbo, routePattern, 0 ) + { + Metadata = + { + new ApiVersionMetadata( apiModel, endpointModel, introducedInApiVersions: [new( v2, 404 )] ), + }, + }; + var endpoints = new[] { builder.Build() }; + var edges = policy.GetEdges( endpoints ); + var tableEdges = new List(); + + for ( var i = 0; i < edges.Count; i++ ) + { + tableEdges.Add( new( edges[i].State, i ) ); + } + + var jumpTable = policy.BuildJumpTable( 42, tableEdges ); + var endpoint = edges[jumpTable.GetDestination( httpContext )].Endpoints[0]; + var responseContext = new DefaultHttpContext(); + + // act + await endpoint.RequestDelegate!( responseContext ); + + // assert + responseContext.Response.StatusCode.Should().Be( 404 ); + } + [Fact] public async Task apply_should_have_candidate_for_matched_api_version() { From 991b14c19a706b700051b0e6c815a783a8f6baec Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sat, 9 May 2026 22:52:41 -0700 Subject: [PATCH 03/26] Fix introduced-in attribute equality and docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IntroducedInApiVersionAttribute.cs | 17 ++++++++++++-- .../IntroducedInApiVersionAttributeTest.cs | 22 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs index 4af16e5f..acd6ca3e 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs @@ -18,8 +18,17 @@ namespace Asp.Versioning; /// /// The action is mapped to every API version declared by the containing controller that is greater than or equal to /// the introduced API version. Requests for a controller-declared API version earlier than the introduced API version -/// are rejected using . +/// are rejected using . Version-neutral controllers and actions ignore introduced API version +/// metadata because version-neutral endpoints are not constrained to declared API versions. /// +/// +/// A controller that declares API versions 1.0, 2.0, and 3.0 can mark an action with +/// [IntroducedInApiVersion( "2.0" )]. The action is mapped to versions 2.0 and 3.0. +/// A request for version 1.0 is rejected using . Set +/// to (0) to use the globally configured +/// unsupported API version status code instead. +/// +/// [AttributeUsage( Method, AllowMultiple = false, Inherited = false )] public class IntroducedInApiVersionAttribute : ApiVersionsBaseAttribute, IIntroducedInApiVersionProvider { @@ -29,7 +38,7 @@ public class IntroducedInApiVersionAttribute : ApiVersionsBaseAttribute, IIntrod public const int DefaultStatusCode = 404; /// - /// Indicates that the configured the configured unsupported API version status code should be used. + /// Indicates that the configured unsupported API version status code should be used. /// public const int UseConfiguredStatusCode = 0; @@ -79,6 +88,10 @@ public IntroducedInApiVersionAttribute( string version ) : base( version ) { } /// Set the value to to use the configured unsupported API version status code. public int StatusCode { get; set; } = DefaultStatusCode; + /// + public override bool Equals( object? obj ) => + obj is IntroducedInApiVersionAttribute other && base.Equals( obj ) && StatusCode == other.StatusCode; + /// public override int GetHashCode() => HashCode.Combine( base.GetHashCode(), StatusCode ); } \ No newline at end of file diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs index b6a80556..79793c80 100644 --- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs +++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs @@ -62,4 +62,26 @@ public void introduced_in_api_version_attribute_should_allow_configured_status_c // assert statusCode.Should().Be( IntroducedInApiVersionAttribute.UseConfiguredStatusCode ); } + + [Fact] + public void introduced_in_api_version_attribute_should_compare_status_code_for_equality() + { + // arrange + var version = new IntroducedInApiVersionAttribute( "2026-12-01" ) { StatusCode = 404 }; + var sameVersionAndStatus = new IntroducedInApiVersionAttribute( "2026-12-01" ) { StatusCode = 404 }; + var sameVersionDifferentStatus = new IntroducedInApiVersionAttribute( "2026-12-01" ) { StatusCode = 410 }; + var differentVersionSameStatus = new IntroducedInApiVersionAttribute( "2027-06-01" ) { StatusCode = 404 }; + + // act + var same = version.Equals( sameVersionAndStatus ); + var differentStatus = version.Equals( sameVersionDifferentStatus ); + var differentVersion = version.Equals( differentVersionSameStatus ); + + // assert + same.Should().BeTrue(); + version.GetHashCode().Should().Be( sameVersionAndStatus.GetHashCode() ); + differentStatus.Should().BeFalse(); + version.GetHashCode().Should().NotBe( sameVersionDifferentStatus.GetHashCode() ); + differentVersion.Should().BeFalse(); + } } \ No newline at end of file From ead80dddbba838d009936f4de42cf99f8af1e206 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sat, 9 May 2026 22:52:41 -0700 Subject: [PATCH 04/26] Align introduced-in routing status selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Routing/EdgeBuilder.cs | 7 + .../IntroducedInApiVersionStatusCode.cs | 15 +- .../Routing/ApiVersionMatcherPolicyTest.cs | 138 ++++++++++++++++++ 3 files changed, 157 insertions(+), 3 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs index 20f0892e..3b36fa89 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs @@ -15,6 +15,7 @@ internal sealed class EdgeBuilder private readonly bool versionsByUrl; private readonly bool unspecifiedAllowed; private readonly string constraintName; + private readonly int unsupportedApiVersionStatusCode; private readonly HashSet keys; private readonly Dictionary> edges; private readonly HashSet routePatterns = new( new RoutePatternComparer() ); @@ -29,6 +30,7 @@ public EdgeBuilder( versionsByUrl = source.VersionsByUrl(); unspecifiedAllowed = options.AssumeDefaultVersionWhenUnspecified; constraintName = options.RouteConstraintName; + unsupportedApiVersionStatusCode = options.UnsupportedApiVersionStatusCode; keys = new( capacity + 1 ); edges = new( capacity + RejectionEndpointCapacity ) { @@ -73,6 +75,11 @@ public void Add( RouteEndpoint endpoint, ApiVersion apiVersion, ApiVersionMetada public void AddIntroducedLater( RouteEndpoint endpoint, ApiVersion apiVersion, int statusCode, ApiVersionMetadata metadata ) { + if ( statusCode == IntroducedInApiVersionAttribute.UseConfiguredStatusCode ) + { + statusCode = unsupportedApiVersionStatusCode; + } + var key = new EdgeKey( apiVersion, statusCode, metadata, routePatterns ); Add( ref key, new IntroducedInApiVersionEndpoint( statusCode ), endpoint.RoutePattern, once: true ); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs index 677a2d04..3e7ef45c 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs @@ -50,15 +50,24 @@ private static bool TryGet( ApiVersion apiVersion, out int statusCode ) { + var matched = default( IntroducedInApiVersionMetadata ); + for ( var i = 0; i < introduced.Count; i++ ) { - if ( apiVersion < introduced[i].IntroducedIn ) + var current = introduced[i]; + + if ( apiVersion < current.IntroducedIn && ( matched is null || current.IntroducedIn > matched.IntroducedIn ) ) { - statusCode = introduced[i].StatusCode; - return true; + matched = current; } } + if ( matched is not null ) + { + statusCode = matched.StatusCode; + return true; + } + statusCode = 0; return false; } diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs index 8c6250d7..5d7d42ca 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs @@ -147,6 +147,106 @@ public async Task jump_table_should_use_introduced_endpoint_for_controller_decla responseContext.Response.StatusCode.Should().Be( 404 ); } + [Fact] + public async Task jump_table_should_use_configured_status_code_for_introduced_status_code_zero() + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, "1.0" ); + feature.SetupProperty( f => f.RawRequestedApiVersions, ["1.0"] ); + + var options = new ApiVersioningOptions() + { + ApiVersionReader = new QueryStringApiVersionReader(), + UnsupportedApiVersionStatusCode = 410, + }; + var policy = NewApiVersionMatcherPolicy( options ); + var httpContext = NewHttpContext( feature, queryParameters: new() { ["api-version"] = new( "1.0" ) } ); + var endpoint = NewIntroducedEndpoint( IntroducedInApiVersionAttribute.UseConfiguredStatusCode ); + var edges = policy.GetEdges( [endpoint] ); + var tableEdges = new List(); + + for ( var i = 0; i < edges.Count; i++ ) + { + tableEdges.Add( new( edges[i].State, i ) ); + } + + var jumpTable = policy.BuildJumpTable( 42, tableEdges ); + var selected = edges[jumpTable.GetDestination( httpContext )].Endpoints[0]; + var responseContext = new DefaultHttpContext(); + + // act + await selected.RequestDelegate!( responseContext ); + + // assert + selected.DisplayName.Should().Be( "410 Introduced API Version" ); + responseContext.Response.StatusCode.Should().Be( 410 ); + } + + [Fact] + public async Task apply_should_use_introduced_endpoint_for_controller_declared_version_before_action() + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, "1.0" ); + feature.SetupProperty( f => f.RawRequestedApiVersions, ["1.0"] ); + feature.SetupProperty( f => f.RequestedApiVersion, new ApiVersion( 1, 0 ) ); + + var policy = NewApiVersionMatcherPolicy(); + var endpoint = NewIntroducedEndpoint( 404 ); + var candidates = new CandidateSet( [endpoint], [[]], [0] ); + var httpContext = NewHttpContext( feature ); + var responseContext = new DefaultHttpContext(); + + // act + await policy.ApplyAsync( httpContext, candidates ); + await httpContext.GetEndpoint().RequestDelegate!( responseContext ); + + // assert + httpContext.GetEndpoint().DisplayName.Should().Be( "404 Introduced API Version" ); + responseContext.Response.StatusCode.Should().Be( 404 ); + } + + [Theory] + [InlineData( "1.0" )] + [InlineData( "2.0" )] + public async Task jump_table_should_use_latest_matching_introduced_version( string requestedVersion ) + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, requestedVersion ); + feature.SetupProperty( f => f.RawRequestedApiVersions, [requestedVersion] ); + + var options = new ApiVersioningOptions() + { + ApiVersionReader = new QueryStringApiVersionReader(), + }; + var policy = NewApiVersionMatcherPolicy( options ); + var httpContext = NewHttpContext( feature, queryParameters: new() { ["api-version"] = new( requestedVersion ) } ); + var endpoint = NewIntroducedEndpoint( [new( new ApiVersion( 2, 0 ), 409 ), new( new ApiVersion( 3, 0 ), 410 )], implementedVersion: new( 3, 0 ) ); + var edges = policy.GetEdges( [endpoint] ); + var tableEdges = new List(); + + for ( var i = 0; i < edges.Count; i++ ) + { + tableEdges.Add( new( edges[i].State, i ) ); + } + + var jumpTable = policy.BuildJumpTable( 42, tableEdges ); + var selected = edges[jumpTable.GetDestination( httpContext )].Endpoints[0]; + var responseContext = new DefaultHttpContext(); + + // act + await selected.RequestDelegate!( responseContext ); + + // assert + selected.DisplayName.Should().Be( "410 Introduced API Version" ); + responseContext.Response.StatusCode.Should().Be( 410 ); + } + [Fact] public async Task apply_should_have_candidate_for_matched_api_version() { @@ -287,6 +387,44 @@ public async Task apply_should_have_candidate_for_unspecified_api_version() private static Task Limbo( HttpContext context ) => Task.CompletedTask; + private static RouteEndpoint NewIntroducedEndpoint( int statusCode ) + { + var v1 = new ApiVersion( 1, 0 ); + var v2 = new ApiVersion( 2, 0 ); + var v3 = new ApiVersion( 3, 0 ); + var apiModel = new ApiVersionModel( [v1, v2, v3], [v1, v2, v3], [], [], [] ); + var endpointModel = new ApiVersionModel( [v2, v3], [v2, v3], [], [], [] ); + var routePattern = RoutePatternFactory.Parse( "api/values" ); + var builder = new RouteEndpointBuilder( Limbo, routePattern, 0 ) + { + Metadata = + { + new ApiVersionMetadata( apiModel, endpointModel, introducedInApiVersions: [new( v2, statusCode )] ), + }, + }; + + return (RouteEndpoint) builder.Build(); + } + + private static RouteEndpoint NewIntroducedEndpoint( IntroducedInApiVersionMetadata[] introduced, ApiVersion implementedVersion ) + { + var v1 = new ApiVersion( 1, 0 ); + var v2 = new ApiVersion( 2, 0 ); + var v3 = new ApiVersion( 3, 0 ); + var apiModel = new ApiVersionModel( [v1, v2, v3], [v1, v2, v3], [], [], [] ); + var endpointModel = new ApiVersionModel( [implementedVersion], [implementedVersion], [], [], [] ); + var routePattern = RoutePatternFactory.Parse( "api/values" ); + var builder = new RouteEndpointBuilder( Limbo, routePattern, 0 ) + { + Metadata = + { + new ApiVersionMetadata( apiModel, endpointModel, introducedInApiVersions: introduced ), + }, + }; + + return (RouteEndpoint) builder.Build(); + } + private static ApiVersionMatcherPolicy NewApiVersionMatcherPolicy( ApiVersioningOptions options = default ) => new( ApiVersionParser.Default, From 469e4f56ccd8c597a0f0794b5a0fce819dfe477b Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sat, 9 May 2026 22:52:42 -0700 Subject: [PATCH 05/26] Define introduced-in convention edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ActionApiVersionConventionBuilderBase.cs | 6 +- .../IntroducedInApiVersionConventionTest.cs | 70 +++++++++++++++++++ .../ActionApiVersionConventionBuilderBase.cs | 21 ++++-- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs index 47e99e6c..05251b02 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -82,6 +82,10 @@ public virtual void ApplyTo( ActionModel item ) } item.AddEndpointMetadata( metadata ); - AddIntroducedApiVersionMetadata( item.AddEndpointMetadata ); + + if ( !metadata.IsApiVersionNeutral ) + { + AddIntroducedApiVersionMetadata( item.AddEndpointMetadata ); + } } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs index 59d523e8..61e6bd5d 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs @@ -90,6 +90,38 @@ public void api_explorer_mapping_should_exclude_versions_before_introduced_versi later.Should().Be( Explicit ); } + [Fact] + public void apply_to_should_use_latest_introduced_version_when_multiple_are_declared() + { + // arrange + var action = ApplyConventions( typeof( MultipleIntroducedController ), nameof( MultipleIntroducedController.Get ) ); + var metadata = GetApiVersionMetadata( action ); + + // act + var model = metadata.Map( Explicit ); + var introduced = action.Selectors.Single().EndpointMetadata.OfType().ToArray(); + + // assert + model.DeclaredApiVersions.Should().Equal( new ApiVersion( 3, 0 ) ); + introduced.Select( item => item.IntroducedIn ).Should().Equal( new ApiVersion( 2, 0 ), new ApiVersion( 3, 0 ) ); + } + + [Fact] + public void apply_to_should_ignore_introduced_version_metadata_for_version_neutral_controller() + { + // arrange + var action = ApplyConventions( typeof( NeutralIntroducedController ), nameof( NeutralIntroducedController.Get ) ); + + // act + var metadata = GetApiVersionMetadata( action ); + var introduced = action.Selectors.Single().EndpointMetadata.OfType(); + + // assert + metadata.IsApiVersionNeutral.Should().BeTrue(); + metadata.IntroducedInApiVersions.Should().BeEmpty(); + introduced.Should().BeEmpty(); + } + private static ActionModel ApplyConventions( Type controllerType, string actionName ) { var controllerAttributes = controllerType.GetTypeInfo().GetCustomAttributes().Cast().ToArray(); @@ -131,5 +163,43 @@ public sealed class IntroducedController : ControllerBase public OkResult GetMapped() => Ok(); } + + [ApiController] + [ApiVersion( 1.0 )] + [ApiVersion( 2.0 )] + [ApiVersion( 3.0 )] + public sealed class MultipleIntroducedController : ControllerBase + { + [TestIntroducedInApiVersion( 2.0, StatusCode = 409 )] + [TestIntroducedInApiVersion( 3.0, StatusCode = 410 )] + public OkResult Get() => Ok(); + } + + [ApiController] + [ApiVersionNeutral] + public sealed class NeutralIntroducedController : ControllerBase + { + [IntroducedInApiVersion( "2.0" )] + public OkResult Get() => Ok(); + } + + [AttributeUsage( AttributeTargets.Method, AllowMultiple = true, Inherited = false )] + public sealed class TestIntroducedInApiVersionAttribute : Attribute, IIntroducedInApiVersionProvider + { + public TestIntroducedInApiVersionAttribute( double version ) + { + Version = version; + Versions = [new ApiVersion( version )]; + } + + public double Version { get; } + + public IReadOnlyList Versions { get; } + + public int StatusCode { get; set; } = IntroducedInApiVersionAttribute.DefaultStatusCode; + + ApiVersionProviderOptions IApiVersionProvider.Options => ApiVersionProviderOptions.Introduced; + } + #pragma warning restore CA1034 // Nested types should not be visible } \ No newline at end of file diff --git a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs index cb195eba..fca4529b 100644 --- a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -45,6 +45,7 @@ public abstract partial class ActionApiVersionConventionBuilderBase : ApiVersion /// Gets the collection of API versions in which the current action was introduced. /// /// A collection of introduced API versions. + /// When multiple introduced API versions are configured, the latest version is used as the effective introduction. protected ICollection IntroducedVersions => introduced ??= []; /// @@ -105,6 +106,8 @@ protected IntroducedInApiVersionMetadata[] GetIntroducedApiVersionMetadata() var metadata = new IntroducedInApiVersionMetadata[introduced.Count]; + introduced.Sort( static ( left, right ) => left.Version.CompareTo( right.Version ) ); + for ( var i = 0; i < introduced.Count; i++ ) { var item = introduced[i]; @@ -130,17 +133,23 @@ protected ICollection ExpandMappedVersions( IReadOnlyList( 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]; - for ( var j = 0; j < introduced.Count; j++ ) + if ( declaredVersion >= effectiveIntroduced ) { - if ( declaredVersion >= introduced[j].Version ) - { - versions.Add( declaredVersion ); - break; - } + versions.Add( declaredVersion ); } } From 94c08142fab3d31570c0724c1bdfa794f37997cf Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sat, 9 May 2026 22:52:42 -0700 Subject: [PATCH 06/26] Demonstrate IntroducedInApiVersion in BasicExample 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> --- .../Controllers/MultiVersionedController.cs | 18 +++++++++++-- .../WebApi/BasicExample/Examples.http | 25 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/examples/AspNetCore/WebApi/BasicExample/Controllers/MultiVersionedController.cs b/examples/AspNetCore/WebApi/BasicExample/Controllers/MultiVersionedController.cs index 009ad78c..34cc077e 100644 --- a/examples/AspNetCore/WebApi/BasicExample/Controllers/MultiVersionedController.cs +++ b/examples/AspNetCore/WebApi/BasicExample/Controllers/MultiVersionedController.cs @@ -5,12 +5,26 @@ [ApiVersion( 1.0 )] [ApiVersion( 2.0 )] +[ApiVersion( 3.0 )] [Route( "api/v{version:apiVersion}/[controller]" )] public class MultiVersionedController : ControllerBase { + // Shared across all versions. [HttpGet] public string Get( ApiVersion version ) => "Version " + version; - [HttpGet, MapToApiVersion( 2.0 )] - public string GetV2( ApiVersion version ) => "Version " + version; + // [MapToApiVersion] — exact-match. Reachable ONLY for v2.0. + // Requests under v1.0 or v3.0 receive the configured + // UnsupportedApiVersionStatusCode (default 400). + [HttpGet( "legacy" ), MapToApiVersion( 2.0 )] + public string GetLegacy( ApiVersion version ) => "Legacy " + version; + + // [IntroducedInApiVersion] — "from this version onward against the + // controller's declared set." Reachable for v2.0 AND v3.0 automatically. + // Requests under v1.0 receive the per-attribute status (default 404) + // — distinguishable from "version unknown" (still 400). + // When v4.0 is added to the controller's [ApiVersion] declarations, + // this action becomes reachable for v4.0 with no further changes. + [HttpGet( "modern" ), IntroducedInApiVersion( 2.0 )] + public string GetModern( ApiVersion version ) => "Modern " + version; } \ No newline at end of file diff --git a/examples/AspNetCore/WebApi/BasicExample/Examples.http b/examples/AspNetCore/WebApi/BasicExample/Examples.http index 68b06a70..0765b179 100644 --- a/examples/AspNetCore/WebApi/BasicExample/Examples.http +++ b/examples/AspNetCore/WebApi/BasicExample/Examples.http @@ -20,6 +20,12 @@ POST {{baseUrl}}/api/v1/helloworld # note: this controller has a single, version interleaved implementation GET {{baseUrl}}/api/v1/multiversioned +### Multi-Versioned - Legacy v2-only (returns 400 — exact-match on v2.0) +GET {{baseUrl}}/api/v1/multiversioned/legacy + +### Multi-Versioned - Modern (returns 404 — introduced in v2.0) +GET {{baseUrl}}/api/v1/multiversioned/modern + ### VERSION 2.0 ### Values - Get All @@ -27,4 +33,21 @@ GET {{baseUrl}}/api/values?api-version=2.0 ### Multi-Versioned - Get # note: this controller has a single, version interleaved implementation -GET {{baseUrl}}/api/v2/multiversioned \ No newline at end of file +GET {{baseUrl}}/api/v2/multiversioned + +### Multi-Versioned - Legacy v2-only (200 — exact match) +GET {{baseUrl}}/api/v2/multiversioned/legacy + +### Multi-Versioned - Modern (200 — introduced in v2.0, reachable from v2.0 onward) +GET {{baseUrl}}/api/v2/multiversioned/modern + +### VERSION 3.0 + +### Multi-Versioned - Get +GET {{baseUrl}}/api/v3/multiversioned + +### Multi-Versioned - Legacy v2-only (returns 400 — [MapToApiVersion(2.0)] is exact-match, NOT v3) +GET {{baseUrl}}/api/v3/multiversioned/legacy + +### Multi-Versioned - Modern (200 — [IntroducedInApiVersion(2.0)] is "from v2 onward", auto-reaches v3) +GET {{baseUrl}}/api/v3/multiversioned/modern \ No newline at end of file From 5a80710f1056f948ccf2624866938995d30368cd Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sat, 9 May 2026 23:10:52 -0700 Subject: [PATCH 07/26] Use exact type for introduced version equality Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IntroducedInApiVersionAttribute.cs | 5 ++++- .../IntroducedInApiVersionAttributeTest.cs | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs index acd6ca3e..34dedaa7 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs @@ -90,7 +90,10 @@ public IntroducedInApiVersionAttribute( string version ) : base( version ) { } /// public override bool Equals( object? obj ) => - obj is IntroducedInApiVersionAttribute other && base.Equals( obj ) && StatusCode == other.StatusCode; + obj is IntroducedInApiVersionAttribute other && + GetType() == obj.GetType() && + base.Equals( obj ) && + StatusCode == other.StatusCode; /// public override int GetHashCode() => HashCode.Combine( base.GetHashCode(), StatusCode ); diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs index 79793c80..5f192238 100644 --- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs +++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs @@ -84,4 +84,25 @@ public void introduced_in_api_version_attribute_should_compare_status_code_for_e version.GetHashCode().Should().NotBe( sameVersionDifferentStatus.GetHashCode() ); differentVersion.Should().BeFalse(); } + + [Fact] + public void introduced_in_api_version_attribute_should_not_equal_derived_type() + { + // arrange + var version = new IntroducedInApiVersionAttribute( "2026-12-01" ) { StatusCode = 404 }; + var derived = new DerivedIntroducedInApiVersionAttribute( "2026-12-01" ) { StatusCode = 404 }; + + // act + var baseEqualsDerived = version.Equals( derived ); + var derivedEqualsBase = derived.Equals( version ); + + // assert + baseEqualsDerived.Should().BeFalse(); + derivedEqualsBase.Should().BeFalse(); + } + + private sealed class DerivedIntroducedInApiVersionAttribute : IntroducedInApiVersionAttribute + { + public DerivedIntroducedInApiVersionAttribute( string version ) : base( version ) { } + } } \ No newline at end of file From 658a0f63f889b1bc1b6ab390870a4b31e94463c7 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sat, 9 May 2026 23:10:57 -0700 Subject: [PATCH 08/26] Materialize introduced version mappings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IntroducedInApiVersionConventionTest.cs | 18 ++++++++++++++++++ .../ActionApiVersionConventionBuilderBase.cs | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs index 61e6bd5d..6ae5c098 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs @@ -25,6 +25,24 @@ public void apply_to_should_expand_introduced_version_to_controller_declared_ver new ApiVersion( new DateOnly( 2027, 6, 1 ) ) ); } + [Fact] + public void apply_to_should_not_throw_when_action_has_only_introduced_version() + { + // arrange + var action = default( ActionModel ); + + // act + var exception = Record.Exception( () => action = ApplyConventions( typeof( IntroducedController ), nameof( IntroducedController.GetIntroduced ) ) ); + var metadata = GetApiVersionMetadata( action! ); + var model = metadata.Map( Explicit ); + + // assert + exception.Should().BeNull(); + model.DeclaredApiVersions.Should().Equal( + new ApiVersion( new DateOnly( 2026, 12, 1 ) ), + new ApiVersion( new DateOnly( 2027, 6, 1 ) ) ); + } + [Theory] [InlineData( "2026-11-12", false )] [InlineData( "2026-12-01", true )] diff --git a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs index fca4529b..b3ca841d 100644 --- a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -131,7 +131,7 @@ protected ICollection ExpandMappedVersions( IReadOnlyList( mapped ); + var versions = mapped is null ? new HashSet() : new HashSet( mapped ); var effectiveIntroduced = introduced[0].Version; From 865f35d6070678d2e63c3eff430772693234ea45 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sat, 9 May 2026 23:11:03 -0700 Subject: [PATCH 09/26] Stabilize introduced-later routing errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Routing/ApiVersionMatcherPolicy.cs | 11 ++- .../Routing/ClientErrorEndpointBuilder.cs | 5 ++ .../IntroducedInApiVersionStatusCode.cs | 7 +- .../Routing/ApiVersionMatcherPolicyTest.cs | 67 +++++++++++++++++++ 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs index d3c8a29e..f576d204 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs @@ -109,6 +109,7 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList( capacity ); var introducedLater = default( Dictionary ); + var introducedLaterStatusCodes = default( Dictionary ); var source = ApiVersionSource; var supported = default( SortedSet ); var deprecated = default( SortedSet ); @@ -150,7 +151,15 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList() ) { - if ( attribute is IIntroducedInApiVersionProvider introducedProvider ) - { - Add( introducedProvider, ref introduced ); - } + Add( introducedProvider, ref introduced ); } } diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs index 5d7d42ca..4c51cbe0 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs @@ -184,6 +184,35 @@ public async Task jump_table_should_use_configured_status_code_for_introduced_st responseContext.Response.StatusCode.Should().Be( 410 ); } + [Fact] + public async Task apply_should_use_configured_status_code_for_introduced_status_code_zero() + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, "1.0" ); + feature.SetupProperty( f => f.RawRequestedApiVersions, ["1.0"] ); + feature.SetupProperty( f => f.RequestedApiVersion, new ApiVersion( 1, 0 ) ); + + var options = new ApiVersioningOptions() + { + UnsupportedApiVersionStatusCode = 410, + }; + var policy = NewApiVersionMatcherPolicy( options ); + var endpoint = NewIntroducedEndpoint( IntroducedInApiVersionAttribute.UseConfiguredStatusCode ); + var candidates = new CandidateSet( [endpoint], [[]], [0] ); + var httpContext = NewHttpContext( feature ); + var responseContext = new DefaultHttpContext(); + + // act + await policy.ApplyAsync( httpContext, candidates ); + await httpContext.GetEndpoint().RequestDelegate!( responseContext ); + + // assert + httpContext.GetEndpoint().DisplayName.Should().Be( "410 Introduced API Version" ); + responseContext.Response.StatusCode.Should().Be( 410 ); + } + [Fact] public async Task apply_should_use_introduced_endpoint_for_controller_declared_version_before_action() { @@ -247,6 +276,44 @@ public async Task jump_table_should_use_latest_matching_introduced_version( stri responseContext.Response.StatusCode.Should().Be( 410 ); } + [Fact] + public async Task jump_table_should_use_smallest_status_code_for_same_introduced_version() + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, "1.0" ); + feature.SetupProperty( f => f.RawRequestedApiVersions, ["1.0"] ); + + var options = new ApiVersioningOptions() + { + ApiVersionReader = new QueryStringApiVersionReader(), + }; + var policy = NewApiVersionMatcherPolicy( options ); + var httpContext = NewHttpContext( feature, queryParameters: new() { ["api-version"] = new( "1.0" ) } ); + var v2 = new ApiVersion( 2, 0 ); + var first = NewIntroducedEndpoint( [new( v2, 410 )], implementedVersion: v2 ); + var second = NewIntroducedEndpoint( [new( v2, 404 )], implementedVersion: v2 ); + var edges = policy.GetEdges( [first, second] ); + var tableEdges = new List(); + + for ( var i = 0; i < edges.Count; i++ ) + { + tableEdges.Add( new( edges[i].State, i ) ); + } + + var jumpTable = policy.BuildJumpTable( 42, tableEdges ); + var selected = edges[jumpTable.GetDestination( httpContext )].Endpoints[0]; + var responseContext = new DefaultHttpContext(); + + // act + await selected.RequestDelegate!( responseContext ); + + // assert + selected.DisplayName.Should().Be( "404 Introduced API Version" ); + responseContext.Response.StatusCode.Should().Be( 404 ); + } + [Fact] public async Task apply_should_have_candidate_for_matched_api_version() { From aac5a7d1f0e879c72fa5fe5c27bf4cdab7a79a09 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 07:32:34 -0700 Subject: [PATCH 10/26] Support introduced minimal API endpoints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Builder/EndpointBuilderFinalizer.cs | 52 +++++++-- .../IEndpointConventionBuilderExtensions.cs | 82 ++++++++++++++ ...EndpointConventionBuilderExtensionsTest.cs | 103 ++++++++++++++++++ 3 files changed, 230 insertions(+), 7 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs index 846fdb04..fa90a3f9 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs @@ -216,26 +216,28 @@ private static bool TryGetApiVersions( IList metadata, out ApiVersionBuc private static ApiVersionMetadata Build( IList metadata, ApiVersionSet versionSet, ApiVersioningOptions options ) { var name = versionSet.Name; - var introducedInApiVersions = metadata.OfType().ToArray(); ApiVersionModel? apiModel; if ( !TryGetApiVersions( metadata, out var buckets ) || ( apiModel = versionSet.Build( options ) ).IsApiVersionNeutral ) { + var neutralIntroducedInApiVersions = metadata.OfType().ToArray(); + if ( string.IsNullOrEmpty( name ) ) { - return introducedInApiVersions.Length == 0 + return neutralIntroducedInApiVersions.Length == 0 ? ApiVersionMetadata.Neutral - : new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, introducedInApiVersions: introducedInApiVersions ); + : new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, introducedInApiVersions: neutralIntroducedInApiVersions ); } - return new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, name, introducedInApiVersions ); + return new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, name, neutralIntroducedInApiVersions ); } ApiVersionModel endpointModel; ApiVersion[] emptyVersions; var inheritedSupported = apiModel.SupportedApiVersions; var inheritedDeprecated = apiModel.DeprecatedApiVersions; + var introducedInApiVersions = metadata.OfType().ToArray(); if ( buckets.AreEmpty ) { @@ -249,10 +251,12 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v else { emptyVersions = []; + var introducedVersions = ExpandIntroducedVersions( apiModel.DeclaredApiVersions, introducedInApiVersions ); + endpointModel = new( - declaredVersions: emptyVersions, - inheritedSupported, - inheritedDeprecated, + declaredVersions: introducedVersions ?? emptyVersions, + introducedVersions is null ? inheritedSupported : introducedVersions.Intersect( inheritedSupported ), + introducedVersions is null ? inheritedDeprecated : introducedVersions.Intersect( inheritedDeprecated ), emptyVersions, emptyVersions ); } @@ -285,6 +289,40 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v return new( apiModel, endpointModel, name, introducedInApiVersions ); } + private static ApiVersion[]? ExpandIntroducedVersions( + IReadOnlyList declaredVersions, + IntroducedInApiVersionMetadata[] introducedInApiVersions ) + { + if ( introducedInApiVersions.Length == 0 ) + { + return default; + } + + var effectiveIntroduced = introducedInApiVersions[0].IntroducedIn; + + for ( var i = 1; i < introducedInApiVersions.Length; i++ ) + { + if ( introducedInApiVersions[i].IntroducedIn > effectiveIntroduced ) + { + effectiveIntroduced = introducedInApiVersions[i].IntroducedIn; + } + } + + var versions = new List(); + + for ( var i = 0; i < declaredVersions.Count; i++ ) + { + var declaredVersion = declaredVersions[i]; + + if ( declaredVersion >= effectiveIntroduced ) + { + versions.Add( declaredVersion ); + } + } + + return [.. versions]; + } + private record struct ApiVersionBuckets( IReadOnlyList Mapped, IReadOnlyList Supported, diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs index 6e48e515..9703c18c 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs @@ -80,6 +80,73 @@ public TBuilder MapToApiVersion( ApiVersion apiVersion ) return builder; } + /// + /// Indicates that the configured endpoint was introduced in the specified API version. + /// + /// The major version number. + /// The optional minor version number. + /// The optional version status. + /// The HTTP status code for earlier API versions. + public TBuilder IntroducedInApiVersion( + int majorVersion, + int? minorVersion = default, + string? status = default, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) => + builder.IntroducedInApiVersion( new ApiVersion( majorVersion, minorVersion, status ), statusCode ); + + /// + /// Indicates that the configured endpoint was introduced in the specified API version. + /// + /// The version number. + /// The optional version status. + /// The HTTP status code for earlier API versions. + public TBuilder IntroducedInApiVersion( + double version, + string? status = default, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) => + builder.IntroducedInApiVersion( new ApiVersion( version, status ), statusCode ); + + /// + /// Indicates that the configured endpoint was introduced in the specified API version. + /// + /// The version year. + /// The version month. + /// The version day. + /// The optional version status. + /// The HTTP status code for earlier API versions. + public TBuilder IntroducedInApiVersion( + int year, + int month, + int day, + string? status = default, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) => + builder.IntroducedInApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ), statusCode ); + + /// + /// Indicates that the configured endpoint was introduced in the specified API version. + /// + /// The group version. + /// The optional version status. + /// The HTTP status code for earlier API versions. + public TBuilder IntroducedInApiVersion( + DateOnly groupVersion, + string? status = default, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) => + builder.IntroducedInApiVersion( new ApiVersion( groupVersion, status ), statusCode ); + + /// + /// Indicates that the configured endpoint was introduced in the specified API version. + /// + /// The API version the endpoint was introduced in. + /// The HTTP status code for earlier API versions. + public TBuilder IntroducedInApiVersion( + ApiVersion apiVersion, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) + { + builder.Add( endpoint => AddMetadata( endpoint, new IntroducedInApiVersionConvention( apiVersion, statusCode ) ) ); + return builder; + } + /// /// Indicates that the endpoint is API version-neutral. /// @@ -412,6 +479,21 @@ private sealed class ReportApiVersionsConvention : IReportApiVersions public void Report( HttpResponse response, ApiVersionModel apiVersionModel ) { } } + private sealed class IntroducedInApiVersionConvention : IIntroducedInApiVersionProvider + { + public IntroducedInApiVersionConvention( ApiVersion version, int statusCode ) + { + Versions = new SingleItemReadOnlyList( version ); + StatusCode = statusCode; + } + + public ApiVersionProviderOptions Options => Introduced; + + public IReadOnlyList Versions { get; } + + public int StatusCode { get; } + } + private sealed class Convention : IApiVersionProvider { private static ReportApiVersionsConvention? reportApiVersions; diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs index 448d8b07..4b84516a 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs @@ -409,6 +409,109 @@ public void has_api_version_should_propagate_to_version_set() versionSet.Build( new() ).SupportedApiVersions.Single().Should().Be( new ApiVersion( 1.0 ) ); } + [Fact] + public void introduced_in_api_version_should_add_convention() + { + // arrange + var conventions = new Mock(); + var provider = default( IIntroducedInApiVersionProvider ); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + provider = endpoint.Metadata.OfType().First(); + } ); + + var route = new RouteHandlerBuilder( [conventions.Object] ); + + // act + route.IntroducedInApiVersion( 2.0 ); + + // assert + provider.Should().BeEquivalentTo( + new + { + Options = Introduced, + Versions = new[] { new ApiVersion( 2.0 ) }, + StatusCode = IntroducedInApiVersionAttribute.DefaultStatusCode, + } ); + } + + [Fact] + public void with_api_version_set_should_expand_introduced_version() + { + // arrange + var dataSources = new List(); + var app = new Mock(); + + app.SetupGet( a => a.ServiceProvider ).Returns( new MockServiceProvider() ); + app.SetupGet( a => a.DataSources ).Returns( dataSources ); + + // act + var versionSet = app.Object.NewApiVersionSet() + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .HasApiVersion( 3.0 ) + .Build(); + + app.Object.MapGet( "/test", () => Results.Ok() ) + .WithApiVersionSet( versionSet ) + .IntroducedInApiVersion( 2.0 ); + + // assert + var metadata = dataSources.Single() + .Endpoints + .Single() + .Metadata + .OfType() + .Single(); + + metadata.MappingTo( new ApiVersion( 1.0 ) ).Should().Be( ApiVersionMapping.None ); + metadata.MappingTo( new ApiVersion( 2.0 ) ).Should().Be( ApiVersionMapping.Explicit ); + metadata.MappingTo( new ApiVersion( 3.0 ) ).Should().Be( ApiVersionMapping.Explicit ); + metadata.IntroducedInApiVersions.Single().Should().BeEquivalentTo( + new IntroducedInApiVersionMetadata( new ApiVersion( 2.0 ), IntroducedInApiVersionAttribute.DefaultStatusCode ) ); + } + + [Fact] + public void with_api_version_set_should_prefer_explicit_versions_over_introduced_version() + { + // arrange + var dataSources = new List(); + var app = new Mock(); + + app.SetupGet( a => a.ServiceProvider ).Returns( new MockServiceProvider() ); + app.SetupGet( a => a.DataSources ).Returns( dataSources ); + + // act + var versionSet = app.Object.NewApiVersionSet() + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .HasApiVersion( 3.0 ) + .Build(); + + app.Object.MapGet( "/test", () => Results.Ok() ) + .WithApiVersionSet( versionSet ) + .IntroducedInApiVersion( 2.0 ) + .HasApiVersion( 3.0 ); + + // assert + var metadata = dataSources.Single() + .Endpoints + .Single() + .Metadata + .OfType() + .Single(); + + metadata.MappingTo( new ApiVersion( 1.0 ) ).Should().Be( ApiVersionMapping.None ); + metadata.MappingTo( new ApiVersion( 2.0 ) ).Should().Be( ApiVersionMapping.None ); + metadata.MappingTo( new ApiVersion( 3.0 ) ).Should().Be( ApiVersionMapping.Explicit ); + } + [Fact] public void has_deprecated_api_version_should_add_convention() { From 86b2f1fa33e6aa6c89e40a12c0802fc1af596078 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 07:32:35 -0700 Subject: [PATCH 11/26] Compare edge keys by fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Asp.Versioning.Http/Routing/EdgeKey.cs | 15 ++++++++++- .../Routing/ApiVersionMatcherPolicyTest.cs | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs index bf8a6193..662d4bf1 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs @@ -62,7 +62,20 @@ internal EdgeKey( internal static EdgeKey AssumeDefault => new( EndpointType.AssumeDefault, new( new RoutePatternComparer() ) ); - public bool Equals( [AllowNull] EdgeKey other ) => GetHashCode() == other.GetHashCode(); + public bool Equals( [AllowNull] EdgeKey other ) + { + if ( EndpointType != other.EndpointType ) + { + return false; + } + + if ( EndpointType is UserDefined or IntroducedLater && ApiVersion != other.ApiVersion ) + { + return false; + } + + return EndpointType != IntroducedLater || StatusCode == other.StatusCode; + } public override bool Equals( object? obj ) => obj is EdgeKey other && Equals( other ); diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs index 4c51cbe0..c0c6b943 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs @@ -314,6 +314,32 @@ public async Task jump_table_should_use_smallest_status_code_for_same_introduced responseContext.Response.StatusCode.Should().Be( 404 ); } + [Fact] + public void edge_key_equals_should_compare_introduced_later_status_code() + { + // arrange + var keyType = typeof( ApiVersionMatcherPolicy ).Assembly.GetType( "Asp.Versioning.Routing.EdgeKey" ); + var ctor = keyType!.GetConstructor( + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + binder: null, + [typeof( ApiVersion ), typeof( int ), typeof( ApiVersionMetadata ), typeof( HashSet )], + modifiers: null ); + var apiVersion = new ApiVersion( 2.0 ); + var metadata = ApiVersionMetadata.Empty; + var routePatterns = new HashSet(); + var left = ctor!.Invoke( [apiVersion, 404, metadata, routePatterns] ); + var same = ctor.Invoke( [apiVersion, 404, metadata, routePatterns] ); + var differentStatusCode = ctor.Invoke( [apiVersion, 410, metadata, routePatterns] ); + + // act + var sameResult = left.Equals( same ); + var differentResult = left.Equals( differentStatusCode ); + + // assert + sameResult.Should().BeTrue(); + differentResult.Should().BeFalse(); + } + [Fact] public async Task apply_should_have_candidate_for_matched_api_version() { From c1207f81f459c5ed80485cf9ff44aeb9117fcbb5 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 07:39:55 -0700 Subject: [PATCH 12/26] Demonstrate IntroducedInApiVersion in MinimalApiExample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../WebApi/MinimalApiExample/Examples.http | 22 ++++++++++++- .../WebApi/MinimalApiExample/Program.cs | 32 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/examples/AspNetCore/WebApi/MinimalApiExample/Examples.http b/examples/AspNetCore/WebApi/MinimalApiExample/Examples.http index 160cfd1e..61d35700 100644 --- a/examples/AspNetCore/WebApi/MinimalApiExample/Examples.http +++ b/examples/AspNetCore/WebApi/MinimalApiExample/Examples.http @@ -7,6 +7,12 @@ ### Weather Forecast - Get All GET {{baseUrl}}/weatherforecast?api-version=1.0 +### MultiVersioned - Legacy v2-only (returns 400 — exact-match on v2.0) +GET {{baseUrl}}/multiversioned/legacy?api-version=1.0 + +### MultiVersioned - Modern (returns 404 — introduced in v2.0) +GET {{baseUrl}}/multiversioned/modern?api-version=1.0 + ### Weather Forecast - Remove (Version-Neutral) DELETE {{baseUrl}}/weatherforecast?api-version=1.0 @@ -21,5 +27,19 @@ content-type: application/json {"date":"2026-02-22T15:00:00-08:00","temperatureC":12,"temperatureF":54,"summary":"Chilly"} +### MultiVersioned - Legacy v2-only (200 — exact match) +GET {{baseUrl}}/multiversioned/legacy?api-version=2.0 + +### MultiVersioned - Modern (200 — introduced in v2.0, reachable from v2.0 onward) +GET {{baseUrl}}/multiversioned/modern?api-version=2.0 + ### Weather Forecast - Remove (Version-Neutral) -DELETE {{baseUrl}}/weatherforecast?api-version=2.0 \ No newline at end of file +DELETE {{baseUrl}}/weatherforecast?api-version=2.0 + +### VERSION 3.0 + +### MultiVersioned - Legacy v2-only (returns 400 — .HasApiVersion(2.0) is exact-match, NOT v3) +GET {{baseUrl}}/multiversioned/legacy?api-version=3.0 + +### MultiVersioned - Modern (200 — .IntroducedInApiVersion(2.0) is "from v2 onward", auto-reaches v3) +GET {{baseUrl}}/multiversioned/modern?api-version=3.0 \ No newline at end of file diff --git a/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs b/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs index a37595fa..fe4ae614 100644 --- a/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs +++ b/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs @@ -56,6 +56,38 @@ forecast.MapDelete( "/weatherforecast", () => Results.NoContent() ) .IsApiVersionNeutral(); +// ---- IntroducedInApiVersion demonstration ---- +// +// An explicit api version set declares v1.0, v2.0, and v3.0. Endpoints +// attached to it inherit that set, so IntroducedInApiVersion's +// "from-this-version-onward" expansion has the full controller-declared +// set to filter against. + +var multiVersionSet = app.NewApiVersionSet( "MultiVersioned" ) + .HasApiVersion( new ApiVersion( 1.0 ) ) + .HasApiVersion( new ApiVersion( 2.0 ) ) + .HasApiVersion( new ApiVersion( 3.0 ) ) + .Build(); + +// .HasApiVersion( 2.0 ) on an endpoint attached to a version set that +// also declares v1.0 and v3.0 is exact-match — equivalent to +// [MapToApiVersion(2.0)]. v1.0 and v3.0 callers receive the configured +// UnsupportedApiVersionStatusCode (default 400). +app.MapGet( "/multiversioned/legacy", ( ApiVersion version ) => + Results.Ok( $"Legacy {version}" ) ) + .WithApiVersionSet( multiVersionSet ) + .HasApiVersion( 2.0 ); + +// .IntroducedInApiVersion( 2.0 ) is "from this version onward against +// the declared set." Reachable for v2.0 AND v3.0 automatically. +// Requests under v1.0 receive the per-attribute status (default 404). +// When v4.0 is added to multiVersionSet, this endpoint becomes reachable +// for v4.0 with no further changes. +app.MapGet( "/multiversioned/modern", ( ApiVersion version ) => + Results.Ok( $"Modern {version}" ) ) + .WithApiVersionSet( multiVersionSet ) + .IntroducedInApiVersion( 2.0 ); + app.Run(); internal record WeatherForecast( DateTime Date, int TemperatureC, string? Summary ) From 0690b02e9448de224953ee781a7766bb8ca0c28d Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 08:45:08 -0700 Subject: [PATCH 13/26] Demonstrate IntroducedInApiVersion in OpenApiExample 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> --- .../Controllers/MultiVersionedController.cs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 examples/AspNetCore/WebApi/OpenApiExample/Controllers/MultiVersionedController.cs diff --git a/examples/AspNetCore/WebApi/OpenApiExample/Controllers/MultiVersionedController.cs b/examples/AspNetCore/WebApi/OpenApiExample/Controllers/MultiVersionedController.cs new file mode 100644 index 00000000..360a1c2f --- /dev/null +++ b/examples/AspNetCore/WebApi/OpenApiExample/Controllers/MultiVersionedController.cs @@ -0,0 +1,84 @@ +namespace ApiVersioning.Examples.Controllers; + +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; + +/// +/// Demonstrates [MapToApiVersion] (exact-match) vs. [IntroducedInApiVersion] +/// (from-this-version-onward) on a single controller declaring multiple versions. +/// +/// +/// +/// Open the Scalar UI per version (1.0, 2.0, 3.0) to see how the two attributes +/// affect the OpenAPI surface differently: +/// +/// +/// v1.0 — only the shared GET appears. +/// +/// v2.0 — shared GET, /legacy, and /modern all appear. +/// +/// +/// v3.0 — shared GET and /modern appear; /legacy is filtered out +/// because [MapToApiVersion(2.0)] is exact-match. /modern is +/// present without any code change because [IntroducedInApiVersion(2.0)] +/// means "from v2.0 onward against the controller's declared set." +/// +/// +/// +[ApiVersion( 1.0 )] +[ApiVersion( 2.0 )] +[ApiVersion( 3.0 )] +[Route( "api/[controller]" )] +public class MultiVersionedController : ControllerBase +{ + /// + /// Get the resource (shared across all versions). + /// + /// The requested API version. + /// A version-tagged response. + /// The resource was retrieved. + [HttpGet] + [Produces( "application/json" )] + [ProducesResponseType( typeof( object ), 200 )] + public IActionResult Get( ApiVersion version ) => + Ok( new { version = version.ToString(), shared = true } ); + + /// + /// A v2-only endpoint declared with [MapToApiVersion(2.0)]. + /// + /// + /// Reachable ONLY for v2.0. v1.0 and v3.0 callers receive the configured + /// UnsupportedApiVersionStatusCode (default 400). When v3.0 was + /// added to this controller's [ApiVersion] declarations, this + /// action did NOT automatically participate; if v3.0 should reach it, the + /// attribute must be edited to + /// [MapToApiVersion(2.0, 3.0)]. + /// + /// The requested API version. + /// Reached the v2-only endpoint. + [HttpGet( "legacy" ), MapToApiVersion( 2.0 )] + [Produces( "application/json" )] + [ProducesResponseType( typeof( object ), 200 )] + public IActionResult GetLegacy( ApiVersion version ) => + Ok( new { version = version.ToString(), legacy = true } ); + + /// + /// An endpoint introduced in v2.0 declared with [IntroducedInApiVersion(2.0)]. + /// + /// + /// Reachable for v2.0 AND v3.0 automatically. v1.0 callers receive the + /// per-attribute status (default 404), distinguishable from "version + /// unknown" (still 400). When v4.0 is added to this controller's + /// [ApiVersion] declarations, this action becomes reachable for + /// v4.0 with no further changes. + /// + /// The requested API version. + /// Reached the v2-onwards endpoint. + /// The endpoint did not exist in the requested version. + [HttpGet( "modern" ), IntroducedInApiVersion( 2.0 )] + [Produces( "application/json" )] + [ProducesResponseType( typeof( object ), 200 )] + [ProducesResponseType( 404 )] + public IActionResult GetModern( ApiVersion version ) => + Ok( new { version = version.ToString(), modern = true } ); +} From 29e5d72937df39d6297f56f14069da19ce254dab Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 09:20:08 -0700 Subject: [PATCH 14/26] Stabilize introduced slow-path status selection Scan all introduced-later candidates in the slow path and select the smallest resolved status code for matching introduced versions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Routing/ApiVersionMatcherPolicy.cs | 7 ++++- .../Routing/ClientErrorEndpointBuilder.cs | 24 +++++++-------- .../IntroducedInApiVersionStatusCode.cs | 29 +++++++++++++++---- .../Routing/ApiVersionMatcherPolicyTest.cs | 27 +++++++++++++++++ 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs index f576d204..c240d0a4 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs @@ -238,7 +238,12 @@ public IReadOnlyList GetEdges( IReadOnlyList endpoints { builder.Add( endpoint, version, metadata ); } - else if ( IntroducedInApiVersionStatusCode.TryGet( endpoint, metadata, version, out var statusCode ) ) + else if ( IntroducedInApiVersionStatusCode.TryGet( + endpoint, + metadata, + version, + Options.UnsupportedApiVersionStatusCode, + out var statusCode ) ) { builder.AddIntroducedLater( endpoint, version, statusCode, metadata ); } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs index 61852933..bd2ae837 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs @@ -52,6 +52,8 @@ private int GetIntroducedInApiVersionStatusCode() return 0; } + var result = 0; + for ( var i = 0; i < candidates.Count; i++ ) { ref readonly var candidate = ref candidates[i]; @@ -62,25 +64,21 @@ private int GetIntroducedInApiVersionStatusCode() continue; } - metadata.Deconstruct( out var apiModel, out _ ); - - if ( !apiModel.DeclaredApiVersions.Contains( apiVersion ) ) + if ( IntroducedInApiVersionStatusCode.TryGet( + candidate.Endpoint, + metadata, + apiVersion, + options.UnsupportedApiVersionStatusCode, + out var statusCode ) ) { - continue; - } - - if ( IntroducedInApiVersionStatusCode.TryGet( candidate.Endpoint, metadata, apiVersion, out var statusCode ) ) - { - if ( statusCode == IntroducedInApiVersionAttribute.UseConfiguredStatusCode ) + if ( result == 0 || statusCode < result ) { - statusCode = options.UnsupportedApiVersionStatusCode; + result = statusCode; } - - return statusCode; } } - return 0; + return result; } private static string DisplayName( Endpoint endpoint ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs index 64ed471f..4aaaf7aa 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs @@ -7,7 +7,12 @@ namespace Asp.Versioning.Routing; internal static class IntroducedInApiVersionStatusCode { - internal static bool TryGet( Endpoint endpoint, ApiVersionMetadata metadata, ApiVersion apiVersion, out int statusCode ) + internal static bool TryGet( + Endpoint endpoint, + ApiVersionMetadata metadata, + ApiVersion apiVersion, + int unsupportedApiVersionStatusCode, + out int statusCode ) { metadata.Deconstruct( out var apiModel, out _ ); @@ -17,21 +22,21 @@ internal static bool TryGet( Endpoint endpoint, ApiVersionMetadata metadata, Api return false; } - if ( TryGet( metadata.IntroducedInApiVersions, apiVersion, out statusCode ) ) + if ( TryGet( metadata.IntroducedInApiVersions, apiVersion, unsupportedApiVersionStatusCode, out statusCode ) ) { return true; } var endpointMetadata = endpoint.Metadata; - if ( TryGet( endpointMetadata.GetOrderedMetadata(), apiVersion, out statusCode ) ) + if ( TryGet( endpointMetadata.GetOrderedMetadata(), apiVersion, unsupportedApiVersionStatusCode, out statusCode ) ) { return true; } var reflectedIntroduced = GetIntroducedInApiVersions( endpointMetadata ); - return reflectedIntroduced is not null && TryGet( reflectedIntroduced, apiVersion, out statusCode ); + return reflectedIntroduced is not null && TryGet( reflectedIntroduced, apiVersion, unsupportedApiVersionStatusCode, out statusCode ); } internal static bool HasIntroducedInApiVersion( Endpoint endpoint, ApiVersionMetadata metadata ) @@ -48,23 +53,35 @@ internal static bool HasIntroducedInApiVersion( Endpoint endpoint, ApiVersionMet private static bool TryGet( IReadOnlyList introduced, ApiVersion apiVersion, + int unsupportedApiVersionStatusCode, out int statusCode ) { var matched = default( IntroducedInApiVersionMetadata ); + var matchedStatusCode = 0; for ( var i = 0; i < introduced.Count; i++ ) { var current = introduced[i]; + var currentStatusCode = current.StatusCode; + + if ( currentStatusCode == IntroducedInApiVersionAttribute.UseConfiguredStatusCode ) + { + currentStatusCode = unsupportedApiVersionStatusCode; + } - if ( apiVersion < current.IntroducedIn && ( matched is null || current.IntroducedIn > matched.IntroducedIn ) ) + if ( apiVersion < current.IntroducedIn && + ( matched is null || + current.IntroducedIn > matched.IntroducedIn || + ( current.IntroducedIn == matched.IntroducedIn && currentStatusCode < matchedStatusCode ) ) ) { matched = current; + matchedStatusCode = currentStatusCode; } } if ( matched is not null ) { - statusCode = matched.StatusCode; + statusCode = matchedStatusCode; return true; } diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs index c0c6b943..d98de0e9 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs @@ -238,6 +238,33 @@ public async Task apply_should_use_introduced_endpoint_for_controller_declared_v responseContext.Response.StatusCode.Should().Be( 404 ); } + [Fact] + public async Task apply_should_use_smallest_status_code_for_same_introduced_version() + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, "1.0" ); + feature.SetupProperty( f => f.RawRequestedApiVersions, ["1.0"] ); + feature.SetupProperty( f => f.RequestedApiVersion, new ApiVersion( 1, 0 ) ); + + var policy = NewApiVersionMatcherPolicy(); + var v2 = new ApiVersion( 2, 0 ); + var first = NewIntroducedEndpoint( [new( v2, 410 )], implementedVersion: v2 ); + var second = NewIntroducedEndpoint( [new( v2, 404 )], implementedVersion: v2 ); + var candidates = new CandidateSet( [first, second], [[], []], [0, 0] ); + var httpContext = NewHttpContext( feature ); + var responseContext = new DefaultHttpContext(); + + // act + await policy.ApplyAsync( httpContext, candidates ); + await httpContext.GetEndpoint().RequestDelegate!( responseContext ); + + // assert + httpContext.GetEndpoint().DisplayName.Should().Be( "404 Introduced API Version" ); + responseContext.Response.StatusCode.Should().Be( 404 ); + } + [Theory] [InlineData( "1.0" )] [InlineData( "2.0" )] From 93359202627b8dbfe6fdfc8ecfb155988ab2c407 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 09:20:09 -0700 Subject: [PATCH 15/26] Clarify mapped version constraint docs Update HasMappedVersions XML docs to reflect introduced API version constraints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Conventions/ActionApiVersionConventionBuilderBase.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs index b3ca841d..a0d74b83 100644 --- a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -36,9 +36,12 @@ public abstract partial class ActionApiVersionConventionBuilderBase : ApiVersion protected ICollection MappedVersions => mapped ??= []; /// - /// Gets a value indicating whether any explicit API version mappings are configured. + /// Gets a value indicating whether any action-level API version constraints (mapped or introduced) are configured. /// - /// True if explicit API version mappings are configured; otherwise, false. + /// + /// True if any mapped or + /// introduced versions are configured for the action; otherwise, false. + /// protected bool HasMappedVersions => ( mapped is not null && mapped.Count > 0 ) || ( introduced is not null && introduced.Count > 0 ); /// From 5ab4956c2dfdeece9ebf191204825b3c8e3c0a82 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 10:04:17 -0700 Subject: [PATCH 16/26] Write problem details for introduced endpoints Tests were added first and observed to fail before the implementation: - ApiVersionMatcherPolicyTest.jump_table_should_write_problem_details_for_introduced_endpoint initially failed with: Expected context.Response.ContentType to be "application/problem+json", but found . - ApiVersionMatcherPolicyTest.apply_should_write_problem_details_for_introduced_endpoint initially failed with: Expected context.Response.ContentType to be "application/problem+json", but found . The fix threads the introduced API version into fast and slow introduced endpoints, writes the introduced problem+json response, reports API versions when enabled, adds parity and negative regression coverage, and folds the CodeQL nested-if in ClientErrorEndpointBuilder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WebApi/src/Asp.Versioning.Http/Format.cs | 1 + .../Routing/ApiVersionMatcherPolicy.cs | 28 +- .../Routing/ApiVersionPolicyJumpTable.cs | 1 + .../Routing/ClientErrorEndpointBuilder.cs | 21 +- .../Routing/EdgeBuilder.cs | 15 +- .../Routing/EndpointProblem.cs | 30 ++- .../Routing/IntroducedInApiVersionEndpoint.cs | 14 + .../IntroducedInApiVersionStatusCode.cs | 31 ++- .../src/Asp.Versioning.Http/SR.Designer.cs | 11 +- .../WebApi/src/Asp.Versioning.Http/SR.resx | 5 +- .../Routing/ApiVersionMatcherPolicyTest.cs | 252 ++++++++++++++++++ .../ProblemDetailsDefaults.cs | 10 + 12 files changed, 390 insertions(+), 29 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Format.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Format.cs index 060138aa..b1aa18a2 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Format.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Format.cs @@ -11,6 +11,7 @@ internal static class Format internal static readonly CompositeFormat InvalidMediaTypeTemplate = CompositeFormat.Parse( CommonSR.InvalidMediaTypeTemplate ); internal static readonly CompositeFormat UnsetRequestDelegate = CompositeFormat.Parse( SR.UnsetRequestDelegate ); internal static readonly CompositeFormat VersionedResourceNotSupported = CompositeFormat.Parse( SR.VersionedResourceNotSupported ); + internal static readonly CompositeFormat VersionedResourceNotIntroduced = CompositeFormat.Parse( SR.VersionedResourceNotIntroduced ); internal static readonly CompositeFormat InvalidDefaultApiVersion = CompositeFormat.Parse( SR.InvalidDefaultApiVersion ); internal static readonly CompositeFormat InvalidPolicyKey = CompositeFormat.Parse( CommonSR.InvalidPolicyKey ); } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs index c240d0a4..d8e54b45 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs @@ -95,6 +95,11 @@ public async Task ApplyAsync( HttpContext httpContext, CandidateSet candidates ) if ( !matched && hasCandidates && !DifferByRouteConstraintsOnly( candidates ) ) { + if ( Options.ReportApiVersions ) + { + httpContext.Features.Set( NewPolicyFeature( candidates ) ); + } + var builder = new ClientErrorEndpointBuilder( feature, candidates, Options, logger ); httpContext.SetEndpoint( builder.Build() ); } @@ -243,9 +248,10 @@ public IReadOnlyList GetEdges( IReadOnlyList endpoints metadata, version, Options.UnsupportedApiVersionStatusCode, - out var statusCode ) ) + out var statusCode, + out var introducedIn ) ) { - builder.AddIntroducedLater( endpoint, version, statusCode, metadata ); + builder.AddIntroducedLater( endpoint, version, statusCode, introducedIn, metadata ); } } } @@ -403,6 +409,24 @@ private static void Collate( return new( new( model, model ) ); } + private static ApiVersionPolicyFeature? NewPolicyFeature( CandidateSet candidates ) + { + var supported = default( SortedSet ); + var deprecated = default( SortedSet ); + + for ( var i = 0; i < candidates.Count; i++ ) + { + var metadata = candidates[i].Endpoint.Metadata.GetMetadata(); + + if ( metadata is not null ) + { + Collate( metadata, ref supported, ref deprecated ); + } + } + + return NewPolicyFeature( supported, deprecated ); + } + private static (bool Matched, bool HasCandidates) MatchApiVersion( CandidateSet candidates, ApiVersion? apiVersion ) { var total = candidates.Count; diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs index 0b980398..a528cb7f 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs @@ -111,6 +111,7 @@ public override int GetDestination( HttpContext httpContext ) if ( rejection.IntroducedLater.TryGetValue( apiVersion, out destination ) ) { + httpContext.Features.Set( policyFeature ); return destination; } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs index bd2ae837..ec1a7408 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs @@ -33,26 +33,27 @@ public Endpoint Build() return new UnspecifiedApiVersionEndpoint( logger, options, GetDisplayNames() ); } - var introducedInApiVersionStatusCode = GetIntroducedInApiVersionStatusCode(); + var (introducedInApiVersionStatusCode, introducedIn) = GetIntroducedInApiVersionStatusCode(); if ( introducedInApiVersionStatusCode > 0 ) { - return new IntroducedInApiVersionEndpoint( introducedInApiVersionStatusCode ); + return new IntroducedInApiVersionEndpoint( options, introducedInApiVersionStatusCode, introducedIn! ); } return new UnsupportedApiVersionEndpoint( options ); } - private int GetIntroducedInApiVersionStatusCode() + private (int StatusCode, ApiVersion? IntroducedIn) GetIntroducedInApiVersionStatusCode() { var apiVersion = feature.RequestedApiVersion; if ( apiVersion is null ) { - return 0; + return default; } var result = 0; + var introducedIn = default( ApiVersion ); for ( var i = 0; i < candidates.Count; i++ ) { @@ -69,16 +70,16 @@ private int GetIntroducedInApiVersionStatusCode() metadata, apiVersion, options.UnsupportedApiVersionStatusCode, - out var statusCode ) ) + out var statusCode, + out var currentIntroducedIn ) && + ( result == 0 || statusCode < result ) ) { - if ( result == 0 || statusCode < result ) - { - result = statusCode; - } + result = statusCode; + introducedIn = currentIntroducedIn; } } - return result; + return (result, introducedIn); } private static string DisplayName( Endpoint endpoint ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs index 3b36fa89..5cc36755 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs @@ -15,7 +15,7 @@ internal sealed class EdgeBuilder private readonly bool versionsByUrl; private readonly bool unspecifiedAllowed; private readonly string constraintName; - private readonly int unsupportedApiVersionStatusCode; + private readonly ApiVersioningOptions options; private readonly HashSet keys; private readonly Dictionary> edges; private readonly HashSet routePatterns = new( new RoutePatternComparer() ); @@ -30,7 +30,7 @@ public EdgeBuilder( versionsByUrl = source.VersionsByUrl(); unspecifiedAllowed = options.AssumeDefaultVersionWhenUnspecified; constraintName = options.RouteConstraintName; - unsupportedApiVersionStatusCode = options.UnsupportedApiVersionStatusCode; + this.options = options; keys = new( capacity + 1 ); edges = new( capacity + RejectionEndpointCapacity ) { @@ -73,16 +73,21 @@ public void Add( RouteEndpoint endpoint, ApiVersion apiVersion, ApiVersionMetada } } - public void AddIntroducedLater( RouteEndpoint endpoint, ApiVersion apiVersion, int statusCode, ApiVersionMetadata metadata ) + public void AddIntroducedLater( + RouteEndpoint endpoint, + ApiVersion apiVersion, + int statusCode, + ApiVersion introducedIn, + ApiVersionMetadata metadata ) { if ( statusCode == IntroducedInApiVersionAttribute.UseConfiguredStatusCode ) { - statusCode = unsupportedApiVersionStatusCode; + statusCode = options.UnsupportedApiVersionStatusCode; } var key = new EdgeKey( apiVersion, statusCode, metadata, routePatterns ); - Add( ref key, new IntroducedInApiVersionEndpoint( statusCode ), endpoint.RoutePattern, once: true ); + Add( ref key, new IntroducedInApiVersionEndpoint( options, statusCode, introducedIn ), endpoint.RoutePattern, once: true ); } private void Add( ref EdgeKey key, RouteEndpoint endpoint ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs index 1b1a50ea..a4bdceff 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs @@ -40,7 +40,7 @@ internal static bool TryReportApiVersions( HttpContext context, ApiVersioningOpt { var reporter = context.RequestServices.GetRequiredService(); var model = feature.Metadata.Map( reporter.Mapping ); - context.Response.OnStarting( ReportApiVersions, (reporter, context.Response, model) ); + reporter.Report( context.Response, model ); return true; } else @@ -58,7 +58,8 @@ internal static Task UnsupportedApiVersion( TryReportApiVersions( context, options ); - if ( context.TryGetProblemDetailsService( out var problemDetails ) ) + if ( context.RequestServices is not null && + context.TryGetProblemDetailsService( out var problemDetails ) ) { var detail = string.Format( CultureInfo.CurrentCulture, @@ -72,10 +73,29 @@ internal static Task UnsupportedApiVersion( return Task.CompletedTask; } - private static Task ReportApiVersions( object state ) + internal static Task IntroducedInApiVersion( + HttpContext context, + ApiVersioningOptions options, + int statusCode, + ApiVersion introducedIn ) { - var (reporter, response, model) = ((IReportApiVersions, HttpResponse, ApiVersionModel)) state; - reporter.Report( response, model ); + context.Response.StatusCode = statusCode; + + TryReportApiVersions( context, options ); + + if ( context.RequestServices is not null && + context.TryGetProblemDetailsService( out var problemDetails ) ) + { + var detail = string.Format( + CultureInfo.CurrentCulture, + Format.VersionedResourceNotIntroduced, + new Uri( context.Request.GetDisplayUrl() ).SafePath, + introducedIn, + context.ApiVersioningFeature.RawRequestedApiVersion ); + + return problemDetails.TryWriteAsync( New( context, Introduced, detail ) ).AsTask(); + } + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs index 4a7a805f..c64f322a 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs @@ -19,4 +19,18 @@ internal IntroducedInApiVersionEndpoint( int statusCode ) Empty, statusCode + Name ) { } + + internal IntroducedInApiVersionEndpoint( + ApiVersioningOptions options, + int statusCode, + ApiVersion introducedIn ) + : base( + context => EndpointProblem.IntroducedInApiVersion( + context, + options, + statusCode, + introducedIn ), + Empty, + statusCode + Name ) + { } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs index 4aaaf7aa..cfc429c5 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs @@ -12,31 +12,49 @@ internal static bool TryGet( ApiVersionMetadata metadata, ApiVersion apiVersion, int unsupportedApiVersionStatusCode, - out int statusCode ) + out int statusCode ) => + TryGet( endpoint, metadata, apiVersion, unsupportedApiVersionStatusCode, out statusCode, out _ ); + + internal static bool TryGet( + Endpoint endpoint, + ApiVersionMetadata metadata, + ApiVersion apiVersion, + int unsupportedApiVersionStatusCode, + out int statusCode, + [NotNullWhen( true )] out ApiVersion? introducedIn ) { metadata.Deconstruct( out var apiModel, out _ ); if ( !apiModel.DeclaredApiVersions.Contains( apiVersion ) ) { statusCode = 0; + introducedIn = default; return false; } - if ( TryGet( metadata.IntroducedInApiVersions, apiVersion, unsupportedApiVersionStatusCode, out statusCode ) ) + if ( TryGet( metadata.IntroducedInApiVersions, apiVersion, unsupportedApiVersionStatusCode, out statusCode, out introducedIn ) ) { return true; } var endpointMetadata = endpoint.Metadata; - if ( TryGet( endpointMetadata.GetOrderedMetadata(), apiVersion, unsupportedApiVersionStatusCode, out statusCode ) ) + if ( TryGet( endpointMetadata.GetOrderedMetadata(), apiVersion, unsupportedApiVersionStatusCode, out statusCode, out introducedIn ) ) { return true; } var reflectedIntroduced = GetIntroducedInApiVersions( endpointMetadata ); - return reflectedIntroduced is not null && TryGet( reflectedIntroduced, apiVersion, unsupportedApiVersionStatusCode, out statusCode ); + if ( reflectedIntroduced is not null && + TryGet( reflectedIntroduced, apiVersion, unsupportedApiVersionStatusCode, out statusCode, out introducedIn ) ) + { + return true; + } + + statusCode = 0; + introducedIn = default; + return false; } internal static bool HasIntroducedInApiVersion( Endpoint endpoint, ApiVersionMetadata metadata ) @@ -54,7 +72,8 @@ private static bool TryGet( IReadOnlyList introduced, ApiVersion apiVersion, int unsupportedApiVersionStatusCode, - out int statusCode ) + out int statusCode, + [NotNullWhen( true )] out ApiVersion? introducedIn ) { var matched = default( IntroducedInApiVersionMetadata ); var matchedStatusCode = 0; @@ -82,10 +101,12 @@ private static bool TryGet( if ( matched is not null ) { statusCode = matchedStatusCode; + introducedIn = matched.IntroducedIn; return true; } statusCode = 0; + introducedIn = default; return false; } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs index 4a56d6b9..6ba673e4 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs @@ -149,5 +149,14 @@ internal static string VersionedResourceNotSupported { return ResourceManager.GetString("VersionedResourceNotSupported", resourceCulture); } } + + /// + /// Looks up a localized string similar to The HTTP resource that matches the request URI '{0}' was introduced in API version '{1}' and is not available in the requested version '{2}'.. + /// + internal static string VersionedResourceNotIntroduced { + get { + return ResourceManager.GetString("VersionedResourceNotIntroduced", resourceCulture); + } + } } -} +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx index dde23ba1..0e7fadb9 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx @@ -152,4 +152,7 @@ The HTTP resource that matches the request URI '{0}' does not support the API version '{1}'. - \ No newline at end of file + + The HTTP resource that matches the request URI '{0}' was introduced in API version '{1}' and is not available in the requested version '{2}'. + + diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs index d98de0e9..314f34b9 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs @@ -8,9 +8,11 @@ namespace Asp.Versioning.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matching; using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; +using System.Text.Json; public class ApiVersionMatcherPolicyTest { @@ -147,6 +149,32 @@ public async Task jump_table_should_use_introduced_endpoint_for_controller_decla responseContext.Response.StatusCode.Should().Be( 404 ); } + [Fact] + public async Task jump_table_should_write_problem_details_for_introduced_endpoint() + { + // arrange + var options = new ApiVersioningOptions() + { + ApiVersionReader = new QueryStringApiVersionReader(), + ReportApiVersions = true, + }; + var policy = NewApiVersionMatcherPolicy( options ); + var httpContext = NewHttpContext( "1.0", options ); + var endpoint = NewIntroducedEndpoint( 404 ); + var edges = policy.GetEdges( [endpoint] ); + var tableEdges = NewJumpTableEdges( edges ); + var jumpTable = policy.BuildJumpTable( 42, tableEdges ); + var selected = edges[jumpTable.GetDestination( httpContext )].Endpoints[0]; + + // act + await selected.RequestDelegate!( httpContext ); + await httpContext.Response.CompleteAsync(); + + // assert + await ResponseShouldHaveIntroducedProblemDetails( httpContext, 404 ); + httpContext.Response.Headers["api-supported-versions"].Should().Equal( "2.0, 3.0" ); + } + [Fact] public async Task jump_table_should_use_configured_status_code_for_introduced_status_code_zero() { @@ -238,6 +266,92 @@ public async Task apply_should_use_introduced_endpoint_for_controller_declared_v responseContext.Response.StatusCode.Should().Be( 404 ); } + [Fact] + public async Task apply_should_write_problem_details_for_introduced_endpoint() + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, "1.0" ); + feature.SetupProperty( f => f.RawRequestedApiVersions, ["1.0"] ); + feature.SetupProperty( f => f.RequestedApiVersion, new ApiVersion( 1, 0 ) ); + + var options = new ApiVersioningOptions() + { + ReportApiVersions = true, + }; + var policy = NewApiVersionMatcherPolicy( options ); + var endpoint = NewIntroducedEndpoint( 404 ); + var candidates = new CandidateSet( [endpoint], [[]], [0] ); + var httpContext = NewHttpContext( "1.0", options ); + + httpContext.Features.Set( feature.Object ); + + // act + await policy.ApplyAsync( httpContext, candidates ); + await httpContext.GetEndpoint().RequestDelegate!( httpContext ); + + // assert + await ResponseShouldHaveIntroducedProblemDetails( httpContext, 404 ); + httpContext.Response.Headers["api-supported-versions"].Should().Equal( "2.0, 3.0" ); + } + + [Fact] + public async Task introduced_endpoint_should_write_same_problem_details_from_jump_table_and_apply() + { + // arrange + var options = new ApiVersioningOptions() + { + ApiVersionReader = new QueryStringApiVersionReader(), + ReportApiVersions = true, + }; + + // act + var fast = await InvokeJumpTableIntroducedEndpoint( options ); + var slow = await InvokeApplyIntroducedEndpoint( options ); + + // assert + ( await ReadResponseBody( fast ) ).Should().Be( await ReadResponseBody( slow ) ); + fast.Response.Headers["api-supported-versions"].Should().Equal( slow.Response.Headers["api-supported-versions"] ); + } + + [Fact] + public async Task jump_table_should_not_report_api_versions_for_introduced_endpoint_when_disabled() + { + // arrange + var options = new ApiVersioningOptions() + { + ApiVersionReader = new QueryStringApiVersionReader(), + ReportApiVersions = false, + }; + + // act + var httpContext = await InvokeJumpTableIntroducedEndpoint( options ); + + // assert + await ResponseShouldHaveIntroducedProblemDetails( httpContext, 404 ); + httpContext.Response.Headers.ContainsKey( "api-supported-versions" ).Should().BeFalse(); + httpContext.Response.Headers.ContainsKey( "api-deprecated-versions" ).Should().BeFalse(); + } + + [Fact] + public async Task apply_should_not_report_api_versions_for_introduced_endpoint_when_disabled() + { + // arrange + var options = new ApiVersioningOptions() + { + ReportApiVersions = false, + }; + + // act + var httpContext = await InvokeApplyIntroducedEndpoint( options ); + + // assert + await ResponseShouldHaveIntroducedProblemDetails( httpContext, 404 ); + httpContext.Response.Headers.ContainsKey( "api-supported-versions" ).Should().BeFalse(); + httpContext.Response.Headers.ContainsKey( "api-deprecated-versions" ).Should().BeFalse(); + } + [Fact] public async Task apply_should_use_smallest_status_code_for_same_introduced_version() { @@ -552,6 +666,105 @@ private static RouteEndpoint NewIntroducedEndpoint( IntroducedInApiVersionMetada Options.Create( options ?? new() ), Mock.Of>() ); + private static List NewJumpTableEdges( IReadOnlyList edges ) + { + var tableEdges = new List(); + + for ( var i = 0; i < edges.Count; i++ ) + { + tableEdges.Add( new( edges[i].State, i ) ); + } + + return tableEdges; + } + + private static DefaultHttpContext NewHttpContext( string apiVersion, ApiVersioningOptions options ) + { + var services = new ServiceCollection(); + + services.AddSingleton( new TestProblemDetailsService() ); + services.AddSingleton( new TestApiVersionReporter() ); + services.AddSingleton( options.ApiVersionReader ); + services.AddSingleton( ApiVersionParser.Default ); + + var context = new DefaultHttpContext() + { + RequestServices = services.BuildServiceProvider(), + }; + + context.Request.Scheme = Uri.UriSchemeHttp; + context.Request.Host = new( "tempuri.org" ); + context.Request.Path = "/api/values"; + context.Response.Body = new MemoryStream(); + context.ApiVersioningFeature.RawRequestedApiVersion = apiVersion; + + return context; + } + + private static async Task InvokeJumpTableIntroducedEndpoint( ApiVersioningOptions options ) + { + var policy = NewApiVersionMatcherPolicy( options ); + var httpContext = NewHttpContext( "1.0", options ); + var endpoint = NewIntroducedEndpoint( 404 ); + var edges = policy.GetEdges( [endpoint] ); + var tableEdges = NewJumpTableEdges( edges ); + var jumpTable = policy.BuildJumpTable( 42, tableEdges ); + var selected = edges[jumpTable.GetDestination( httpContext )].Endpoints[0]; + + await selected.RequestDelegate!( httpContext ); + await httpContext.Response.CompleteAsync(); + + return httpContext; + } + + private static async Task InvokeApplyIntroducedEndpoint( ApiVersioningOptions options ) + { + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, "1.0" ); + feature.SetupProperty( f => f.RawRequestedApiVersions, ["1.0"] ); + feature.SetupProperty( f => f.RequestedApiVersion, new ApiVersion( 1, 0 ) ); + + var policy = NewApiVersionMatcherPolicy( options ); + var endpoint = NewIntroducedEndpoint( 404 ); + var candidates = new CandidateSet( [endpoint], [[]], [0] ); + var httpContext = NewHttpContext( "1.0", options ); + + httpContext.Features.Set( feature.Object ); + + await policy.ApplyAsync( httpContext, candidates ); + await httpContext.GetEndpoint().RequestDelegate!( httpContext ); + await httpContext.Response.CompleteAsync(); + + return httpContext; + } + + private static async Task ReadResponseBody( DefaultHttpContext context ) + { + context.Response.Body.Position = 0; + + using var reader = new StreamReader( context.Response.Body, leaveOpen: true ); + + return await reader.ReadToEndAsync(); + } + + private static async Task ResponseShouldHaveIntroducedProblemDetails( DefaultHttpContext context, int statusCode ) + { + context.Response.ContentType.Should().Be( "application/problem+json" ); + context.Response.StatusCode.Should().Be( statusCode ); + + var body = await ReadResponseBody( context ); + var problem = JsonDocument.Parse( body ); + var root = problem.RootElement; + + root.GetProperty( "type" ).GetString().Should().Be( "https://docs.api-versioning.org/problems#introduced" ); + root.GetProperty( "title" ).GetString().Should().Be( "API endpoint not yet introduced" ); + root.GetProperty( "status" ).GetInt32().Should().Be( statusCode ); + root.GetProperty( "detail" ).GetString().Should().Be( + "The HTTP resource that matches the request URI 'http://tempuri.org/api/values' was introduced in API version '2.0' and is not available in the requested version '1.0'." ); + root.GetProperty( "code" ).GetString().Should().Be( "EndpointNotIntroduced" ); + } + private static HttpContext NewHttpContext( Mock apiVersioningFeature, IServiceProvider services = default, @@ -598,4 +811,43 @@ private static HttpContext NewHttpContext( return httpContext.Object; } + + private sealed class TestProblemDetailsService : IProblemDetailsService + { + public ValueTask WriteAsync( ProblemDetailsContext context ) => Write( context ); + + public async ValueTask TryWriteAsync( ProblemDetailsContext context ) + { + await Write( context ); + return true; + } + + private static async ValueTask Write( ProblemDetailsContext context ) + { + var response = context.HttpContext.Response; + + response.ContentType = "application/problem+json"; + + await response.StartAsync(); + await JsonSerializer.SerializeAsync( response.Body, context.ProblemDetails ); + } + } + + private sealed class TestApiVersionReporter : IReportApiVersions + { + public ApiVersionMapping Mapping => ApiVersionMapping.Explicit | ApiVersionMapping.Implicit; + + public void Report( HttpResponse response, ApiVersionModel apiVersionModel ) + { + if ( apiVersionModel.SupportedApiVersions.Count > 0 ) + { + response.Headers["api-supported-versions"] = string.Join( ", ", apiVersionModel.SupportedApiVersions ); + } + + if ( apiVersionModel.DeprecatedApiVersions.Count > 0 ) + { + response.Headers["api-deprecated-versions"] = string.Join( ", ", apiVersionModel.DeprecatedApiVersions ); + } + } + } } \ No newline at end of file diff --git a/src/Common/src/Common.ProblemDetails/ProblemDetailsDefaults.cs b/src/Common/src/Common.ProblemDetails/ProblemDetailsDefaults.cs index bc668a05..26839704 100644 --- a/src/Common/src/Common.ProblemDetails/ProblemDetailsDefaults.cs +++ b/src/Common/src/Common.ProblemDetails/ProblemDetailsDefaults.cs @@ -8,6 +8,7 @@ namespace Asp.Versioning; public static class ProblemDetailsDefaults { private static ProblemDetailsInfo? unsupported; + private static ProblemDetailsInfo? introduced; private static ProblemDetailsInfo? unspecified; private static ProblemDetailsInfo? invalid; private static ProblemDetailsInfo? ambiguous; @@ -21,6 +22,15 @@ public static class ProblemDetailsDefaults "Unsupported API version", "UnsupportedApiVersion" ); + /// + /// Gets the problem details for an API endpoint introduced in a later API version. + /// + public static ProblemDetailsInfo Introduced => + introduced ??= new( + "https://docs.api-versioning.org/problems#introduced", + "API endpoint not yet introduced", + "EndpointNotIntroduced" ); + /// /// Gets the problem details for an unspecified API version. /// From b532bf6cf4e9bf6185ef011a31825228dfad0a7c Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 10:34:17 -0700 Subject: [PATCH 17/26] Apply introduced-in precedence across candidates TDD red tests: - apply_should_use_latest_introduced_version_across_candidates initially failed with: Expected context.Response.StatusCode to be 410, but found 404 (difference of -6). - jump_table_should_use_latest_introduced_version_across_candidates initially failed with: Expected context.Response.StatusCode to be 410, but found 404 (difference of -6). Implemented a shared cross-candidate selector in IntroducedInApiVersionStatusCode for the slow path and reused the same precedence predicate in jump-table destination resolution. Fast-path EdgeKey now carries IntroducedIn so same requested-version/status edges with different introduced versions do not collapse; this is an internal routing structure and avoids public surface changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Routing/ApiVersionMatcherPolicy.cs | 16 +- .../Routing/ClientErrorEndpointBuilder.cs | 36 +--- .../Routing/EdgeBuilder.cs | 2 +- .../Asp.Versioning.Http/Routing/EdgeKey.cs | 11 +- .../IntroducedInApiVersionStatusCode.cs | 47 +++++ .../Routing/ApiVersionMatcherPolicyTest.cs | 168 ++++++++++++++++-- 6 files changed, 233 insertions(+), 47 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs index d8e54b45..930ac464 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs @@ -114,7 +114,7 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList( capacity ); var introducedLater = default( Dictionary ); - var introducedLaterStatusCodes = default( Dictionary ); + var introducedLaterMatches = default( Dictionary ); var source = ApiVersionSource; var supported = default( SortedSet ); var deprecated = default( SortedSet ); @@ -156,12 +156,16 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList(); - - if ( metadata is null ) - { - continue; - } - - if ( IntroducedInApiVersionStatusCode.TryGet( - candidate.Endpoint, - metadata, - apiVersion, - options.UnsupportedApiVersionStatusCode, - out var statusCode, - out var currentIntroducedIn ) && - ( result == 0 || statusCode < result ) ) - { - result = statusCode; - introducedIn = currentIntroducedIn; - } - } - - return (result, introducedIn); + return IntroducedInApiVersionStatusCode.TryGetBest( + candidates, + apiVersion, + options.UnsupportedApiVersionStatusCode, + out var statusCode, + out var introducedIn ) + ? (statusCode, introducedIn) + : default; } private static string DisplayName( Endpoint endpoint ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs index 5cc36755..632c24f6 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs @@ -85,7 +85,7 @@ public void AddIntroducedLater( statusCode = options.UnsupportedApiVersionStatusCode; } - var key = new EdgeKey( apiVersion, statusCode, metadata, routePatterns ); + var key = new EdgeKey( apiVersion, statusCode, introducedIn, metadata, routePatterns ); Add( ref key, new IntroducedInApiVersionEndpoint( options, statusCode, introducedIn ), endpoint.RoutePattern, once: true ); } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs index 662d4bf1..eb34810f 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs @@ -13,6 +13,7 @@ namespace Asp.Versioning.Routing; public readonly HashSet RoutePatterns; public readonly EndpointType EndpointType; public readonly int StatusCode; + public readonly ApiVersion? IntroducedIn; private EdgeKey( EndpointType endpointType, HashSet routePatterns ) { @@ -21,6 +22,7 @@ private EdgeKey( EndpointType endpointType, HashSet routePatterns RoutePatterns = routePatterns; EndpointType = endpointType; StatusCode = 0; + IntroducedIn = default; } internal EdgeKey( @@ -33,11 +35,13 @@ internal EdgeKey( RoutePatterns = routePatterns; EndpointType = UserDefined; StatusCode = 0; + IntroducedIn = default; } internal EdgeKey( ApiVersion apiVersion, int statusCode, + ApiVersion introducedIn, ApiVersionMetadata metadata, HashSet routePatterns ) { @@ -46,6 +50,7 @@ internal EdgeKey( RoutePatterns = routePatterns; EndpointType = IntroducedLater; StatusCode = statusCode; + IntroducedIn = introducedIn; } internal static EdgeKey Ambiguous => new( EndpointType.Ambiguous, Set.Empty ); @@ -74,7 +79,8 @@ public bool Equals( [AllowNull] EdgeKey other ) return false; } - return EndpointType != IntroducedLater || StatusCode == other.StatusCode; + return EndpointType != IntroducedLater || + ( StatusCode == other.StatusCode && IntroducedIn == other.IntroducedIn ); } public override bool Equals( object? obj ) => obj is EdgeKey other && Equals( other ); @@ -93,6 +99,7 @@ public override int GetHashCode() if ( EndpointType == IntroducedLater ) { result.Add( StatusCode ); + result.Add( IntroducedIn ); } return result.ToHashCode(); @@ -112,7 +119,7 @@ public override string ToString() } else if ( EndpointType == IntroducedLater ) { - value = EndpointType + " " + ApiVersion + " (" + StatusCode + ")"; + value = EndpointType + " " + ApiVersion + " (" + StatusCode + ", " + IntroducedIn + ")"; } else { diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs index cfc429c5..e6b87c12 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs @@ -3,6 +3,7 @@ namespace Asp.Versioning.Routing; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Matching; using System.Diagnostics.CodeAnalysis; internal static class IntroducedInApiVersionStatusCode @@ -68,6 +69,52 @@ internal static bool HasIntroducedInApiVersion( Endpoint endpoint, ApiVersionMet return GetIntroducedInApiVersions( endpoint.Metadata ) is { Count: > 0 }; } + internal static bool TryGetBest( + CandidateSet candidates, + ApiVersion apiVersion, + int unsupportedApiVersionStatusCode, + out int statusCode, + [NotNullWhen( true )] out ApiVersion? introducedIn ) + { + statusCode = 0; + introducedIn = default; + + for ( var i = 0; i < candidates.Count; i++ ) + { + ref readonly var candidate = ref candidates[i]; + var metadata = candidate.Endpoint.Metadata.GetMetadata(); + + if ( metadata is null ) + { + continue; + } + + if ( TryGet( + candidate.Endpoint, + metadata, + apiVersion, + unsupportedApiVersionStatusCode, + out var currentStatusCode, + out var currentIntroducedIn ) && + IsBetterMatch( currentStatusCode, currentIntroducedIn, statusCode, introducedIn ) ) + { + statusCode = currentStatusCode; + introducedIn = currentIntroducedIn; + } + } + + return introducedIn is not null; + } + + internal static bool IsBetterMatch( + int currentStatusCode, + ApiVersion currentIntroducedIn, + int matchedStatusCode, + ApiVersion? matchedIntroducedIn ) => + matchedIntroducedIn is null || + currentIntroducedIn > matchedIntroducedIn || + ( currentIntroducedIn == matchedIntroducedIn && currentStatusCode < matchedStatusCode ); + private static bool TryGet( IReadOnlyList introduced, ApiVersion apiVersion, diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs index 314f34b9..1899b430 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs @@ -379,6 +379,107 @@ public async Task apply_should_use_smallest_status_code_for_same_introduced_vers responseContext.Response.StatusCode.Should().Be( 404 ); } + [Fact] + public async Task apply_should_use_latest_introduced_version_across_candidates() + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, "1.0" ); + feature.SetupProperty( f => f.RawRequestedApiVersions, ["1.0"] ); + feature.SetupProperty( f => f.RequestedApiVersion, new ApiVersion( 1, 0 ) ); + + var options = new ApiVersioningOptions() + { + ReportApiVersions = true, + }; + var policy = NewApiVersionMatcherPolicy( options ); + var v2 = new ApiVersion( 2, 0 ); + var v3 = new ApiVersion( 3, 0 ); + var first = NewIntroducedEndpoint( [new( v2, 404 )], implementedVersion: v2 ); + var second = NewIntroducedEndpoint( [new( v3, 410 )], implementedVersion: v3 ); + var candidates = new CandidateSet( [first, second], [[], []], [0, 0] ); + var httpContext = NewHttpContext( "1.0", options ); + + httpContext.Features.Set( feature.Object ); + + // act + await policy.ApplyAsync( httpContext, candidates ); + await httpContext.GetEndpoint().RequestDelegate!( httpContext ); + await httpContext.Response.CompleteAsync(); + + // assert + await ResponseShouldHaveIntroducedProblemDetails( httpContext, 410, "3.0" ); + } + + [Fact] + public async Task jump_table_should_use_latest_introduced_version_across_candidates() + { + // arrange + var options = new ApiVersioningOptions() + { + ApiVersionReader = new QueryStringApiVersionReader(), + ReportApiVersions = true, + }; + var policy = NewApiVersionMatcherPolicy( options ); + var httpContext = NewHttpContext( "1.0", options ); + var v2 = new ApiVersion( 2, 0 ); + var v3 = new ApiVersion( 3, 0 ); + var first = NewIntroducedEndpoint( [new( v2, 404 )], implementedVersion: v2 ); + var second = NewIntroducedEndpoint( [new( v3, 410 )], implementedVersion: v3 ); + var edges = policy.GetEdges( [first, second] ); + var jumpTable = policy.BuildJumpTable( 42, NewJumpTableEdges( edges ) ); + var selected = edges[jumpTable.GetDestination( httpContext )].Endpoints[0]; + + // act + await selected.RequestDelegate!( httpContext ); + await httpContext.Response.CompleteAsync(); + + // assert + await ResponseShouldHaveIntroducedProblemDetails( httpContext, 410, "3.0" ); + } + + [Fact] + public async Task introduced_endpoint_should_use_same_latest_introduced_version_from_jump_table_and_apply() + { + // arrange + var options = new ApiVersioningOptions() + { + ApiVersionReader = new QueryStringApiVersionReader(), + ReportApiVersions = true, + }; + var endpoints = NewIntroducedEndpointCandidates( latestStatusCode: 410, earlierStatusCode: 404 ); + + // act + var fast = await InvokeJumpTableIntroducedEndpoint( options, endpoints ); + var slow = await InvokeApplyIntroducedEndpoint( options, endpoints ); + + // assert + ( await ReadResponseBody( fast ) ).Should().Be( await ReadResponseBody( slow ) ); + fast.Response.StatusCode.Should().Be( slow.Response.StatusCode ); + fast.Response.Headers["api-supported-versions"].Should().Equal( slow.Response.Headers["api-supported-versions"] ); + } + + [Fact] + public async Task introduced_endpoint_should_tie_break_latest_introduced_version_by_smallest_status_code() + { + // arrange + var options = new ApiVersioningOptions() + { + ApiVersionReader = new QueryStringApiVersionReader(), + ReportApiVersions = true, + }; + var endpoints = NewIntroducedEndpointCandidatesWithLatestTie(); + + // act + var fast = await InvokeJumpTableIntroducedEndpoint( options, endpoints ); + var slow = await InvokeApplyIntroducedEndpoint( options, endpoints ); + + // assert + await ResponseShouldHaveIntroducedProblemDetails( fast, 404, "3.0" ); + await ResponseShouldHaveIntroducedProblemDetails( slow, 404, "3.0" ); + } + [Theory] [InlineData( "1.0" )] [InlineData( "2.0" )] @@ -463,22 +564,26 @@ public void edge_key_equals_should_compare_introduced_later_status_code() var ctor = keyType!.GetConstructor( System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, binder: null, - [typeof( ApiVersion ), typeof( int ), typeof( ApiVersionMetadata ), typeof( HashSet )], + [typeof( ApiVersion ), typeof( int ), typeof( ApiVersion ), typeof( ApiVersionMetadata ), typeof( HashSet )], modifiers: null ); var apiVersion = new ApiVersion( 2.0 ); + var introducedIn = new ApiVersion( 3.0 ); var metadata = ApiVersionMetadata.Empty; var routePatterns = new HashSet(); - var left = ctor!.Invoke( [apiVersion, 404, metadata, routePatterns] ); - var same = ctor.Invoke( [apiVersion, 404, metadata, routePatterns] ); - var differentStatusCode = ctor.Invoke( [apiVersion, 410, metadata, routePatterns] ); + var left = ctor!.Invoke( [apiVersion, 404, introducedIn, metadata, routePatterns] ); + var same = ctor.Invoke( [apiVersion, 404, introducedIn, metadata, routePatterns] ); + var differentStatusCode = ctor.Invoke( [apiVersion, 410, introducedIn, metadata, routePatterns] ); + var differentIntroducedIn = ctor.Invoke( [apiVersion, 404, new ApiVersion( 4.0 ), metadata, routePatterns] ); // act var sameResult = left.Equals( same ); var differentResult = left.Equals( differentStatusCode ); + var differentIntroducedInResult = left.Equals( differentIntroducedIn ); // assert sameResult.Should().BeTrue(); differentResult.Should().BeFalse(); + differentIntroducedInResult.Should().BeFalse(); } [Fact] @@ -659,6 +764,31 @@ private static RouteEndpoint NewIntroducedEndpoint( IntroducedInApiVersionMetada return (RouteEndpoint) builder.Build(); } + private static RouteEndpoint[] NewIntroducedEndpointCandidates( int latestStatusCode, int earlierStatusCode ) + { + var v2 = new ApiVersion( 2, 0 ); + var v3 = new ApiVersion( 3, 0 ); + + return + [ + NewIntroducedEndpoint( [new( v2, earlierStatusCode )], implementedVersion: v2 ), + NewIntroducedEndpoint( [new( v3, latestStatusCode )], implementedVersion: v3 ), + ]; + } + + private static RouteEndpoint[] NewIntroducedEndpointCandidatesWithLatestTie() + { + var v2 = new ApiVersion( 2, 0 ); + var v3 = new ApiVersion( 3, 0 ); + + return + [ + NewIntroducedEndpoint( [new( v2, 400 )], implementedVersion: v2 ), + NewIntroducedEndpoint( [new( v3, 410 )], implementedVersion: v3 ), + NewIntroducedEndpoint( [new( v3, 404 )], implementedVersion: v3 ), + ]; + } + private static ApiVersionMatcherPolicy NewApiVersionMatcherPolicy( ApiVersioningOptions options = default ) => new( ApiVersionParser.Default, @@ -702,11 +832,17 @@ private static DefaultHttpContext NewHttpContext( string apiVersion, ApiVersioni } private static async Task InvokeJumpTableIntroducedEndpoint( ApiVersioningOptions options ) + { + return await InvokeJumpTableIntroducedEndpoint( options, [NewIntroducedEndpoint( 404 )] ); + } + + private static async Task InvokeJumpTableIntroducedEndpoint( + ApiVersioningOptions options, + RouteEndpoint[] endpoints ) { var policy = NewApiVersionMatcherPolicy( options ); var httpContext = NewHttpContext( "1.0", options ); - var endpoint = NewIntroducedEndpoint( 404 ); - var edges = policy.GetEdges( [endpoint] ); + var edges = policy.GetEdges( endpoints ); var tableEdges = NewJumpTableEdges( edges ); var jumpTable = policy.BuildJumpTable( 42, tableEdges ); var selected = edges[jumpTable.GetDestination( httpContext )].Endpoints[0]; @@ -718,6 +854,13 @@ private static async Task InvokeJumpTableIntroducedEndpoint( } private static async Task InvokeApplyIntroducedEndpoint( ApiVersioningOptions options ) + { + return await InvokeApplyIntroducedEndpoint( options, [NewIntroducedEndpoint( 404 )] ); + } + + private static async Task InvokeApplyIntroducedEndpoint( + ApiVersioningOptions options, + RouteEndpoint[] endpoints ) { var feature = new Mock(); @@ -726,8 +869,10 @@ private static async Task InvokeApplyIntroducedEndpoint( Api feature.SetupProperty( f => f.RequestedApiVersion, new ApiVersion( 1, 0 ) ); var policy = NewApiVersionMatcherPolicy( options ); - var endpoint = NewIntroducedEndpoint( 404 ); - var candidates = new CandidateSet( [endpoint], [[]], [0] ); + var candidates = new CandidateSet( + endpoints, + endpoints.Select( _ => new RouteValueDictionary() ).ToArray(), + endpoints.Select( _ => 0 ).ToArray() ); var httpContext = NewHttpContext( "1.0", options ); httpContext.Features.Set( feature.Object ); @@ -748,7 +893,10 @@ private static async Task ReadResponseBody( DefaultHttpContext context ) return await reader.ReadToEndAsync(); } - private static async Task ResponseShouldHaveIntroducedProblemDetails( DefaultHttpContext context, int statusCode ) + private static async Task ResponseShouldHaveIntroducedProblemDetails( DefaultHttpContext context, int statusCode ) => + await ResponseShouldHaveIntroducedProblemDetails( context, statusCode, "2.0" ); + + private static async Task ResponseShouldHaveIntroducedProblemDetails( DefaultHttpContext context, int statusCode, string introducedIn ) { context.Response.ContentType.Should().Be( "application/problem+json" ); context.Response.StatusCode.Should().Be( statusCode ); @@ -761,7 +909,7 @@ private static async Task ResponseShouldHaveIntroducedProblemDetails( DefaultHtt root.GetProperty( "title" ).GetString().Should().Be( "API endpoint not yet introduced" ); root.GetProperty( "status" ).GetInt32().Should().Be( statusCode ); root.GetProperty( "detail" ).GetString().Should().Be( - "The HTTP resource that matches the request URI 'http://tempuri.org/api/values' was introduced in API version '2.0' and is not available in the requested version '1.0'." ); + $"The HTTP resource that matches the request URI 'http://tempuri.org/api/values' was introduced in API version '{introducedIn}' and is not available in the requested version '1.0'." ); root.GetProperty( "code" ).GetString().Should().Be( "EndpointNotIntroduced" ); } From db53e29b1dfc14afea4b09c295872cd8ee83ef04 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 10:34:17 -0700 Subject: [PATCH 18/26] Drop introduced metadata from neutral endpoints TDD red test: - with_api_version_set_should_ignore_introduced_version_for_neutral_endpoint initially failed with: Expected metadata.IntroducedInApiVersions to be empty, but found at least one item { Asp.Versioning.IntroducedInApiVersionMetadata { IntroducedIn = 2.0, StatusCode = 404 } }. Neutral endpoint metadata now returns the neutral model without carrying introduced-in metadata, matching the documented convention that neutral endpoints ignore introduced versions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Builder/EndpointBuilderFinalizer.cs | 8 ++--- ...EndpointConventionBuilderExtensionsTest.cs | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs index fa90a3f9..50f5bbf4 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs @@ -221,16 +221,12 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v if ( !TryGetApiVersions( metadata, out var buckets ) || ( apiModel = versionSet.Build( options ) ).IsApiVersionNeutral ) { - var neutralIntroducedInApiVersions = metadata.OfType().ToArray(); - if ( string.IsNullOrEmpty( name ) ) { - return neutralIntroducedInApiVersions.Length == 0 - ? ApiVersionMetadata.Neutral - : new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, introducedInApiVersions: neutralIntroducedInApiVersions ); + return ApiVersionMetadata.Neutral; } - return new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, name, neutralIntroducedInApiVersions ); + return new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, name ); } ApiVersionModel endpointModel; diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs index 4b84516a..ce0a2bfe 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs @@ -354,6 +354,36 @@ public void is_api_version_neutral_should_add_convention() versionNeutral.Should().NotBeNull(); } + [Fact] + public void with_api_version_set_should_ignore_introduced_version_for_neutral_endpoint() + { + // arrange + var dataSources = new List(); + var app = new Mock(); + + app.SetupGet( a => a.ServiceProvider ).Returns( new MockServiceProvider() ); + app.SetupGet( a => a.DataSources ).Returns( dataSources ); + + // act + var versionSet = app.Object.NewApiVersionSet() + .IsApiVersionNeutral() + .Build(); + + app.Object.MapGet( "/test", () => Results.Ok() ) + .WithApiVersionSet( versionSet ) + .IntroducedInApiVersion( 2.0 ); + + // assert + var metadata = dataSources.Single() + .Endpoints + .Single() + .Metadata + .OfType() + .Single(); + + metadata.IntroducedInApiVersions.Should().BeEmpty(); + } + [Fact] public void has_api_version_should_add_convention() { From 3db2ee1ce01382ae77edadf5b54825a9dfb50523 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 10:59:48 -0700 Subject: [PATCH 19/26] Populate URL segment version for introduced endpoints 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> --- .../Routing/ApiVersionPolicyJumpTable.cs | 6 + .../Routing/ApiVersionMatcherPolicyTest.cs | 130 +++++++++++++++++- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs index a528cb7f..dae41ada 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs @@ -111,6 +111,12 @@ public override int GetDestination( HttpContext httpContext ) if ( rejection.IntroducedLater.TryGetValue( apiVersion, out destination ) ) { + if ( addedFromUrl ) + { + feature.RawRequestedApiVersion = rawApiVersion; + feature.RequestedApiVersion = apiVersion; + } + httpContext.Features.Set( policyFeature ); return destination; } diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs index 1899b430..c99045f8 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs @@ -315,6 +315,62 @@ public async Task introduced_endpoint_should_write_same_problem_details_from_jum fast.Response.Headers["api-supported-versions"].Should().Equal( slow.Response.Headers["api-supported-versions"] ); } + [Fact] + public async Task jump_table_should_write_requested_url_segment_in_problem_details_for_introduced_endpoint() + { + // arrange + var options = new ApiVersioningOptions() + { + ApiVersionReader = new UrlSegmentApiVersionReader(), + ReportApiVersions = true, + }; + + // act + var httpContext = await InvokeJumpTableIntroducedEndpoint( options, NewUrlSegmentIntroducedEndpointCandidates() ); + + // assert + await ResponseShouldHaveIntroducedProblemDetails( httpContext, 404, "2.0", "http://tempuri.org/v1/api/values", "1" ); + httpContext.Response.Headers["api-supported-versions"].Should().Equal( "2.0" ); + } + + [Fact] + public async Task url_segment_introduced_endpoint_should_write_same_problem_details_from_jump_table_and_apply() + { + // arrange + var options = new ApiVersioningOptions() + { + ApiVersionReader = new UrlSegmentApiVersionReader(), + ReportApiVersions = true, + }; + var endpoints = NewUrlSegmentIntroducedEndpointCandidates(); + + // act + var fast = await InvokeJumpTableIntroducedEndpoint( options, endpoints ); + var slow = await InvokeApplyIntroducedEndpoint( options, endpoints ); + + // assert + ( await ReadResponseBody( fast ) ).Should().Be( await ReadResponseBody( slow ) ); + fast.Response.Headers["api-supported-versions"].Should().Equal( slow.Response.Headers["api-supported-versions"] ); + } + + [Fact] + public async Task url_segment_introduced_endpoint_should_use_latest_matching_introduced_version() + { + // arrange + var options = new ApiVersioningOptions() + { + ApiVersionReader = new UrlSegmentApiVersionReader(), + ReportApiVersions = true, + }; + var endpoints = NewUrlSegmentIntroducedEndpointCandidates( latestStatusCode: 410, earlierStatusCode: 404 ); + + // act + var httpContext = await InvokeJumpTableIntroducedEndpoint( options, endpoints ); + + // assert + await ResponseShouldHaveIntroducedProblemDetails( httpContext, 410, "3.0", "http://tempuri.org/v1/api/values", "1" ); + } + [Fact] public async Task jump_table_should_not_report_api_versions_for_introduced_endpoint_when_disabled() { @@ -746,13 +802,21 @@ private static RouteEndpoint NewIntroducedEndpoint( int statusCode ) } private static RouteEndpoint NewIntroducedEndpoint( IntroducedInApiVersionMetadata[] introduced, ApiVersion implementedVersion ) + { + return NewIntroducedEndpoint( introduced, implementedVersion, "api/values" ); + } + + private static RouteEndpoint NewIntroducedEndpoint( + IntroducedInApiVersionMetadata[] introduced, + ApiVersion implementedVersion, + string routeTemplate ) { var v1 = new ApiVersion( 1, 0 ); var v2 = new ApiVersion( 2, 0 ); var v3 = new ApiVersion( 3, 0 ); var apiModel = new ApiVersionModel( [v1, v2, v3], [v1, v2, v3], [], [], [] ); var endpointModel = new ApiVersionModel( [implementedVersion], [implementedVersion], [], [], [] ); - var routePattern = RoutePatternFactory.Parse( "api/values" ); + var routePattern = RoutePatternFactory.Parse( routeTemplate ); var builder = new RouteEndpointBuilder( Limbo, routePattern, 0 ) { Metadata = @@ -789,6 +853,28 @@ private static RouteEndpoint[] NewIntroducedEndpointCandidatesWithLatestTie() ]; } + private static RouteEndpoint[] NewUrlSegmentIntroducedEndpointCandidates() + { + var v2 = new ApiVersion( 2, 0 ); + + return + [ + NewIntroducedEndpoint( [new( v2, 404 )], implementedVersion: v2, "v{version:apiVersion}/api/values" ), + ]; + } + + private static RouteEndpoint[] NewUrlSegmentIntroducedEndpointCandidates( int latestStatusCode, int earlierStatusCode ) + { + var v2 = new ApiVersion( 2, 0 ); + var v3 = new ApiVersion( 3, 0 ); + + return + [ + NewIntroducedEndpoint( [new( v2, earlierStatusCode )], implementedVersion: v2, "v{version:apiVersion}/api/values" ), + NewIntroducedEndpoint( [new( v3, latestStatusCode )], implementedVersion: v3, "v{version:apiVersion}/api/values" ), + ]; + } + private static ApiVersionMatcherPolicy NewApiVersionMatcherPolicy( ApiVersioningOptions options = default ) => new( ApiVersionParser.Default, @@ -841,7 +927,9 @@ private static async Task InvokeJumpTableIntroducedEndpoint( RouteEndpoint[] endpoints ) { var policy = NewApiVersionMatcherPolicy( options ); - var httpContext = NewHttpContext( "1.0", options ); + var httpContext = options.ApiVersionReader is UrlSegmentApiVersionReader + ? NewUrlSegmentHttpContext( "1", options ) + : NewHttpContext( "1.0", options ); var edges = policy.GetEdges( endpoints ); var tableEdges = NewJumpTableEdges( edges ); var jumpTable = policy.BuildJumpTable( 42, tableEdges ); @@ -864,8 +952,10 @@ private static async Task InvokeApplyIntroducedEndpoint( { var feature = new Mock(); - feature.SetupProperty( f => f.RawRequestedApiVersion, "1.0" ); - feature.SetupProperty( f => f.RawRequestedApiVersions, ["1.0"] ); + var requestedVersion = options.ApiVersionReader is UrlSegmentApiVersionReader ? "1" : "1.0"; + + feature.SetupProperty( f => f.RawRequestedApiVersion, requestedVersion ); + feature.SetupProperty( f => f.RawRequestedApiVersions, [requestedVersion] ); feature.SetupProperty( f => f.RequestedApiVersion, new ApiVersion( 1, 0 ) ); var policy = NewApiVersionMatcherPolicy( options ); @@ -873,7 +963,9 @@ private static async Task InvokeApplyIntroducedEndpoint( endpoints, endpoints.Select( _ => new RouteValueDictionary() ).ToArray(), endpoints.Select( _ => 0 ).ToArray() ); - var httpContext = NewHttpContext( "1.0", options ); + var httpContext = options.ApiVersionReader is UrlSegmentApiVersionReader + ? NewUrlSegmentHttpContext( requestedVersion, options ) + : NewHttpContext( requestedVersion, options ); httpContext.Features.Set( feature.Object ); @@ -897,6 +989,21 @@ private static async Task ResponseShouldHaveIntroducedProblemDetails( DefaultHtt await ResponseShouldHaveIntroducedProblemDetails( context, statusCode, "2.0" ); private static async Task ResponseShouldHaveIntroducedProblemDetails( DefaultHttpContext context, int statusCode, string introducedIn ) + { + await ResponseShouldHaveIntroducedProblemDetails( + context, + statusCode, + introducedIn, + "http://tempuri.org/api/values", + "1.0" ); + } + + private static async Task ResponseShouldHaveIntroducedProblemDetails( + DefaultHttpContext context, + int statusCode, + string introducedIn, + string requestUri, + string requestedVersion ) { context.Response.ContentType.Should().Be( "application/problem+json" ); context.Response.StatusCode.Should().Be( statusCode ); @@ -909,10 +1016,21 @@ private static async Task ResponseShouldHaveIntroducedProblemDetails( DefaultHtt root.GetProperty( "title" ).GetString().Should().Be( "API endpoint not yet introduced" ); root.GetProperty( "status" ).GetInt32().Should().Be( statusCode ); root.GetProperty( "detail" ).GetString().Should().Be( - $"The HTTP resource that matches the request URI 'http://tempuri.org/api/values' was introduced in API version '{introducedIn}' and is not available in the requested version '1.0'." ); + $"The HTTP resource that matches the request URI '{requestUri}' was introduced in API version '{introducedIn}' and is not available in the requested version '{requestedVersion}'." ); root.GetProperty( "code" ).GetString().Should().Be( "EndpointNotIntroduced" ); } + private static DefaultHttpContext NewUrlSegmentHttpContext( string apiVersion, ApiVersioningOptions options ) + { + var context = NewHttpContext( apiVersion, options ); + + context.Request.Path = $"/v{apiVersion}/api/values"; + context.ApiVersioningFeature.RawRequestedApiVersion = default; + context.ApiVersioningFeature.RawRequestedApiVersions = []; + + return context; + } + private static HttpContext NewHttpContext( Mock apiVersioningFeature, IServiceProvider services = default, From ff5ed8793848128cb17bce9e01331e9e39dcbd54 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 11:07:10 -0700 Subject: [PATCH 20/26] Add introduced-in MVC action conventions 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> --- .../ApiVersionConventionBuilderExtensions.cs | 86 ++++++++++++++ ...IntroducedInApiVersionConventionBuilder.cs | 16 +++ .../ActionApiVersionConventionBuilderTest.cs | 36 ++++++ .../ActionApiVersionConventionBuilderTest.cs | 109 ++++++++++++++++++ .../ActionApiVersionConventionBuilder.cs | 17 +++ .../ActionApiVersionConventionBuilder{T}.cs | 17 +++ .../Conventions/IActionConventionBuilder.cs | 2 +- .../IActionConventionBuilder{T}.cs | 2 +- .../ActionApiVersionConventionBuilderTest.cs | 3 + 9 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/IIntroducedInApiVersionConventionBuilder.cs diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs index af66109c..17d55abe 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs @@ -338,4 +338,90 @@ public T MapToApiVersions( IEnumerable apiVersions ) return builder; } } + + /// The type of . + /// The extended . + /// The original . + extension( T builder ) + where T : notnull, IIntroducedInApiVersionConventionBuilder + { + /// + /// Indicates that the configured controller action was introduced in the specified API version. + /// + /// The major version number. + /// The optional minor version number. + /// The optional version status. + /// The HTTP status code for earlier API versions. + public T IntroducedInApiVersion( + int majorVersion, + int? minorVersion = default, + string? status = default, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) + { + builder.IntroducedInApiVersion( new ApiVersion( majorVersion, minorVersion, status ), statusCode ); + return builder; + } + + /// + /// Indicates that the configured controller action was introduced in the specified API version. + /// + /// The version number. + /// The optional version status. + /// The HTTP status code for earlier API versions. + public T IntroducedInApiVersion( + double version, + string? status = default, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) + { + builder.IntroducedInApiVersion( new ApiVersion( version, status ), statusCode ); + return builder; + } + + /// + /// Indicates that the configured controller action was introduced in the specified API version. + /// + /// The version year. + /// The version month. + /// The version day. + /// The optional version status. + /// The HTTP status code for earlier API versions. + public T IntroducedInApiVersion( + int year, + int month, + int day, + string? status = default, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) + { + builder.IntroducedInApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ), statusCode ); + return builder; + } + + /// + /// Indicates that the configured controller action was introduced in the specified API version. + /// + /// The group version. + /// The optional version status. + /// The HTTP status code for earlier API versions. + public T IntroducedInApiVersion( + DateOnly groupVersion, + string? status = default, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) + { + builder.IntroducedInApiVersion( new ApiVersion( groupVersion, status ), statusCode ); + return builder; + } + + /// + /// Indicates that the configured controller action was introduced in the specified API version. + /// + /// The API version the action was introduced in. + /// The HTTP status code for earlier API versions. + public T IntroducedInApiVersion( + ApiVersion apiVersion, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) + { + builder.IntroducedInApiVersion( apiVersion, statusCode ); + return builder; + } + } } \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/IIntroducedInApiVersionConventionBuilder.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/IIntroducedInApiVersionConventionBuilder.cs new file mode 100644 index 00000000..19fef616 --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/IIntroducedInApiVersionConventionBuilder.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Conventions; + +/// +/// Defines the behavior of convention builder that builds introduced API versions. +/// +public interface IIntroducedInApiVersionConventionBuilder : IMapToApiVersionConventionBuilder +{ + /// + /// Indicates that the configured controller action was introduced in the specified API version. + /// + /// The API version the action was introduced in. + /// The HTTP status code for earlier API versions. + void IntroducedInApiVersion( ApiVersion apiVersion, int statusCode ); +} \ No newline at end of file diff --git a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs index 9d3efded..228b97fb 100644 --- a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs +++ b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs @@ -88,4 +88,40 @@ public void apply_to_should_assign_model_with_declared_api_versions_from_mapped_ ImplementedApiVersions = Array.Empty(), } ); } + + [Fact] + public void apply_to_should_expand_declared_api_versions_from_introduced_convention() + { + // arrange + var controllerBuilder = new ControllerApiVersionConventionBuilder( typeof( UndecoratedController ) ); + var actionBuilder = new ActionApiVersionConventionBuilder( controllerBuilder ); + var actionDescriptor = NewActionDescriptor(); + + actionBuilder.IntroducedInApiVersion( new ApiVersion( 2, 0 ) ); + + // act + actionBuilder.ApplyTo( actionDescriptor ); + + // assert + actionDescriptor.ApiVersionMetadata + .Map( Explicit ) + .DeclaredApiVersions + .Should() + .Equal( new ApiVersion( 2, 0 ), new ApiVersion( 3, 0 ) ); + } + + private static ReflectedHttpActionDescriptor NewActionDescriptor() + { + var controllerDescriptor = new HttpControllerDescriptor() + { + ControllerName = "Undecorated", + ControllerType = typeof( UndecoratedController ), + }; + var versions = new ApiVersion[] { new( 1, 0 ), new( 2, 0 ), new( 3, 0 ) }; + var method = typeof( UndecoratedController ).GetMethod( nameof( UndecoratedController.Get ) ); + + controllerDescriptor.ApiVersionModel = new( versions, versions, [], [], [] ); + + return new( controllerDescriptor, method ); + } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs index 3f2a40aa..e769fd1a 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs @@ -114,4 +114,113 @@ public void apply_to_should_assign_model_with_declared_api_versions_from_mapped_ ImplementedApiVersions = Array.Empty(), } ); } + + [Fact] + public void apply_to_should_expand_declared_api_versions_from_introduced_convention() + { + // arrange + var controllerBuilder = new ControllerApiVersionConventionBuilder( typeof( UndecoratedController ) ); + var actionBuilder = new ActionApiVersionConventionBuilder( controllerBuilder ); + var actionModel = NewActionModel( typeof( UndecoratedController ), nameof( UndecoratedController.Get ) ); + + actionModel.Controller.Properties[typeof( ApiVersionModel )] = NewControllerModel(); + actionBuilder.IntroducedInApiVersion( new ApiVersion( 2, 0 ) ); + + // act + actionBuilder.ApplyTo( actionModel ); + + // assert + actionModel.Selectors + .Single() + .EndpointMetadata + .OfType() + .Single() + .Map( Explicit ) + .DeclaredApiVersions + .Should() + .Equal( new ApiVersion( 2, 0 ), new ApiVersion( 3, 0 ) ); + } + + [Fact] + public void introduced_in_api_version_should_support_fluent_overloads() + { + // arrange + var controllerBuilder = new ControllerApiVersionConventionBuilder( typeof( UndecoratedController ) ); + var actionBuilder = new ActionApiVersionConventionBuilder( controllerBuilder ); + var actionModel = NewActionModel( typeof( UndecoratedController ), nameof( UndecoratedController.Get ) ); + + actionBuilder.IntroducedInApiVersion( new ApiVersion( 2, 0 ), 409 ) + .IntroducedInApiVersion( 3, 0 ) + .IntroducedInApiVersion( 4.0 ) + .IntroducedInApiVersion( 2026, 12, 1 ) + .IntroducedInApiVersion( new DateOnly( 2027, 6, 1 ) ); + + // act + actionBuilder.ApplyTo( actionModel ); + + // assert + actionModel.Selectors + .Single() + .EndpointMetadata + .OfType() + .Should() + .BeEquivalentTo( + new IntroducedInApiVersionMetadata[] + { + new( new ApiVersion( 2, 0 ), 409 ), + new( new ApiVersion( 3, 0 ), IntroducedInApiVersionAttribute.DefaultStatusCode ), + new( new ApiVersion( 4.0 ), IntroducedInApiVersionAttribute.DefaultStatusCode ), + new( new ApiVersion( new DateOnly( 2026, 12, 1 ) ), IntroducedInApiVersionAttribute.DefaultStatusCode ), + new( new ApiVersion( new DateOnly( 2027, 6, 1 ) ), IntroducedInApiVersionAttribute.DefaultStatusCode ), + }, + options => options.WithStrictOrdering() ); + } + + [Fact] + public void introduced_convention_should_match_attribute_declared_versions() + { + // arrange + var conventionAction = NewActionModel( typeof( UndecoratedController ), nameof( UndecoratedController.Get ) ); + var attributeAction = NewActionModel( typeof( DecoratedController ), nameof( DecoratedController.GetIntroduced ) ); + var controllerBuilder = new ControllerApiVersionConventionBuilder( typeof( UndecoratedController ) ); + var actionBuilder = new ActionApiVersionConventionBuilder( controllerBuilder ); + + conventionAction.Controller.Properties[typeof( ApiVersionModel )] = NewControllerModel(); + attributeAction.Controller.Properties[typeof( ApiVersionModel )] = NewControllerModel(); + actionBuilder.IntroducedInApiVersion( new ApiVersion( 2, 0 ) ); + + // act + actionBuilder.ApplyTo( conventionAction ); + new ActionApiVersionConventionBuilder( new ControllerApiVersionConventionBuilder( typeof( DecoratedController ) ) ) + .ApplyTo( attributeAction ); + + // assert + GetApiVersionMetadata( conventionAction ).Map( Explicit ).DeclaredApiVersions.Should().Equal( + GetApiVersionMetadata( attributeAction ).Map( Explicit ).DeclaredApiVersions ); + } + + private static ActionModel NewActionModel( Type controllerType, string actionName ) + { + var controller = new ControllerModel( controllerType.GetTypeInfo(), [] ) + { + ControllerName = controllerType.Name.Replace( "Controller", string.Empty, StringComparison.Ordinal ), + }; + var method = controllerType.GetMethod( actionName ); + var action = new ActionModel( method, method.GetCustomAttributes().Cast().ToArray() ) + { + Controller = controller, + }; + + return action; + } + + private static ApiVersionMetadata GetApiVersionMetadata( ActionModel action ) => + action.Selectors.Single().EndpointMetadata.OfType().Single(); + + private static ApiVersionModel NewControllerModel() + { + var versions = new ApiVersion[] { new( 1, 0 ), new( 2, 0 ), new( 3, 0 ) }; + + return new( versions, versions, [], [], [] ); + } } \ No newline at end of file diff --git a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder.cs index c72ece92..51af412f 100644 --- a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder.cs +++ b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder.cs @@ -50,6 +50,20 @@ public virtual ActionApiVersionConventionBuilder MapToApiVersion( ApiVersion api return this; } + /// + /// Indicates that the configured action was introduced in the specified API version. + /// + /// The API version the action was introduced in. + /// The HTTP status code for earlier API versions. + /// The original . + public virtual ActionApiVersionConventionBuilder IntroducedInApiVersion( + ApiVersion apiVersion, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) + { + IntroducedVersions.Add( new( apiVersion, statusCode ) ); + return this; + } + /// /// Indicates that the action is API version-neutral. /// @@ -116,5 +130,8 @@ public virtual ActionApiVersionConventionBuilder AdvertisesDeprecatedApiVersion( void IMapToApiVersionConventionBuilder.MapToApiVersion( ApiVersion apiVersion ) => MapToApiVersion( apiVersion ); + void IIntroducedInApiVersionConventionBuilder.IntroducedInApiVersion( ApiVersion apiVersion, int statusCode ) => + IntroducedInApiVersion( apiVersion, statusCode ); + IActionConventionBuilder IActionConventionBuilder.Action( MethodInfo actionMethod ) => Action( actionMethod ); } \ No newline at end of file diff --git a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder{T}.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder{T}.cs index 0531a70a..7a91b817 100644 --- a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder{T}.cs +++ b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder{T}.cs @@ -62,6 +62,20 @@ public virtual ActionApiVersionConventionBuilder MapToApiVersion( ApiVersion return this; } + /// + /// Indicates that the configured action was introduced in the specified API version. + /// + /// The API version the action was introduced in. + /// The HTTP status code for earlier API versions. + /// The original . + public virtual ActionApiVersionConventionBuilder IntroducedInApiVersion( + ApiVersion apiVersion, + int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode ) + { + IntroducedVersions.Add( new( apiVersion, statusCode ) ); + return this; + } + /// /// Indicates that the action is API version-neutral. /// @@ -134,6 +148,9 @@ public virtual ActionApiVersionConventionBuilder AdvertisesDeprecatedApiVersi void IMapToApiVersionConventionBuilder.MapToApiVersion( ApiVersion apiVersion ) => MapToApiVersion( apiVersion ); + void IIntroducedInApiVersionConventionBuilder.IntroducedInApiVersion( ApiVersion apiVersion, int statusCode ) => + IntroducedInApiVersion( apiVersion, statusCode ); + IActionConventionBuilder IActionConventionBuilder.Action( MethodInfo actionMethod ) => Action( actionMethod ); IActionConventionBuilder IActionConventionBuilder.Action( MethodInfo actionMethod ) => Action( actionMethod ); diff --git a/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder.cs b/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder.cs index d3fd5580..00c02a9d 100644 --- a/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder.cs +++ b/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder.cs @@ -16,7 +16,7 @@ namespace Asp.Versioning.Conventions; #if !NETFRAMEWORK [CLSCompliant( false )] #endif -public interface IActionConventionBuilder : IMapToApiVersionConventionBuilder, IApiVersionConvention +public interface IActionConventionBuilder : IIntroducedInApiVersionConventionBuilder, IApiVersionConvention { /// /// Gets the type of controller the convention builder is for. diff --git a/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder{T}.cs b/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder{T}.cs index a1bf381d..fbcbae84 100644 --- a/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder{T}.cs +++ b/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder{T}.cs @@ -14,7 +14,7 @@ namespace Asp.Versioning.Conventions; #if !NETFRAMEWORK [CLSCompliant( false )] #endif -public interface IActionConventionBuilder : IMapToApiVersionConventionBuilder +public interface IActionConventionBuilder : IIntroducedInApiVersionConventionBuilder #if NETFRAMEWORK where T : notnull, IHttpController #else diff --git a/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs b/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs index ed3fcbbf..8177dd86 100644 --- a/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs +++ b/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs @@ -52,5 +52,8 @@ public sealed class DecoratedController : ControllerBase [MapToApiVersion( "2.0" )] [MapToApiVersion( "3.0" )] public IActionResult GetV2() => Ok(); + + [IntroducedInApiVersion( "2.0" )] + public IActionResult GetIntroduced() => Ok(); } } \ No newline at end of file From 0eb79225cacfa035a296743adbd24ad8815eac81 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 11:18:58 -0700 Subject: [PATCH 21/26] Add summary to introduced-in convention extension block CodeQL flagged the extension(...) block in ApiVersionConventionBuilderExtensions.cs as missing a 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> --- .../Conventions/ApiVersionConventionBuilderExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs index 17d55abe..c27cdfb1 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs @@ -339,6 +339,9 @@ public T MapToApiVersions( IEnumerable apiVersions ) } } + /// + /// Provides extensions for builders that support the introduced-in API version convention. + /// /// The type of . /// The extended . /// The original . From 6a455a32c7d045ff6ef0ef09c4d07a95b44f4daf Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 11:49:06 -0700 Subject: [PATCH 22/26] Intersect introduced action supported versions 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> --- .../ActionApiVersionConventionBuilderBase.cs | 7 +- .../ActionApiVersionConventionBuilderTest.cs | 50 ++++++++++++++ .../ActionApiVersionConventionBuilderBase.cs | 7 +- .../VersionedApiDescriptionProviderTest.cs | 65 +++++++++++++++++++ .../ActionApiVersionConventionBuilderTest.cs | 52 +++++++++++++++ .../ActionApiVersionConventionBuilderBase.cs | 6 ++ 6 files changed, 183 insertions(+), 4 deletions(-) diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Conventions/ActionApiVersionConventionBuilderBase.cs index 1b2eb26d..7773c371 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -70,10 +70,13 @@ public virtual void ApplyTo( HttpActionDescriptor item ) else { emptyVersions = []; + var supportedVersions = HasIntroducedVersions ? inheritedSupported.Intersect( effectiveMapped ) : inheritedSupported; + var deprecatedVersions = HasIntroducedVersions ? inheritedDeprecated.Intersect( effectiveMapped ) : inheritedDeprecated; + endpointModel = new( declaredVersions: effectiveMapped, - supportedVersions: apiModel.SupportedApiVersions, - deprecatedVersions: apiModel.DeprecatedApiVersions, + supportedVersions: supportedVersions, + deprecatedVersions: deprecatedVersions, advertisedVersions: emptyVersions, deprecatedAdvertisedVersions: emptyVersions ); } diff --git a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs index 228b97fb..c8b8b153 100644 --- a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs +++ b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs @@ -110,6 +110,56 @@ public void apply_to_should_expand_declared_api_versions_from_introduced_convent .Equal( new ApiVersion( 2, 0 ), new ApiVersion( 3, 0 ) ); } + [Fact] + public void apply_to_should_intersect_supported_api_versions_with_introduced_convention() + { + // arrange + var controllerBuilder = new ControllerApiVersionConventionBuilder( typeof( UndecoratedController ) ); + var actionBuilder = new ActionApiVersionConventionBuilder( controllerBuilder ); + var actionDescriptor = NewActionDescriptor(); + var version1 = new ApiVersion( 1, 0 ); + var version2 = new ApiVersion( 2, 0 ); + var version3 = new ApiVersion( 3, 0 ); + + actionBuilder.IntroducedInApiVersion( version2 ); + + // act + actionBuilder.ApplyTo( actionDescriptor ); + + // assert + var metadata = actionDescriptor.ApiVersionMetadata; + var model = metadata.Map( Explicit | Implicit ); + + model.SupportedApiVersions.Should().NotContain( version1 ); + model.SupportedApiVersions.Should().ContainInOrder( version2, version3 ); + metadata.MappingTo( version2 ).Should().Be( Explicit ); + metadata.MappingTo( version3 ).Should().Be( Explicit ); + } + + [Fact] + public void apply_to_should_preserve_inherited_supported_api_versions_with_mapped_convention() + { + // arrange + var controllerBuilder = new ControllerApiVersionConventionBuilder( typeof( UndecoratedController ) ); + var actionBuilder = new ActionApiVersionConventionBuilder( controllerBuilder ); + var actionDescriptor = NewActionDescriptor(); + var version1 = new ApiVersion( 1, 0 ); + var version2 = new ApiVersion( 2, 0 ); + var version3 = new ApiVersion( 3, 0 ); + + actionBuilder.MapToApiVersion( version2 ); + + // act + actionBuilder.ApplyTo( actionDescriptor ); + + // assert + actionDescriptor.ApiVersionMetadata + .Map( Explicit | Implicit ) + .SupportedApiVersions + .Should() + .Equal( version1, version2, version3 ); + } + private static ReflectedHttpActionDescriptor NewActionDescriptor() { var controllerDescriptor = new HttpControllerDescriptor() diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs index 05251b02..4a3fb51b 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -70,10 +70,13 @@ public virtual void ApplyTo( ActionModel item ) else { emptyVersions = []; + var supportedVersions = HasIntroducedVersions ? inheritedSupported.Intersect( effectiveMapped ) : inheritedSupported; + var deprecatedVersions = HasIntroducedVersions ? inheritedDeprecated.Intersect( effectiveMapped ) : inheritedDeprecated; + endpointModel = new( declaredVersions: effectiveMapped, - supportedVersions: inheritedSupported, - deprecatedVersions: inheritedDeprecated, + supportedVersions: supportedVersions, + deprecatedVersions: deprecatedVersions, advertisedVersions: emptyVersions, deprecatedAdvertisedVersions: emptyVersions ); } diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs index 94e0cdf9..c6d2612b 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs @@ -2,11 +2,15 @@ namespace Asp.Versioning.ApiExplorer; +using Asp.Versioning.Conventions; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.Options; +using System.Reflection; public class VersionedApiDescriptionProviderTest { @@ -122,6 +126,34 @@ public void versioned_api_explorer_should_preserve_group_name() context.Results.Single().GroupName.Should().Be( "Test" ); } + [Fact] + public void versioned_api_explorer_should_exclude_versions_before_introduced_action() + { + // arrange + var metadata = GetApiVersionMetadata( typeof( IntroducedController ), nameof( IntroducedController.Get ) ); + var descriptor = new ActionDescriptor() { EndpointMetadata = [metadata] }; + var actionProvider = new TestActionDescriptorCollectionProvider( descriptor ); + var context = new ApiDescriptionProviderContext( actionProvider.ActionDescriptors.Items ); + var apiExplorer = new VersionedApiDescriptionProvider( + Mock.Of>(), + Mock.Of>(), + NewModelMetadataProvider(), + Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) ); + + context.Results.Add( new() + { + ActionDescriptor = descriptor, + HttpMethod = "GET", + RelativePath = "test", + } ); + + // act + apiExplorer.OnProvidersExecuted( context ); + + // assert + context.Results.Select( result => result.GroupName ).Should().Equal( "v2", "v3" ); + } + [Fact] public void versioned_api_explorer_should_use_custom_group_name() { @@ -254,4 +286,37 @@ private static IModelMetadataProvider NewModelMetadataProvider() return provider.Object; } + + private static ApiVersionMetadata GetApiVersionMetadata( Type controllerType, string actionName ) + { + var controllerAttributes = controllerType.GetTypeInfo().GetCustomAttributes().Cast().ToArray(); + var controller = new ControllerModel( controllerType.GetTypeInfo(), controllerAttributes ) + { + ControllerName = controllerType.Name.Replace( "Controller", string.Empty, StringComparison.Ordinal ), + }; + var method = controllerType.GetMethod( actionName ); + var action = new ActionModel( method, method.GetCustomAttributes().Cast().ToArray() ) + { + Controller = controller, + }; + + controller.Actions.Add( action ); + new ControllerApiVersionConventionBuilder( controllerType ).ApplyTo( controller ); + + return action.Selectors.Single().EndpointMetadata.OfType().Single(); + } + +#pragma warning disable CA1034 // Nested types should not be visible + + [ApiController] + [ApiVersion( 1.0 )] + [ApiVersion( 2.0 )] + [ApiVersion( 3.0 )] + public sealed class IntroducedController : ControllerBase + { + [IntroducedInApiVersion( 2.0 )] + public OkResult Get() => Ok(); + } + +#pragma warning restore CA1034 // Nested types should not be visible } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs index e769fd1a..8e53fae8 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs @@ -141,6 +141,58 @@ public void apply_to_should_expand_declared_api_versions_from_introduced_convent .Equal( new ApiVersion( 2, 0 ), new ApiVersion( 3, 0 ) ); } + [Fact] + public void apply_to_should_intersect_supported_api_versions_with_introduced_convention() + { + // arrange + var controllerBuilder = new ControllerApiVersionConventionBuilder( typeof( UndecoratedController ) ); + var actionBuilder = new ActionApiVersionConventionBuilder( controllerBuilder ); + var actionModel = NewActionModel( typeof( UndecoratedController ), nameof( UndecoratedController.Get ) ); + var version1 = new ApiVersion( 1, 0 ); + var version2 = new ApiVersion( 2, 0 ); + var version3 = new ApiVersion( 3, 0 ); + + actionModel.Controller.Properties[typeof( ApiVersionModel )] = NewControllerModel(); + actionBuilder.IntroducedInApiVersion( version2 ); + + // act + actionBuilder.ApplyTo( actionModel ); + + // assert + var metadata = GetApiVersionMetadata( actionModel ); + var model = metadata.Map( Explicit | Implicit ); + + model.SupportedApiVersions.Should().NotContain( version1 ); + model.SupportedApiVersions.Should().ContainInOrder( version2, version3 ); + metadata.MappingTo( version2 ).Should().Be( Explicit ); + metadata.MappingTo( version3 ).Should().Be( Explicit ); + } + + [Fact] + public void apply_to_should_preserve_inherited_supported_api_versions_with_mapped_convention() + { + // arrange + var controllerBuilder = new ControllerApiVersionConventionBuilder( typeof( UndecoratedController ) ); + var actionBuilder = new ActionApiVersionConventionBuilder( controllerBuilder ); + var actionModel = NewActionModel( typeof( UndecoratedController ), nameof( UndecoratedController.Get ) ); + var version1 = new ApiVersion( 1, 0 ); + var version2 = new ApiVersion( 2, 0 ); + var version3 = new ApiVersion( 3, 0 ); + + actionModel.Controller.Properties[typeof( ApiVersionModel )] = NewControllerModel(); + actionBuilder.MapToApiVersion( version2 ); + + // act + actionBuilder.ApplyTo( actionModel ); + + // assert + GetApiVersionMetadata( actionModel ) + .Map( Explicit | Implicit ) + .SupportedApiVersions + .Should() + .Equal( version1, version2, version3 ); + } + [Fact] public void introduced_in_api_version_should_support_fluent_overloads() { diff --git a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs index a0d74b83..b90646d5 100644 --- a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -44,6 +44,12 @@ public abstract partial class ActionApiVersionConventionBuilderBase : ApiVersion /// protected bool HasMappedVersions => ( mapped is not null && mapped.Count > 0 ) || ( introduced is not null && introduced.Count > 0 ); + /// + /// Gets a value indicating whether any introduced API versions are configured for the action. + /// + /// True if any introduced versions are configured for the action; otherwise, false. + protected bool HasIntroducedVersions => introduced is not null && introduced.Count > 0; + /// /// Gets the collection of API versions in which the current action was introduced. /// From 1baa0050084df09e0dc68c18f6412c3260e779ab Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 11:49:17 -0700 Subject: [PATCH 23/26] Restore action convention interface shape 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 retain the main-compatible IMapToApiVersionConventionBuilder surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ActionApiVersionConventionBuilder.cs | 5 ++- .../ActionApiVersionConventionBuilder{T}.cs | 3 +- .../Conventions/IActionConventionBuilder.cs | 2 +- .../IActionConventionBuilder{T}.cs | 2 +- .../ActionApiVersionConventionBuilderTTest.cs | 30 ++++++++++++++++ .../ActionApiVersionConventionBuilderTest.cs | 36 +++++++++++++++++++ 6 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder.cs index 51af412f..10a5417d 100644 --- a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder.cs +++ b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder.cs @@ -10,7 +10,10 @@ namespace Asp.Versioning.Conventions; #if !NETFRAMEWORK [CLSCompliant( false )] #endif -public class ActionApiVersionConventionBuilder : ActionApiVersionConventionBuilderBase, IActionConventionBuilder +public class ActionApiVersionConventionBuilder : + ActionApiVersionConventionBuilderBase, + IActionConventionBuilder, + IIntroducedInApiVersionConventionBuilder { /// /// Initializes a new instance of the class. diff --git a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder{T}.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder{T}.cs index 7a91b817..ae1d270c 100644 --- a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder{T}.cs +++ b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder{T}.cs @@ -18,7 +18,8 @@ namespace Asp.Versioning.Conventions; public class ActionApiVersionConventionBuilder : ActionApiVersionConventionBuilderBase, IActionConventionBuilder, - IActionConventionBuilder + IActionConventionBuilder, + IIntroducedInApiVersionConventionBuilder #if NETFRAMEWORK where T : notnull, IHttpController #else diff --git a/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder.cs b/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder.cs index 00c02a9d..d3fd5580 100644 --- a/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder.cs +++ b/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder.cs @@ -16,7 +16,7 @@ namespace Asp.Versioning.Conventions; #if !NETFRAMEWORK [CLSCompliant( false )] #endif -public interface IActionConventionBuilder : IIntroducedInApiVersionConventionBuilder, IApiVersionConvention +public interface IActionConventionBuilder : IMapToApiVersionConventionBuilder, IApiVersionConvention { /// /// Gets the type of controller the convention builder is for. diff --git a/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder{T}.cs b/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder{T}.cs index fbcbae84..a1bf381d 100644 --- a/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder{T}.cs +++ b/src/Common/src/Common.Mvc/Conventions/IActionConventionBuilder{T}.cs @@ -14,7 +14,7 @@ namespace Asp.Versioning.Conventions; #if !NETFRAMEWORK [CLSCompliant( false )] #endif -public interface IActionConventionBuilder : IIntroducedInApiVersionConventionBuilder +public interface IActionConventionBuilder : IMapToApiVersionConventionBuilder #if NETFRAMEWORK where T : notnull, IHttpController #else diff --git a/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTTest.cs b/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTTest.cs index baef9fc0..39f46c2c 100644 --- a/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTTest.cs +++ b/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTTest.cs @@ -30,9 +30,39 @@ public void action_should_call_action_on_controller_builder() controllerBuilder.Verify( cb => cb.Action( method ), Once() ); } + [Fact] + public void custom_action_convention_builder_should_not_implement_introduced_contract() + { + // arrange + var builder = new CustomActionConventionBuilder(); + + // act + var actionBuilder = (IActionConventionBuilder) builder; + + // assert + actionBuilder.Should().NotBeAssignableTo(); + } + #pragma warning disable IDE0079 #pragma warning disable CA1034 // Nested types should not be visible + public sealed class CustomActionConventionBuilder : IActionConventionBuilder + { + public IActionConventionBuilder Action( MethodInfo actionMethod ) => this; + + public void MapToApiVersion( ApiVersion apiVersion ) { } + + public void IsApiVersionNeutral() { } + + public void HasApiVersion( ApiVersion apiVersion ) { } + + public void HasDeprecatedApiVersion( ApiVersion apiVersion ) { } + + public void AdvertisesApiVersion( ApiVersion apiVersion ) { } + + public void AdvertisesDeprecatedApiVersion( ApiVersion apiVersion ) { } + } + #if !NETFRAMEWORK [ApiController] #endif diff --git a/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs b/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs index 8177dd86..2e9d19dc 100644 --- a/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs +++ b/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs @@ -4,8 +4,10 @@ namespace Asp.Versioning.Conventions; #if !NETFRAMEWORK using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; #endif #if NETFRAMEWORK +using ActionModel = System.Web.Http.Controllers.HttpActionDescriptor; using ControllerBase = System.Web.Http.ApiController; using IActionResult = System.Web.Http.IHttpActionResult; #endif @@ -31,9 +33,43 @@ public void action_should_call_action_on_controller_builder() controllerBuilder.Verify( cb => cb.Action( method ), Once() ); } + [Fact] + public void custom_action_convention_builder_should_not_implement_introduced_contract() + { + // arrange + var builder = new CustomActionConventionBuilder(); + + // act + var actionBuilder = (IActionConventionBuilder) builder; + + // assert + actionBuilder.Should().NotBeAssignableTo(); + } + #pragma warning disable IDE0079 #pragma warning disable CA1034 // Nested types should not be visible + public sealed class CustomActionConventionBuilder : IActionConventionBuilder + { + public Type ControllerType => typeof( UndecoratedController ); + + public IActionConventionBuilder Action( MethodInfo actionMethod ) => this; + + public void MapToApiVersion( ApiVersion apiVersion ) { } + + public void IsApiVersionNeutral() { } + + public void HasApiVersion( ApiVersion apiVersion ) { } + + public void HasDeprecatedApiVersion( ApiVersion apiVersion ) { } + + public void AdvertisesApiVersion( ApiVersion apiVersion ) { } + + public void AdvertisesDeprecatedApiVersion( ApiVersion apiVersion ) { } + + public void ApplyTo( ActionModel item ) { } + } + #if !NETFRAMEWORK [ApiController] #endif From bbdb874630beb0df7b235b8124173cf50daa8622 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 12:27:44 -0700 Subject: [PATCH 24/26] Expand minimal API introduced versions 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> --- .../Builder/EndpointBuilderFinalizer.cs | 30 +++- ...EndpointConventionBuilderExtensionsTest.cs | 138 +++++++++++++++++- .../ActionApiVersionConventionBuilderTest.cs | 110 +++++++++++++- 3 files changed, 268 insertions(+), 10 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs index 50f5bbf4..497f4cd5 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs @@ -261,7 +261,9 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v { var (mapped, supported, deprecated, advertised, advertisedDeprecated) = buckets; - if ( mapped.Count == 0 ) + var hasIntroducedVersions = introducedInApiVersions.Length > 0; + + if ( mapped.Count == 0 && !hasIntroducedVersions ) { endpointModel = new( declaredVersions: supported.Union( deprecated ), @@ -273,10 +275,23 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v else { emptyVersions = []; + var effectiveMapped = mapped; + var effectiveSupported = inheritedSupported.AsEnumerable(); + var effectiveDeprecated = inheritedDeprecated.AsEnumerable(); + + if ( hasIntroducedVersions ) + { + effectiveMapped = ExpandIntroducedVersions( apiModel.DeclaredApiVersions, introducedInApiVersions, mapped ) ?? []; + effectiveSupported = inheritedSupported.Intersect( effectiveMapped ); + effectiveDeprecated = inheritedDeprecated.Intersect( effectiveMapped ); + } + + // MVC treats IntroducedInApiVersion as an action-level mapping constraint: explicit + // supported/deprecated versions do not narrow the introduced-and-later expansion. endpointModel = new( - declaredVersions: mapped, - supportedVersions: inheritedSupported, - deprecatedVersions: inheritedDeprecated, + declaredVersions: effectiveMapped, + supportedVersions: effectiveSupported, + deprecatedVersions: effectiveDeprecated, advertisedVersions: emptyVersions, deprecatedAdvertisedVersions: emptyVersions ); } @@ -287,7 +302,8 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v private static ApiVersion[]? ExpandIntroducedVersions( IReadOnlyList declaredVersions, - IntroducedInApiVersionMetadata[] introducedInApiVersions ) + IntroducedInApiVersionMetadata[] introducedInApiVersions, + IEnumerable? mappedVersions = default ) { if ( introducedInApiVersions.Length == 0 ) { @@ -304,7 +320,7 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v } } - var versions = new List(); + var versions = mappedVersions is null ? new HashSet() : new HashSet( mappedVersions ); for ( var i = 0; i < declaredVersions.Count; i++ ) { @@ -316,7 +332,7 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v } } - return [.. versions]; + return [.. versions.OrderBy( v => v )]; } private record struct ApiVersionBuckets( diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs index ce0a2bfe..adfb02c5 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs @@ -508,7 +508,7 @@ public void with_api_version_set_should_expand_introduced_version() } [Fact] - public void with_api_version_set_should_prefer_explicit_versions_over_introduced_version() + public void with_api_version_set_should_expand_supported_versions_from_introduced_version() { // arrange var dataSources = new List(); @@ -538,10 +538,136 @@ public void with_api_version_set_should_prefer_explicit_versions_over_introduced .Single(); metadata.MappingTo( new ApiVersion( 1.0 ) ).Should().Be( ApiVersionMapping.None ); - metadata.MappingTo( new ApiVersion( 2.0 ) ).Should().Be( ApiVersionMapping.None ); + metadata.MappingTo( new ApiVersion( 2.0 ) ).Should().Be( ApiVersionMapping.Explicit ); metadata.MappingTo( new ApiVersion( 3.0 ) ).Should().Be( ApiVersionMapping.Explicit ); } + [Fact] + public void with_api_version_set_should_apply_introduced_version_to_explicit_supported_versions() + { + // arrange + var dataSources = new List(); + var app = new Mock(); + + app.SetupGet( a => a.ServiceProvider ).Returns( new MockServiceProvider() ); + app.SetupGet( a => a.DataSources ).Returns( dataSources ); + + // act + var versionSet = app.Object.NewApiVersionSet() + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .HasApiVersion( 3.0 ) + .Build(); + + app.Object.MapGet( "/test", () => Results.Ok() ) + .WithApiVersionSet( versionSet ) + .HasApiVersion( 1.0 ) + .IntroducedInApiVersion( 3.0 ); + + // assert + var metadata = GetApiVersionMetadata( dataSources ); + var model = metadata.Map( ApiVersionMapping.Explicit ); + + model.DeclaredApiVersions.Should().Equal( new ApiVersion( 3.0 ) ); + model.ImplementedApiVersions.Should().Equal( new ApiVersion( 3.0 ) ); + metadata.MappingTo( new ApiVersion( 1.0 ) ).Should().Be( ApiVersionMapping.None ); + metadata.MappingTo( new ApiVersion( 3.0 ) ).Should().Be( ApiVersionMapping.Explicit ); + } + + [Fact] + public void with_api_version_set_should_expand_mapped_versions_from_introduced_version() + { + // arrange + var dataSources = new List(); + var app = new Mock(); + + app.SetupGet( a => a.ServiceProvider ).Returns( new MockServiceProvider() ); + app.SetupGet( a => a.DataSources ).Returns( dataSources ); + + // act + var versionSet = app.Object.NewApiVersionSet() + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .HasApiVersion( 3.0 ) + .HasApiVersion( 4.0 ) + .Build(); + + app.Object.MapGet( "/test", () => Results.Ok() ) + .WithApiVersionSet( versionSet ) + .MapToApiVersion( 2.0 ) + .IntroducedInApiVersion( 3.0 ); + + // assert + var model = GetApiVersionMetadata( dataSources ).Map( ApiVersionMapping.Explicit ); + + model.DeclaredApiVersions.Should().Equal( new ApiVersion( 2.0 ), new ApiVersion( 3.0 ), new ApiVersion( 4.0 ) ); + model.ImplementedApiVersions.Should().Equal( new ApiVersion( 2.0 ), new ApiVersion( 3.0 ), new ApiVersion( 4.0 ) ); + } + + [Theory] + [InlineData( 1.0 )] + [InlineData( 4.0 )] + public void with_api_version_set_should_mirror_mvc_when_supported_version_is_combined_with_introduced_version( double version ) + { + // arrange + var dataSources = new List(); + var app = new Mock(); + + app.SetupGet( a => a.ServiceProvider ).Returns( new MockServiceProvider() ); + app.SetupGet( a => a.DataSources ).Returns( dataSources ); + + // act + var versionSet = app.Object.NewApiVersionSet() + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .HasApiVersion( 3.0 ) + .HasApiVersion( 4.0 ) + .Build(); + + app.Object.MapGet( "/test", () => Results.Ok() ) + .WithApiVersionSet( versionSet ) + .HasApiVersion( version ) + .IntroducedInApiVersion( 3.0 ); + + // assert + var model = GetApiVersionMetadata( dataSources ).Map( ApiVersionMapping.Explicit ); + + model.DeclaredApiVersions.Should().Equal( new ApiVersion( 3.0 ), new ApiVersion( 4.0 ) ); + model.ImplementedApiVersions.Should().Equal( new ApiVersion( 3.0 ), new ApiVersion( 4.0 ) ); + } + + [Fact] + public void with_api_version_set_should_mirror_mvc_when_deprecated_version_is_combined_with_introduced_version() + { + // arrange + var dataSources = new List(); + var app = new Mock(); + + app.SetupGet( a => a.ServiceProvider ).Returns( new MockServiceProvider() ); + app.SetupGet( a => a.DataSources ).Returns( dataSources ); + + // act + var versionSet = app.Object.NewApiVersionSet() + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .HasDeprecatedApiVersion( 3.0 ) + .HasDeprecatedApiVersion( 4.0 ) + .Build(); + + app.Object.MapGet( "/test", () => Results.Ok() ) + .WithApiVersionSet( versionSet ) + .HasDeprecatedApiVersion( 1.0 ) + .IntroducedInApiVersion( 3.0 ); + + // assert + var model = GetApiVersionMetadata( dataSources ).Map( ApiVersionMapping.Explicit ); + + model.DeclaredApiVersions.Should().Equal( new ApiVersion( 3.0 ), new ApiVersion( 4.0 ) ); + model.ImplementedApiVersions.Should().Equal( new ApiVersion( 3.0 ), new ApiVersion( 4.0 ) ); + model.SupportedApiVersions.Should().BeEmpty(); + model.DeprecatedApiVersions.Should().Equal( new ApiVersion( 3.0 ), new ApiVersion( 4.0 ) ); + } + [Fact] public void has_deprecated_api_version_should_add_convention() { @@ -883,4 +1009,12 @@ public object GetService( Type serviceType ) return null; } } + + private static ApiVersionMetadata GetApiVersionMetadata( IReadOnlyList dataSources ) => + dataSources.Single() + .Endpoints + .Single() + .Metadata + .OfType() + .Single(); } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs index 8e53fae8..7f957b6b 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderTest.cs @@ -2,8 +2,13 @@ namespace Asp.Versioning.Conventions; +using Asp.Versioning.Builder; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; using System.Reflection; using static Asp.Versioning.ApiVersionMapping; @@ -251,6 +256,60 @@ public void introduced_convention_should_match_attribute_declared_versions() GetApiVersionMetadata( attributeAction ).Map( Explicit ).DeclaredApiVersions ); } + [Fact] + public void introduced_convention_should_match_minimal_api_mapped_versions() + { + // arrange + var action = NewActionModel( typeof( UndecoratedController ), nameof( UndecoratedController.Get ) ); + var controllerBuilder = new ControllerApiVersionConventionBuilder( typeof( UndecoratedController ) ); + var actionBuilder = new ActionApiVersionConventionBuilder( controllerBuilder ); + + action.Controller.Properties[typeof( ApiVersionModel )] = NewControllerModel( + new ApiVersion( 1, 0 ), + new ApiVersion( 2, 0 ), + new ApiVersion( 3, 0 ), + new ApiVersion( 4, 0 ) ); + actionBuilder.MapToApiVersion( new ApiVersion( 2, 0 ) ) + .IntroducedInApiVersion( new ApiVersion( 3, 0 ) ); + + // act + actionBuilder.ApplyTo( action ); + var mvcModel = GetApiVersionMetadata( action ).Map( Explicit ); + var minimalModel = NewMinimalApiVersionMetadata( + route => route.MapToApiVersion( 2.0 ).IntroducedInApiVersion( 3.0 ) ).Map( Explicit ); + + // assert + minimalModel.DeclaredApiVersions.Should().Equal( mvcModel.DeclaredApiVersions ); + minimalModel.ImplementedApiVersions.Should().Equal( mvcModel.ImplementedApiVersions ); + } + + [Fact] + public void introduced_convention_should_match_minimal_api_supported_versions() + { + // arrange + var action = NewActionModel( typeof( UndecoratedController ), nameof( UndecoratedController.Get ) ); + var controllerBuilder = new ControllerApiVersionConventionBuilder( typeof( UndecoratedController ) ); + var actionBuilder = new ActionApiVersionConventionBuilder( controllerBuilder ); + + action.Controller.Properties[typeof( ApiVersionModel )] = NewControllerModel( + new ApiVersion( 1, 0 ), + new ApiVersion( 2, 0 ), + new ApiVersion( 3, 0 ), + new ApiVersion( 4, 0 ) ); + actionBuilder.HasApiVersion( new ApiVersion( 1, 0 ) ) + .IntroducedInApiVersion( new ApiVersion( 3, 0 ) ); + + // act + actionBuilder.ApplyTo( action ); + var mvcModel = GetApiVersionMetadata( action ).Map( Explicit ); + var minimalModel = NewMinimalApiVersionMetadata( + route => route.HasApiVersion( 1.0 ).IntroducedInApiVersion( 3.0 ) ).Map( Explicit ); + + // assert + minimalModel.DeclaredApiVersions.Should().Equal( mvcModel.DeclaredApiVersions ); + minimalModel.ImplementedApiVersions.Should().Equal( mvcModel.ImplementedApiVersions ); + } + private static ActionModel NewActionModel( Type controllerType, string actionName ) { var controller = new ControllerModel( controllerType.GetTypeInfo(), [] ) @@ -273,6 +332,55 @@ private static ApiVersionModel NewControllerModel() { var versions = new ApiVersion[] { new( 1, 0 ), new( 2, 0 ), new( 3, 0 ) }; - return new( versions, versions, [], [], [] ); + return NewControllerModel( versions ); + } + + private static ApiVersionModel NewControllerModel( params ApiVersion[] versions ) => new( versions, versions, [], [], [] ); + + private static ApiVersionMetadata NewMinimalApiVersionMetadata( Action configure ) + { + var dataSources = new List(); + var app = new Mock(); + + app.SetupGet( a => a.ServiceProvider ).Returns( new MockServiceProvider() ); + app.SetupGet( a => a.DataSources ).Returns( dataSources ); + + var versionSet = app.Object.NewApiVersionSet() + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .HasApiVersion( 3.0 ) + .HasApiVersion( 4.0 ) + .Build(); + var route = app.Object.MapGet( "/test", () => Results.Ok() ) + .WithApiVersionSet( versionSet ); + + configure( route ); + + return dataSources.Single() + .Endpoints + .Single() + .Metadata + .OfType() + .Single(); + } + + private sealed class MockServiceProvider : IServiceProvider + { + private readonly IOptions options = Options.Create( new ApiVersioningOptions() ); + + public object GetService( Type serviceType ) + { + if ( typeof( IOptions ) == serviceType ) + { + return options; + } + + if ( typeof( IApiVersionParameterSource ) == serviceType ) + { + return options.Value.ApiVersionReader; + } + + return null; + } } } \ No newline at end of file From 513fa4ce2b7bc846bd3dea8b9b09f50eb37a8cc3 Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 12:34:28 -0700 Subject: [PATCH 25/26] Recognize introduced type in ErrorObjectWriter 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> --- .../WebApi/src/Asp.Versioning.Http/ErrorObjectWriter.cs | 3 ++- .../test/Asp.Versioning.Http.Tests/ErrorObjectWriterTest.cs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ErrorObjectWriter.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ErrorObjectWriter.cs index b02d1260..37db6139 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ErrorObjectWriter.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ErrorObjectWriter.cs @@ -54,7 +54,8 @@ public virtual bool CanWrite( ProblemDetailsContext context ) return type == ProblemDetailsDefaults.Unsupported.Type || type == ProblemDetailsDefaults.Unspecified.Type || type == ProblemDetailsDefaults.Invalid.Type || - type == ProblemDetailsDefaults.Ambiguous.Type; + type == ProblemDetailsDefaults.Ambiguous.Type || + type == ProblemDetailsDefaults.Introduced.Type; } /// diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/ErrorObjectWriterTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/ErrorObjectWriterTest.cs index 04aac15c..3c82cfe3 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/ErrorObjectWriterTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/ErrorObjectWriterTest.cs @@ -27,6 +27,7 @@ public class ErrorObjectWriterTest [InlineData( "https://docs.api-versioning.org/problems#unspecified" )] [InlineData( "https://docs.api-versioning.org/problems#invalid" )] [InlineData( "https://docs.api-versioning.org/problems#ambiguous" )] + [InlineData( "https://docs.api-versioning.org/problems#introduced" )] public void can_write_should_be_true_for_api_versioning_problem_types( string type ) { // arrange From 0402e7082006d9d732d996d95cc64e3390387e1c Mon Sep 17 00:00:00 2001 From: Xavier John <1859710+xavierjohn@users.noreply.github.com> Date: Sun, 10 May 2026 13:06:04 -0700 Subject: [PATCH 26/26] Share introduced metadata instances in MVC convention apply 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> --- .../ActionApiVersionConventionBuilderBase.cs | 6 +++- .../IntroducedInApiVersionConventionTest.cs | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs index 4a3fb51b..77a62b05 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs @@ -88,7 +88,11 @@ public virtual void ApplyTo( ActionModel item ) if ( !metadata.IsApiVersionNeutral ) { - AddIntroducedApiVersionMetadata( item.AddEndpointMetadata ); + var introducedItems = metadata.IntroducedInApiVersions; + for ( var i = 0; i < introducedItems.Count; i++ ) + { + item.AddEndpointMetadata( introducedItems[i] ); + } } } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs index 6ae5c098..527296fb 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs @@ -140,6 +140,34 @@ public void apply_to_should_ignore_introduced_version_metadata_for_version_neutr introduced.Should().BeEmpty(); } + [Fact] + public void apply_to_should_share_introduced_metadata_instances_across_endpoint_and_api_version_metadata() + { + // arrange + var action = ApplyConventions( typeof( IntroducedController ), nameof( IntroducedController.GetIntroduced ) ); + var metadata = GetApiVersionMetadata( action ); + + // act + var consolidated = metadata.IntroducedInApiVersions; + var standalone = action.Selectors.Single() + .EndpointMetadata + .OfType() + .ToArray(); + + // assert + // The same IntroducedInApiVersionMetadata instances must back both + // the consolidated ApiVersionMetadata.IntroducedInApiVersions list + // and the standalone EndpointMetadata items. Two distinct sets would + // mean ApplyTo allocated and sorted twice for the same data. + consolidated.Should().HaveCount( standalone.Length ); + for ( var i = 0; i < consolidated.Count; i++ ) + { + ReferenceEquals( consolidated[i], standalone[i] ).Should().BeTrue( + "consolidated[{0}] and standalone[{0}] should be the same instance", + i ); + } + } + private static ActionModel ApplyConventions( Type controllerType, string actionName ) { var controllerAttributes = controllerType.GetTypeInfo().GetCustomAttributes().Cast().ToArray();