Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -338,4 +338,93 @@ public T MapToApiVersions( IEnumerable<ApiVersion> apiVersions )
return builder;
}
}

/// <summary>
/// Provides extensions for builders that support the introduced-in API version convention.
/// </summary>
/// <typeparam name="T">The type of <see cref="IIntroducedInApiVersionConventionBuilder"/>.</typeparam>
/// <param name="builder">The extended <see cref="IIntroducedInApiVersionConventionBuilder"/>.</param>
/// <returns>The original <paramref name="builder"/>.</returns>
extension<T>( T builder )
where T : notnull, IIntroducedInApiVersionConventionBuilder
{
/// <summary>
/// Indicates that the configured controller action was introduced in the specified API version.
/// </summary>
/// <param name="majorVersion">The major version number.</param>
/// <param name="minorVersion">The optional minor version number.</param>
/// <param name="status">The optional version status.</param>
/// <param name="statusCode">The HTTP status code for earlier API versions.</param>
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;
}

/// <summary>
/// Indicates that the configured controller action was introduced in the specified API version.
/// </summary>
/// <param name="version">The version number.</param>
/// <param name="status">The optional version status.</param>
/// <param name="statusCode">The HTTP status code for earlier API versions.</param>
public T IntroducedInApiVersion(
double version,
string? status = default,
int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode )
{
builder.IntroducedInApiVersion( new ApiVersion( version, status ), statusCode );
return builder;
}

/// <summary>
/// Indicates that the configured controller action was introduced in the specified API version.
/// </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 optional version status.</param>
/// <param name="statusCode">The HTTP status code for earlier API versions.</param>
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;
}

/// <summary>
/// Indicates that the configured controller action was introduced in the specified API version.
/// </summary>
/// <param name="groupVersion">The group version.</param>
/// <param name="status">The optional version status.</param>
/// <param name="statusCode">The HTTP status code for earlier API versions.</param>
public T IntroducedInApiVersion(
DateOnly groupVersion,
string? status = default,
int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode )
{
builder.IntroducedInApiVersion( new ApiVersion( groupVersion, status ), statusCode );
return builder;
}

/// <summary>
/// Indicates that the configured controller action was introduced in the specified API version.
/// </summary>
/// <param name="apiVersion">The <see cref="ApiVersion">API version</see> the action was introduced in.</param>
/// <param name="statusCode">The HTTP status code for earlier API versions.</param>
public T IntroducedInApiVersion(
ApiVersion apiVersion,
int statusCode = IntroducedInApiVersionAttribute.DefaultStatusCode )
{
builder.IntroducedInApiVersion( apiVersion, statusCode );
return builder;
}
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

namespace Asp.Versioning.Conventions;

/// <summary>
/// Defines the behavior of convention builder that builds introduced API versions.
/// </summary>
public interface IIntroducedInApiVersionConventionBuilder : IMapToApiVersionConventionBuilder
{
/// <summary>
/// Indicates that the configured controller action was introduced in the specified API version.
/// </summary>
/// <param name="apiVersion">The <see cref="ApiVersion">API version</see> the action was introduced in.</param>
/// <param name="statusCode">The HTTP status code for earlier API versions.</param>
void IntroducedInApiVersion( ApiVersion apiVersion, int statusCode );
}
Loading
Loading