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