Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
112373d
Add introduced-in API version attribute
xavierjohn May 10, 2026
061b136
Fix introduced-in version routing
xavierjohn May 10, 2026
991b14c
Fix introduced-in attribute equality and docs
xavierjohn May 10, 2026
ead80dd
Align introduced-in routing status selection
xavierjohn May 10, 2026
469e4f5
Define introduced-in convention edge cases
xavierjohn May 10, 2026
94c0814
Demonstrate IntroducedInApiVersion in BasicExample
xavierjohn May 10, 2026
5a80710
Use exact type for introduced version equality
xavierjohn May 10, 2026
658a0f6
Materialize introduced version mappings
xavierjohn May 10, 2026
865f35d
Stabilize introduced-later routing errors
xavierjohn May 10, 2026
aac5a7d
Support introduced minimal API endpoints
xavierjohn May 10, 2026
86b2f1f
Compare edge keys by fields
xavierjohn May 10, 2026
c1207f8
Demonstrate IntroducedInApiVersion in MinimalApiExample
xavierjohn May 10, 2026
0690b02
Demonstrate IntroducedInApiVersion in OpenApiExample
xavierjohn May 10, 2026
29e5d72
Stabilize introduced slow-path status selection
xavierjohn May 10, 2026
9335920
Clarify mapped version constraint docs
xavierjohn May 10, 2026
5ab4956
Write problem details for introduced endpoints
xavierjohn May 10, 2026
b532bf6
Apply introduced-in precedence across candidates
xavierjohn May 10, 2026
db53e29
Drop introduced metadata from neutral endpoints
xavierjohn May 10, 2026
3db2ee1
Populate URL segment version for introduced endpoints
xavierjohn May 10, 2026
ff5ed87
Add introduced-in MVC action conventions
xavierjohn May 10, 2026
0eb7922
Add summary to introduced-in convention extension block
xavierjohn May 10, 2026
6a455a3
Intersect introduced action supported versions
xavierjohn May 10, 2026
1baa005
Restore action convention interface shape
xavierjohn May 10, 2026
bbdb874
Expand minimal API introduced versions
xavierjohn May 10, 2026
513fa4c
Recognize introduced type in ErrorObjectWriter
xavierjohn May 10, 2026
0402e70
Share introduced metadata instances in MVC convention apply
xavierjohn May 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
25 changes: 24 additions & 1 deletion examples/AspNetCore/WebApi/BasicExample/Examples.http
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,34 @@ 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
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ public class ApiVersionMetadata
/// <param name="apiModel">The model for an entire API.</param>
/// <param name="endpointModel">The model defined for a specific API endpoint.</param>
/// <param name="name">The logical name of the API.</param>
public ApiVersionMetadata( ApiVersionModel apiModel, ApiVersionModel endpointModel, string? name = default )
/// <param name="introducedInApiVersions">The introduced API version metadata associated with the endpoint.</param>
public ApiVersionMetadata(
ApiVersionModel apiModel,
ApiVersionModel endpointModel,
string? name = default,
IEnumerable<IntroducedInApiVersionMetadata>? introducedInApiVersions = default )
{
this.apiModel = apiModel;
this.endpointModel = endpointModel;
Name = name ?? string.Empty;
IntroducedInApiVersions = introducedInApiVersions?.ToArray() ?? [];
}

/// <summary>
Expand All @@ -40,6 +46,7 @@ protected ApiVersionMetadata( ApiVersionMetadata other )
endpointModel = other.endpointModel;
mergedModel = other.mergedModel;
Name = other.Name;
IntroducedInApiVersions = other.IntroducedInApiVersions;
}

/// <summary>
Expand All @@ -60,6 +67,12 @@ protected ApiVersionMetadata( ApiVersionMetadata other )
/// <value>The logical name of the API.</value>
public string Name { get; }

/// <summary>
/// Gets the introduced API version metadata associated with the endpoint.
/// </summary>
/// <value>A read-only list of introduced API version metadata.</value>
public IReadOnlyList<IntroducedInApiVersionMetadata> IntroducedInApiVersions { get; }

/// <summary>
/// Gets a value indicating whether the API is version-neutral.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,11 @@ public enum ApiVersionProviderOptions
/// <remarks>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.</remarks>
Mapped = 4,

/// <summary>
/// Indicates the provided API versions describe when an API was introduced.
/// </summary>
/// <remarks>Introduced API versions are expanded into mapped API versions from the controller-declared
/// API version set.</remarks>
Introduced = 8,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

namespace Asp.Versioning;

/// <summary>
/// Defines the behavior of an <see cref="ApiVersion">API version</see> provider that describes when an API was introduced.
/// </summary>
public interface IIntroducedInApiVersionProvider : IApiVersionProvider
{
/// <summary>
/// Gets the HTTP status code returned when the requested API version is earlier than the introduced API version.
/// </summary>
/// <value>The HTTP status code.</value>
int StatusCode { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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

/// <summary>
/// Represents metadata that describes the <see cref="ApiVersion">API version</see> in which an API action was introduced.
/// </summary>
/// <remarks>
/// 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 <see cref="StatusCode"/>. Version-neutral controllers and actions ignore introduced API version
/// metadata because version-neutral endpoints are not constrained to declared API versions.
/// </remarks>
/// <example>
/// A controller that declares API versions 1.0, 2.0, and 3.0 can mark an action with
/// <c>[IntroducedInApiVersion( "2.0" )]</c>. The action is mapped to versions 2.0 and 3.0.
/// A request for version 1.0 is rejected using <see cref="StatusCode"/>. Set
/// <see cref="StatusCode"/> to <see cref="UseConfiguredStatusCode"/> (<c>0</c>) to use the globally configured
/// unsupported API version status code instead.
/// </example>
/// <seealso cref="MapToApiVersionAttribute"/>
[AttributeUsage( Method, AllowMultiple = false, Inherited = false )]
public class IntroducedInApiVersionAttribute : ApiVersionsBaseAttribute, IIntroducedInApiVersionProvider
{
/// <summary>
/// The default HTTP status code used when a requested API version is earlier than the introduced API version.
/// </summary>
public const int DefaultStatusCode = 404;

/// <summary>
/// Indicates that the configured unsupported API version status code should be used.
/// </summary>
public const int UseConfiguredStatusCode = 0;

/// <summary>
/// Initializes a new instance of the <see cref="IntroducedInApiVersionAttribute"/> class.
/// </summary>
/// <param name="version">The <see cref="ApiVersion">API version</see>.</param>
protected IntroducedInApiVersionAttribute( ApiVersion version ) : base( version ) { }

/// <summary>
/// Initializes a new instance of the <see cref="IntroducedInApiVersionAttribute"/> class.
/// </summary>
/// <param name="parser">The parser used to parse the specified versions.</param>
/// <param name="version">The API version string.</param>
protected IntroducedInApiVersionAttribute( IApiVersionParser parser, string version ) : base( parser, version ) { }

/// <summary>
/// Initializes a new instance of the <see cref="IntroducedInApiVersionAttribute"/> class.
/// </summary>
/// <param name="version">A numeric API version.</param>
/// <param name="status">The status associated with the API version, if any.</param>
public IntroducedInApiVersionAttribute( double version, string? status = default )
: base( new ApiVersion( version, status ) ) { }

/// <summary>
/// Initializes a new instance of the <see cref="IntroducedInApiVersionAttribute"/> class.
/// </summary>
/// <param name="year">The version year.</param>
/// <param name="month">The version month.</param>
/// <param name="day">The version day.</param>
/// <param name="status">The status associated with the API version, if any.</param>
public IntroducedInApiVersionAttribute( int year, int month, int day, string? status = default )
: base( new ApiVersion( new DateOnly( year, month, day ), status ) ) { }

/// <summary>
/// Initializes a new instance of the <see cref="IntroducedInApiVersionAttribute"/> class.
/// </summary>
/// <param name="version">The API version string.</param>
public IntroducedInApiVersionAttribute( string version ) : base( version ) { }

ApiVersionProviderOptions IApiVersionProvider.Options => ApiVersionProviderOptions.Introduced;

/// <summary>
/// Gets or sets the HTTP status code returned when the requested API version is earlier than the introduced API version.
/// </summary>
/// <value>The HTTP status code. The default value is <c>404</c>.</value>
/// <remarks>Set the value to <see cref="UseConfiguredStatusCode"/> to use the configured unsupported API version status code.</remarks>
public int StatusCode { get; set; } = DefaultStatusCode;

/// <inheritdoc />
public override bool Equals( object? obj ) =>
obj is IntroducedInApiVersionAttribute other && base.Equals( obj ) && StatusCode == other.StatusCode;

Check warning

Code scanning / CodeQL

Equals should not apply "is" Warning

IntroducedInApiVersionAttribute.Equals(object) should not use "is" on its parameter, as it will not work properly for subclasses of IntroducedInApiVersionAttribute.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine( base.GetHashCode(), StatusCode );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

namespace Asp.Versioning;

/// <summary>
/// Represents endpoint metadata that describes when an API action was introduced.
/// </summary>
public sealed class IntroducedInApiVersionMetadata
{
/// <summary>
/// Initializes a new instance of the <see cref="IntroducedInApiVersionMetadata"/> class.
/// </summary>
/// <param name="introducedIn">The <see cref="ApiVersion">API version</see> in which the API action was introduced.</param>
/// <param name="statusCode">The HTTP status code returned for earlier controller-declared API versions.</param>
public IntroducedInApiVersionMetadata( ApiVersion introducedIn, int statusCode )
{
IntroducedIn = introducedIn;
StatusCode = statusCode;
}

/// <summary>
/// Gets the API version in which the API action was introduced.
/// </summary>
/// <value>The introduced <see cref="ApiVersion">API version</see>.</value>
public ApiVersion IntroducedIn { get; }

/// <summary>
/// Gets the HTTP status code returned for earlier controller-declared API versions.
/// </summary>
/// <value>The HTTP status code.</value>
public int StatusCode { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public virtual void ApplyTo( HttpActionDescriptor item )
IEnumerable<ApiVersion> emptyVersions;
var inheritedSupported = apiModel.SupportedApiVersions;
var inheritedDeprecated = apiModel.DeprecatedApiVersions;
var effectiveMapped = ExpandMappedVersions( apiModel.DeclaredApiVersions );
var noInheritedApiVersions = inheritedSupported.Count == 0 &&
inheritedDeprecated.Count == 0;

Expand All @@ -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 ),
Expand All @@ -70,14 +71,14 @@ public virtual void ApplyTo( HttpActionDescriptor item )
{
emptyVersions = [];
endpointModel = new(
declaredVersions: mapped,
declaredVersions: effectiveMapped,
supportedVersions: apiModel.SupportedApiVersions,
deprecatedVersions: apiModel.DeprecatedApiVersions,
advertisedVersions: emptyVersions,
deprecatedAdvertisedVersions: emptyVersions );
Comment on lines 72 to 81
}

metadata = new( apiModel, endpointModel, name );
metadata = new( apiModel, endpointModel, name, GetIntroducedApiVersionMetadata() );
}

item.ApiVersionMetadata = metadata;
Expand Down
Loading
Loading