Skip to content
Open
Show file tree
Hide file tree
Changes from 18 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
22 changes: 21 additions & 1 deletion examples/AspNetCore/WebApi/MinimalApiExample/Examples.http
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
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
32 changes: 32 additions & 0 deletions examples/AspNetCore/WebApi/MinimalApiExample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 )
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
namespace ApiVersioning.Examples.Controllers;

using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

/// <summary>
/// Demonstrates [MapToApiVersion] (exact-match) vs. [IntroducedInApiVersion]
/// (from-this-version-onward) on a single controller declaring multiple versions.
/// </summary>
/// <remarks>
/// <para>
/// Open the Scalar UI per version (1.0, 2.0, 3.0) to see how the two attributes
/// affect the OpenAPI surface differently:
/// </para>
/// <list type="bullet">
/// <item><description>v1.0 — only the shared <c>GET</c> appears.</description></item>
/// <item><description>
/// v2.0 — shared <c>GET</c>, <c>/legacy</c>, and <c>/modern</c> all appear.
/// </description></item>
/// <item><description>
/// v3.0 — shared <c>GET</c> and <c>/modern</c> appear; <c>/legacy</c> is filtered out
/// because <c>[MapToApiVersion(2.0)]</c> is exact-match. <c>/modern</c> is
/// present without any code change because <c>[IntroducedInApiVersion(2.0)]</c>
/// means "from v2.0 onward against the controller's declared set."
/// </description></item>
/// </list>
/// </remarks>
[ApiVersion( 1.0 )]
[ApiVersion( 2.0 )]
[ApiVersion( 3.0 )]
[Route( "api/[controller]" )]
public class MultiVersionedController : ControllerBase
{
/// <summary>
/// Get the resource (shared across all versions).
/// </summary>
/// <param name="version">The requested API version.</param>
/// <returns>A version-tagged response.</returns>
/// <response code="200">The resource was retrieved.</response>
[HttpGet]
[Produces( "application/json" )]
[ProducesResponseType( typeof( object ), 200 )]
public IActionResult Get( ApiVersion version ) =>
Ok( new { version = version.ToString(), shared = true } );

/// <summary>
/// A v2-only endpoint declared with <c>[MapToApiVersion(2.0)]</c>.
/// </summary>
/// <remarks>
/// Reachable ONLY for v2.0. v1.0 and v3.0 callers receive the configured
/// <c>UnsupportedApiVersionStatusCode</c> (default 400). When v3.0 was
/// added to this controller's <c>[ApiVersion]</c> declarations, this
/// action did NOT automatically participate; if v3.0 should reach it, the
/// attribute must be edited to
/// <c>[MapToApiVersion(2.0, 3.0)]</c>.
/// </remarks>
/// <param name="version">The requested API version.</param>
/// <response code="200">Reached the v2-only endpoint.</response>
[HttpGet( "legacy" ), MapToApiVersion( 2.0 )]
[Produces( "application/json" )]
[ProducesResponseType( typeof( object ), 200 )]
public IActionResult GetLegacy( ApiVersion version ) =>
Ok( new { version = version.ToString(), legacy = true } );

/// <summary>
/// An endpoint introduced in v2.0 declared with <c>[IntroducedInApiVersion(2.0)]</c>.
/// </summary>
/// <remarks>
/// 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
/// <c>[ApiVersion]</c> declarations, this action becomes reachable for
/// v4.0 with no further changes.
/// </remarks>
/// <param name="version">The requested API version.</param>
/// <response code="200">Reached the v2-onwards endpoint.</response>
/// <response code="404">The endpoint did not exist in the requested version.</response>
[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 } );
}
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,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

/// <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 &&
GetType() == obj.GetType() &&
base.Equals( obj ) &&
StatusCode == other.StatusCode;

/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine( base.GetHashCode(), StatusCode );
}
Loading
Loading