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
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 )
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 } );
+}
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/Conventions/ApiVersionConventionBuilderExtensions.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs
index af66109c..c27cdfb1 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/Conventions/ApiVersionConventionBuilderExtensions.cs
@@ -338,4 +338,93 @@ public T MapToApiVersions( IEnumerable apiVersions )
return builder;
}
}
+
+ ///
+ /// Provides extensions for builders that support the introduced-in API version convention.
+ ///
+ /// 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/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..34dedaa7
--- /dev/null
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IntroducedInApiVersionAttribute.cs
@@ -0,0 +1,100 @@
+// 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 . 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
+{
+ ///
+ /// 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 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 bool Equals( object? obj ) =>
+ obj is IntroducedInApiVersionAttribute other &&
+ GetType() == obj.GetType() &&
+ 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/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..5f192238
--- /dev/null
+++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IntroducedInApiVersionAttributeTest.cs
@@ -0,0 +1,108 @@
+// 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 );
+ }
+
+ [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();
+ }
+
+ [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
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..7773c371 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 ),
@@ -69,15 +70,18 @@ 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: mapped,
- supportedVersions: apiModel.SupportedApiVersions,
- deprecatedVersions: apiModel.DeprecatedApiVersions,
+ declaredVersions: effectiveMapped,
+ supportedVersions: supportedVersions,
+ deprecatedVersions: deprecatedVersions,
advertisedVersions: emptyVersions,
deprecatedAdvertisedVersions: emptyVersions );
}
- metadata = new( apiModel, endpointModel, name );
+ metadata = new( apiModel, endpointModel, name, GetIntroducedApiVersionMetadata() );
}
item.ApiVersionMetadata = metadata;
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..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
@@ -88,4 +88,90 @@ 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 ) );
+ }
+
+ [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()
+ {
+ 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/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs
index df8c73f3..497f4cd5 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;
@@ -223,6 +233,7 @@ private static ApiVersionMetadata Build( IList metadata, ApiVersionSet v
ApiVersion[] emptyVersions;
var inheritedSupported = apiModel.SupportedApiVersions;
var inheritedDeprecated = apiModel.DeprecatedApiVersions;
+ var introducedInApiVersions = metadata.OfType().ToArray();
if ( buckets.AreEmpty )
{
@@ -236,10 +247,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 );
}
@@ -248,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 ),
@@ -260,16 +275,64 @@ 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 );
}
}
- return new( apiModel, endpointModel, name );
+ return new( apiModel, endpointModel, name, introducedInApiVersions );
+ }
+
+ private static ApiVersion[]? ExpandIntroducedVersions(
+ IReadOnlyList declaredVersions,
+ IntroducedInApiVersionMetadata[] introducedInApiVersions,
+ IEnumerable? mappedVersions = default )
+ {
+ 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 = mappedVersions is null ? new HashSet() : new HashSet( mappedVersions );
+
+ for ( var i = 0; i < declaredVersions.Count; i++ )
+ {
+ var declaredVersion = declaredVersions[i];
+
+ if ( declaredVersion >= effectiveIntroduced )
+ {
+ versions.Add( declaredVersion );
+ }
+ }
+
+ return [.. versions.OrderBy( v => v )];
}
private record struct ApiVersionBuckets(
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/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/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 137e598e..930ac464 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() );
}
@@ -108,6 +113,8 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList( capacity );
+ var introducedLater = default( Dictionary );
+ var introducedLaterMatches = default( Dictionary );
var source = ApiVersionSource;
var supported = default( SortedSet );
var deprecated = default( SortedSet );
@@ -145,6 +152,23 @@ 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 +247,16 @@ public IReadOnlyList GetEdges( IReadOnlyList endpoints
{
builder.Add( endpoint, version, metadata );
}
+ else if ( IntroducedInApiVersionStatusCode.TryGet(
+ endpoint,
+ metadata,
+ version,
+ Options.UnsupportedApiVersionStatusCode,
+ out var statusCode,
+ out var introducedIn ) )
+ {
+ builder.AddIntroducedLater( endpoint, version, statusCode, introducedIn, metadata );
+ }
}
}
@@ -368,6 +413,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 29c93e3b..dae41ada 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,18 @@ public override int GetDestination( HttpContext httpContext )
return destination;
}
+ if ( rejection.IntroducedLater.TryGetValue( apiVersion, out destination ) )
+ {
+ if ( addedFromUrl )
+ {
+ feature.RawRequestedApiVersion = rawApiVersion;
+ feature.RequestedApiVersion = apiVersion;
+ }
+
+ httpContext.Features.Set( policyFeature );
+ 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 9b594f04..47c78bae 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,35 @@ public Endpoint Build()
return new UnspecifiedApiVersionEndpoint( logger, options, GetDisplayNames() );
}
+ var (introducedInApiVersionStatusCode, introducedIn) = GetIntroducedInApiVersionStatusCode();
+
+ if ( introducedInApiVersionStatusCode > 0 )
+ {
+ return new IntroducedInApiVersionEndpoint( options, introducedInApiVersionStatusCode, introducedIn! );
+ }
+
return new UnsupportedApiVersionEndpoint( options );
}
+ private (int StatusCode, ApiVersion? IntroducedIn) GetIntroducedInApiVersionStatusCode()
+ {
+ var apiVersion = feature.RequestedApiVersion;
+
+ if ( apiVersion is null )
+ {
+ return default;
+ }
+
+ return IntroducedInApiVersionStatusCode.TryGetBest(
+ candidates,
+ apiVersion,
+ options.UnsupportedApiVersionStatusCode,
+ out var statusCode,
+ out var introducedIn )
+ ? (statusCode, introducedIn)
+ : default;
+ }
+
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..632c24f6 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 ApiVersioningOptions options;
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;
+ this.options = options;
keys = new( capacity + 1 );
edges = new( capacity + RejectionEndpointCapacity )
{
@@ -71,7 +73,29 @@ public void Add( RouteEndpoint endpoint, ApiVersion apiVersion, ApiVersionMetada
}
}
+ public void AddIntroducedLater(
+ RouteEndpoint endpoint,
+ ApiVersion apiVersion,
+ int statusCode,
+ ApiVersion introducedIn,
+ ApiVersionMetadata metadata )
+ {
+ if ( statusCode == IntroducedInApiVersionAttribute.UseConfiguredStatusCode )
+ {
+ statusCode = options.UnsupportedApiVersionStatusCode;
+ }
+
+ var key = new EdgeKey( apiVersion, statusCode, introducedIn, metadata, routePatterns );
+
+ Add( ref key, new IntroducedInApiVersionEndpoint( options, statusCode, introducedIn ), 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 +106,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 +118,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..eb34810f 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,8 @@ namespace Asp.Versioning.Routing;
public readonly ApiVersionMetadata Metadata;
public readonly HashSet RoutePatterns;
public readonly EndpointType EndpointType;
+ public readonly int StatusCode;
+ public readonly ApiVersion? IntroducedIn;
private EdgeKey( EndpointType endpointType, HashSet routePatterns )
{
@@ -19,6 +21,8 @@ private EdgeKey( EndpointType endpointType, HashSet routePatterns
Metadata = ApiVersionMetadata.Empty;
RoutePatterns = routePatterns;
EndpointType = endpointType;
+ StatusCode = 0;
+ IntroducedIn = default;
}
internal EdgeKey(
@@ -30,6 +34,23 @@ internal EdgeKey(
Metadata = metadata;
RoutePatterns = routePatterns;
EndpointType = UserDefined;
+ StatusCode = 0;
+ IntroducedIn = default;
+ }
+
+ internal EdgeKey(
+ ApiVersion apiVersion,
+ int statusCode,
+ ApiVersion introducedIn,
+ ApiVersionMetadata metadata,
+ HashSet routePatterns )
+ {
+ ApiVersion = apiVersion;
+ Metadata = metadata;
+ RoutePatterns = routePatterns;
+ EndpointType = IntroducedLater;
+ StatusCode = statusCode;
+ IntroducedIn = introducedIn;
}
internal static EdgeKey Ambiguous => new( EndpointType.Ambiguous, Set.Empty );
@@ -46,7 +67,21 @@ 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 && IntroducedIn == other.IntroducedIn );
+ }
public override bool Equals( object? obj ) => obj is EdgeKey other && Equals( other );
@@ -56,11 +91,17 @@ 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 );
+ result.Add( IntroducedIn );
+ }
+
return result.ToHashCode();
}
@@ -76,6 +117,10 @@ public override string ToString()
{
value = ApiVersion.ToString();
}
+ else if ( EndpointType == IntroducedLater )
+ {
+ value = EndpointType + " " + ApiVersion + " (" + StatusCode + ", " + IntroducedIn + ")";
+ }
else
{
value = EndpointType.ToString();
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/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/IntroducedInApiVersionEndpoint.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs
new file mode 100644
index 00000000..c64f322a
--- /dev/null
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionEndpoint.cs
@@ -0,0 +1,36 @@
+// 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 )
+ { }
+
+ 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
new file mode 100644
index 00000000..e6b87c12
--- /dev/null
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/IntroducedInApiVersionStatusCode.cs
@@ -0,0 +1,198 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Routing;
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing.Matching;
+using System.Diagnostics.CodeAnalysis;
+
+internal static class IntroducedInApiVersionStatusCode
+{
+ internal static bool TryGet(
+ Endpoint endpoint,
+ ApiVersionMetadata metadata,
+ ApiVersion apiVersion,
+ int unsupportedApiVersionStatusCode,
+ 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, out introducedIn ) )
+ {
+ return true;
+ }
+
+ var endpointMetadata = endpoint.Metadata;
+
+ if ( TryGet( endpointMetadata.GetOrderedMetadata(), apiVersion, unsupportedApiVersionStatusCode, out statusCode, out introducedIn ) )
+ {
+ return true;
+ }
+
+ var reflectedIntroduced = GetIntroducedInApiVersions( endpointMetadata );
+
+ 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 )
+ {
+ if ( metadata.IntroducedInApiVersions.Count > 0 ||
+ endpoint.Metadata.GetOrderedMetadata().Count > 0 )
+ {
+ return true;
+ }
+
+ 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,
+ int unsupportedApiVersionStatusCode,
+ out int statusCode,
+ [NotNullWhen( true )] out ApiVersion? introducedIn )
+ {
+ 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 ||
+ ( current.IntroducedIn == matched.IntroducedIn && currentStatusCode < matchedStatusCode ) ) )
+ {
+ matched = current;
+ matchedStatusCode = currentStatusCode;
+ }
+ }
+
+ if ( matched is not null )
+ {
+ statusCode = matchedStatusCode;
+ introducedIn = matched.IntroducedIn;
+ return true;
+ }
+
+ statusCode = 0;
+ introducedIn = default;
+ 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 introducedProvider in method.GetCustomAttributes( inherit: false ).OfType() )
+ {
+ 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/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/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..77a62b05 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 ),
@@ -69,17 +70,29 @@ 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: mapped,
- supportedVersions: inheritedSupported,
- deprecatedVersions: inheritedDeprecated,
+ declaredVersions: effectiveMapped,
+ supportedVersions: supportedVersions,
+ deprecatedVersions: deprecatedVersions,
advertisedVersions: emptyVersions,
deprecatedAdvertisedVersions: emptyVersions );
}
- metadata = new( apiModel, endpointModel, name );
+ metadata = new( apiModel, endpointModel, name, GetIntroducedApiVersionMetadata() );
}
item.AddEndpointMetadata( metadata );
+
+ if ( !metadata.IsApiVersionNeutral )
+ {
+ 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.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs
index 448d8b07..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
@@ -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()
{
@@ -409,6 +439,235 @@ 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_expand_supported_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 )
+ .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.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()
{
@@ -750,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.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
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..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
@@ -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
{
@@ -89,6 +91,557 @@ 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 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()
+ {
+ // 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_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()
+ {
+ // 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 );
+ }
+
+ [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_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()
+ {
+ // 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()
+ {
+ // 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 );
+ }
+
+ [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" )]
+ 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 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 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( 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, 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]
public async Task apply_should_have_candidate_for_matched_api_version()
{
@@ -229,6 +782,99 @@ 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 )
+ {
+ 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( routeTemplate );
+ var builder = new RouteEndpointBuilder( Limbo, routePattern, 0 )
+ {
+ Metadata =
+ {
+ new ApiVersionMetadata( apiModel, endpointModel, introducedInApiVersions: introduced ),
+ },
+ };
+
+ 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 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,
@@ -236,6 +882,155 @@ public async Task apply_should_have_candidate_for_unspecified_api_version()
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 )
+ {
+ return await InvokeJumpTableIntroducedEndpoint( options, [NewIntroducedEndpoint( 404 )] );
+ }
+
+ private static async Task InvokeJumpTableIntroducedEndpoint(
+ ApiVersioningOptions options,
+ RouteEndpoint[] endpoints )
+ {
+ var policy = NewApiVersionMatcherPolicy( 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 );
+ 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 )
+ {
+ return await InvokeApplyIntroducedEndpoint( options, [NewIntroducedEndpoint( 404 )] );
+ }
+
+ private static async Task InvokeApplyIntroducedEndpoint(
+ ApiVersioningOptions options,
+ RouteEndpoint[] endpoints )
+ {
+ var feature = new Mock();
+
+ 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 );
+ var candidates = new CandidateSet(
+ endpoints,
+ endpoints.Select( _ => new RouteValueDictionary() ).ToArray(),
+ endpoints.Select( _ => 0 ).ToArray() );
+ var httpContext = options.ApiVersionReader is UrlSegmentApiVersionReader
+ ? NewUrlSegmentHttpContext( requestedVersion, options )
+ : NewHttpContext( requestedVersion, 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 ) =>
+ 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 );
+
+ 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 '{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,
@@ -282,4 +1077,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/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 3f2a40aa..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;
@@ -114,4 +119,268 @@ 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 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()
+ {
+ // 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 );
+ }
+
+ [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(), [] )
+ {
+ 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 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
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..527296fb
--- /dev/null
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Conventions/IntroducedInApiVersionConventionTest.cs
@@ -0,0 +1,251 @@
+// 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 ) ) );
+ }
+
+ [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 )]
+ [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 );
+ }
+
+ [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();
+ }
+
+ [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();
+ 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();
+ }
+
+
+ [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/ActionApiVersionConventionBuilder.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder.cs
index c72ece92..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.
@@ -50,6 +53,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 +133,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/ActionApiVersionConventionBuilderBase.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilderBase.cs
index 99aa8402..b90646d5 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,28 @@ public abstract partial class ActionApiVersionConventionBuilderBase : ApiVersion
/// A collection of mapped API versions .
protected ICollection MappedVersions => mapped ??= [];
+ ///
+ /// Gets a value indicating whether any action-level API version constraints (mapped or introduced) are configured.
+ ///
+ ///
+ /// 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 );
+
+ ///
+ /// 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.
+ ///
+ /// 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 ??= [];
+
///
/// Gets the controller naming convention associated with the builder.
///
@@ -46,10 +72,123 @@ 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];
+
+ introduced.Sort( static ( left, right ) => left.Version.CompareTo( right.Version ) );
+
+ 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() : new HashSet( mapped );
+
+ var effectiveIntroduced = introduced[0].Version;
+
+ for ( var i = 1; i < introduced.Count; i++ )
+ {
+ if ( introduced[i].Version > effectiveIntroduced )
+ {
+ effectiveIntroduced = introduced[i].Version;
+ }
+ }
+
+ for ( var i = 0; i < declaredVersions.Count; i++ )
+ {
+ var declaredVersion = declaredVersions[i];
+
+ if ( declaredVersion >= effectiveIntroduced )
+ {
+ versions.Add( declaredVersion );
+ }
+ }
+
+ 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
diff --git a/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder{T}.cs b/src/Common/src/Common.Mvc/Conventions/ActionApiVersionConventionBuilder{T}.cs
index 0531a70a..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
@@ -62,6 +63,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 +149,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.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.
///
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 ed3fcbbf..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
@@ -52,5 +88,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