Adds API versioning to your classic Web API applications
[](https://www.nuget.org/packages/Asp.Versioning.WebApi)
[](https://www.nuget.org/packages/Asp.Versioning.WebApi)
- [](../../wiki/New-Services-Quick-Start#aspnet-web-api)
+ [](https://dotnet.github.io/aspnet-api-versioning/aspnet/quick-starts/new-services.html#web-api)
[](../../tree/main/examples/AspNet/WebApi)
-* **ASP.NET Web API and OData**
-
Adds API versioning to your Web API applications using OData v4.0
+* **ASP.NET Web API with OData**
+
Adds API versioning to your classic Web API applications using OData v4.0
[](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData)
[](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData)
- [](../../wiki/New-Services-Quick-Start#aspnet-web-api-with-odata-v40)
+ [](https://dotnet.github.io/aspnet-api-versioning/aspnet/quick-starts/new-services.html#odata)
[](../../tree/main/examples/AspNet/OData)
-This is also the home of the ASP.NET API versioning API explorers that you can use to easily document your REST APIs with OpenAPI:
-
-* **ASP.NET Core Versioned API Explorer**
-
Adds additional API explorer support to your ASP.NET Core applications
-
- [](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer)
- [](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer)
- [](../../wiki/API-Documentation#aspnet-core)
- [](../../tree/main/examples/AspNetCore/WebApi/OpenApiSample)
+This is also the home of the ASP.NET API versioning API explorers that you can use to easily document your APIs with OpenAPI:
* **ASP.NET Core Versioned OpenAPI**
Adds additional OpenAPI support to your ASP.NET Core applications
[](https://www.nuget.org/packages/Asp.Versioning.OpenApi)
[](https://www.nuget.org/packages/Asp.Versioning.OpenApi)
- [](../../wiki/API-Documentation#aspnet-core)
+
+* **ASP.NET Core Versioned API Explorer and OpenAPI**
+
Adds additional API explorer and OpenAPI support to your ASP.NET Core applications
+
+ [](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer)
+ [](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer)
+ [](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/docs/overview.html#minimal-api-or-mvc-core)
[](../../tree/main/examples/AspNetCore/WebApi/OpenApiSample)
+* **ASP.NET Core Versioned API Explorer and OpenAPI with gRPC**
+
Adds API versioning to your ASP.NET Core gRPC applications
+
+ [](https://www.nuget.org/packages/Asp.Versioning.Grpc.ApiExplorer)
+ [](https://www.nuget.org/packages/Asp.Versioning.Grpc.ApiExplorer)
+ [](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/docs/overview.html#grpc)
+ [](../../tree/main/examples/AspNetCore/WebApi/GrpcOpenApiExample)
+
* **ASP.NET Core with OData API Explorer**
Adds additional API explorer support to your ASP.NET Core applications using OData v4.0
[](https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer)
[](https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer)
- [](../../wiki/API-Documentation#aspnet-core-with-odata)
+ [](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/docs/overview.html#odata)
[](../../tree/main/examples/AspNetCore/OData/OpenApiODataSample)
* **ASP.NET Web API Versioned API Explorer**
-
Replaces the default API explorer in your Web API applications
+
Replaces the default API explorer in your classic Web API applications
[](https://www.nuget.org/packages/Asp.Versioning.WebApi.ApiExplorer)
[](https://www.nuget.org/packages/Asp.Versioning.WebApi.ApiExplorer)
- [](../../wiki/API-Documentation#aspnet-web-api)
+ [](https://dotnet.github.io/aspnet-api-versioning/aspnet/docs/overview.html#web-api)
[](../../tree/main/examples/AspNet/WebApi/OpenApiWebApiSample)
* **ASP.NET Web API with OData API Explorer**
-
Adds an API explorer to your Web API applications using OData v4.0
+
Adds an API explorer to your classic Web API applications using OData v4.0
[](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData.ApiExplorer)
[](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData.ApiExplorer)
- [](../../wiki/API-Documentation#aspnet-web-api-with-odata)
+ [](https://dotnet.github.io/aspnet-api-versioning/aspnet/docs/overview.html#odata)
[](../../tree/main/examples/AspNet/OData/OpenApiODataWebApiSample)
The client-side libraries make it simple to create API version-aware HTTP clients.
@@ -109,11 +123,11 @@ The client-side libraries make it simple to create API version-aware HTTP client
[](https://www.nuget.org/packages/Asp.Versioning.Http.Client)
[](https://www.nuget.org/packages/Asp.Versioning.Http.Client)
- [](../../wiki/API-Documentation#http-client)
+ [](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/ext/clients.html)
## Documentation
-You can find additional examples, documentation, and getting started instructions in the [wiki](../../wiki).
+You can find additional examples, documentation, and getting started instructions in the [wiki](https://dotnet.github.io/aspnet-api-versioning).
## Discussion
diff --git a/asp.slnx b/asp.slnx
index 38d55b81d..ce62c5a21 100644
--- a/asp.slnx
+++ b/asp.slnx
@@ -21,6 +21,7 @@
+
@@ -96,6 +97,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -115,6 +129,7 @@
+
@@ -155,4 +170,5 @@
+
diff --git a/build/analyzers.targets b/build/analyzers.targets
new file mode 100644
index 000000000..b0d54f989
--- /dev/null
+++ b/build/analyzers.targets
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/nuget.props b/build/nuget.props
index 066bd7bf1..2ff2ada22 100644
--- a/build/nuget.props
+++ b/build/nuget.props
@@ -10,11 +10,12 @@
trueMIT$(MSBuildThisFileDirectory)..\bin
- README.md
+
+ README.mdNU5118;$(NoWarn)
-
+
@@ -25,9 +26,7 @@
-
- true
- snupkg
+ truetrue
@@ -35,7 +34,15 @@
true
-
+
+
+ true
+ snupkg
+
+
+
diff --git a/build/nuget.targets b/build/nuget.targets
index 891ec5af7..456b36a06 100644
--- a/build/nuget.targets
+++ b/build/nuget.targets
@@ -1,19 +1,46 @@
-
- $(MSBuildProjectDirectory)\README.md
- $(BaseIntermediateOutputPath)\README.md
-
+
+
+
+
+
+
+
+
-
- $(TargetsForTfmSpecificContentInPackage);IncludeNuGetReadme
-
+
+
+
+
-
+
-
+
+
+
-
+ $(MSBuildProjectDirectory)\ReleaseNotes.txt
- $(PackageVersion.Substring(0,$(PackageVersion.LastIndexOf('.')))).0
- $(PackageVersion)
- https://github.com/dotnet/aspnet-api-versioning/releases/tag/v$(GitHubReleaseTag)
+
+ | :mega: $(ReadmeBanner) |
|-|
-
-
- | :mega: $(ReadmeBanner) |
-|-|
-
-$([System.IO.File]::ReadAllText('$(SourcePackageReadmeFile)'))
-
-
-
-
-
-
-
-
-
-
-
+
+ $(PackageVersion.Substring(0,$(PackageVersion.LastIndexOf('.')))).0
+ $(PackageVersion)
+ https://github.com/dotnet/aspnet-api-versioning/releases/tag/v$(GitHubReleaseTag)
+
-
-
+
+ | :mega: $(ReadmeBanner) |
|-|
+ $(PackageReleaseNotes)@(ReleaseNotes->'- %(Identity)','
')
+
diff --git a/examples/AspNetCore/WebApi/GrpcOpenApiExample/GrpcOpenApiExample.csproj b/examples/AspNetCore/WebApi/GrpcOpenApiExample/GrpcOpenApiExample.csproj
index d9d56da37..af2c9e768 100644
--- a/examples/AspNetCore/WebApi/GrpcOpenApiExample/GrpcOpenApiExample.csproj
+++ b/examples/AspNetCore/WebApi/GrpcOpenApiExample/GrpcOpenApiExample.csproj
@@ -22,6 +22,7 @@
+
@@ -30,11 +31,11 @@
true
-
+
- ..\..\..\..\src\AspNetCore\WebApi\src\Asp.Versioning.Grpc.ApiExplorer\protos
+ ..\..\..\..\src\AspNetCore\WebApi\src\Asp.Versioning.Grpc\protos
diff --git a/examples/AspNetCore/WebApi/GrpcOpenApiExample/Program.cs b/examples/AspNetCore/WebApi/GrpcOpenApiExample/Program.cs
index 7c5d162c6..6c0aa3624 100644
--- a/examples/AspNetCore/WebApi/GrpcOpenApiExample/Program.cs
+++ b/examples/AspNetCore/WebApi/GrpcOpenApiExample/Program.cs
@@ -8,10 +8,12 @@
var builder = WebApplication.CreateBuilder( args );
var services = builder.Services;
+// only required because controllers are mixed in
services.AddControllers();
services.AddProblemDetails();
+
services.AddApiVersioning()
- .AddMvc()
+ .AddMvc() // only required because controllers are mixed in
.AddApiExplorer(
options =>
{
@@ -23,20 +25,30 @@
// can also be used to control the format of the API version in route templates
options.SubstituteApiVersionInUrl = true;
} )
+ .AddGrpc()
+ .AddGrpcApiExplorer()
.AddOpenApi( options => options.Document.AddScalarTransformers() );
-services.AddGrpc().AddJsonTranscoding();
-services.AddGrpcApiExplorer();
-
var app = builder.Build();
var orders = app.NewVersionedApi( "Orders" );
var people = app.NewVersionedApi( "People" );
var greeter = app.NewVersionedApi( "Greeter" );
-orders.MapGrpcService().HasApiVersion( 1.0 ).HasApiVersion( 2.0 ).HasApiVersion( 3.0 );
+// single implementation versioned by query string, but with different transcoded fields
+orders.MapGrpcService()
+ .HasApiVersion( 1.0 )
+ .HasApiVersion( 2.0 )
+ .HasApiVersion( 3.0 );
+
+// split implementations, where 2.0 is a normal controller
greeter.MapGrpcService().HasApiVersion( 1.0 );
greeter.MapGrpcService().HasApiVersion( 3.0 );
-people.MapGrpcService().HasApiVersion( 1.0 ).HasApiVersion( 2.0 ).HasApiVersion( 3.0 );
+
+// single implementation versioned by url, but with different transcoded fields
+people.MapGrpcService()
+ .HasApiVersion( 1.0 )
+ .HasApiVersion( 2.0 )
+ .HasApiVersion( 3.0 );
if ( app.Environment.IsDevelopment() )
{
@@ -49,11 +61,14 @@
for ( var i = 0; i < descriptions.Count; i++ )
{
var description = descriptions[i];
+ var isDefault = i == descriptions.Count - 1;
- options.AddDocument( description.GroupName, description.GroupName );
+ options.AddDocument( description.GroupName, description.GroupName, isDefault: isDefault );
}
} );
}
+// only required because controllers are mixed in
app.MapControllers();
+
app.Run();
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/greet.proto b/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/greet.proto
index 28ff816d2..993bc1db0 100644
--- a/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/greet.proto
+++ b/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/greet.proto
@@ -8,7 +8,7 @@ package greet;
// Requests a hello greeting
message HelloRequest {
// Gets or sets the requested API version
- string api_version = 1 [json_name = "api-version"];
+ string api_version = 1 [json_name = "api-version"]; // required because the url method is also used
// Gets or sets the name of the person to greet
string name = 2;
diff --git a/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/orders.proto b/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/orders.proto
index 4469ea138..85d58f0f3 100644
--- a/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/orders.proto
+++ b/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/orders.proto
@@ -49,22 +49,16 @@ message Order {
}
message OrderIdRequest {
- // Gets or sets the requested API version
- string api_version = 1 [json_name = "api-version"];
-
// Gets or sets the requested order identifier
- int32 id = 2;
+ int32 id = 1;
}
message OrderRequest {
- // Gets or sets the requested API version
- string api_version = 1 [json_name = "api-version"];
-
// Gets or sets the requested order identifier
- int32 id = 2;
+ int32 id = 1;
// Gets or sets the order
- Order order = 3;
+ Order order = 2;
}
message OrderReply {
diff --git a/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/people.proto b/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/people.proto
index 6fe22f57a..f3dc55358 100644
--- a/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/people.proto
+++ b/examples/AspNetCore/WebApi/GrpcOpenApiExample/Protos/people.proto
@@ -46,6 +46,9 @@ message Person {
string phone = 7 [(asp.api.version) = "3.0"];
}
+// note: 'api_version' is required as a placeholder to identify where the api version route segment is
+// even though it likely never actually be used
+
message PeopleRequest {
// Gets or sets the requested API version
string api_version = 1;
diff --git a/examples/AspNetCore/WebApi/GrpcOpenApiExample/Services/V1/GreeterService.cs b/examples/AspNetCore/WebApi/GrpcOpenApiExample/Services/V1/GreeterService.cs
index 9f030da31..85e7ccaa2 100644
--- a/examples/AspNetCore/WebApi/GrpcOpenApiExample/Services/V1/GreeterService.cs
+++ b/examples/AspNetCore/WebApi/GrpcOpenApiExample/Services/V1/GreeterService.cs
@@ -14,6 +14,9 @@ public class GreeterService : Greeter.GreeterBase
///
///
/// A user-specific greeting
- public override Task SayHello( HelloRequest request, ServerCallContext context ) =>
- Task.FromResult( new HelloReply { Message = $"Hello {request.Name} (v{request.ApiVersion})" } );
+ public override Task SayHello( HelloRequest request, ServerCallContext context )
+ {
+ var apiVersion = context.GetHttpContext().ApiVersioningFeature.RawRequestedApiVersion;
+ return Task.FromResult( new HelloReply { Message = $"Hello {request.Name} (v{apiVersion})" } );
+ }
}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V2/Models/Order.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/Order.cs
similarity index 90%
rename from examples/AspNetCore/WebApi/OpenApiExample/V2/Models/Order.cs
rename to examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/Order.cs
index 12c8ca00f..61171534c 100644
--- a/examples/AspNetCore/WebApi/OpenApiExample/V2/Models/Order.cs
+++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/Order.cs
@@ -1,5 +1,6 @@
-namespace ApiVersioning.Examples.V2.Models;
+namespace ApiVersioning.Examples.Models;
+using Asp.Versioning;
using System.ComponentModel.DataAnnotations;
///
@@ -23,6 +24,7 @@ public class Order
/// Gets or sets the date and time when the order becomes effective.
///
/// The order's effective date.
+ [VisibleInApiVersion( "2.0" )]
public DateTimeOffset EffectiveDate { get; set; } = DateTimeOffset.Now;
///
diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V3/Person.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/Person.cs
similarity index 89%
rename from examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V3/Person.cs
rename to examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/Person.cs
index ca9f4629b..e28df3883 100644
--- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V3/Person.cs
+++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/Person.cs
@@ -1,5 +1,6 @@
-namespace ApiVersioning.Examples.Models.V3;
+namespace ApiVersioning.Examples.Models;
+using Asp.Versioning;
using System.ComponentModel.DataAnnotations;
///
@@ -33,11 +34,13 @@ public class Person
/// Gets or sets the email address for a person.
///
/// The person's email address.
+ [VisibleInApiVersion( "2.0" )]
public string Email { get; set; }
///
/// Gets or sets the telephone number for a person.
///
/// The person's telephone number.
+ [VisibleInApiVersion( "3.0" )]
public string Phone { get; set; }
}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V1/Order.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V1/Order.cs
deleted file mode 100644
index 5797afc27..000000000
--- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V1/Order.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-namespace ApiVersioning.Examples.Models.V1;
-
-using System.ComponentModel.DataAnnotations;
-
-///
-/// Represents an order.
-///
-public class Order
-{
- ///
- /// Gets or sets the unique identifier for the order.
- ///
- /// The order's unique identifier.
- public int Id { get; set; }
-
- ///
- /// Gets or sets the date and time when the order was created.
- ///
- /// The order's creation date.
- public DateTimeOffset CreatedDate { get; set; } = DateTimeOffset.Now;
-
- ///
- /// Gets or sets the name of the ordering customer.
- ///
- /// The name of the customer that placed the order.
- [Required]
- public string Customer { get; set; }
-}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V1/Person.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V1/Person.cs
deleted file mode 100644
index 816752156..000000000
--- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V1/Person.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-namespace ApiVersioning.Examples.Models.V1;
-
-using System.ComponentModel.DataAnnotations;
-
-///
-/// Represents a person.
-///
-public class Person
-{
- ///
- /// Gets or sets the unique identifier for a person.
- ///
- /// The person's unique identifier.
- public int Id { get; set; }
-
- ///
- /// Gets or sets the first name of a person.
- ///
- /// The person's first name.
- [Required]
- [StringLength( 25 )]
- public string FirstName { get; set; }
-
- ///
- /// Gets or sets the last name of a person.
- ///
- /// The person's last name.
- [Required]
- [StringLength( 25 )]
- public string LastName { get; set; }
-}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V2/Order.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V2/Order.cs
deleted file mode 100644
index 4532c1479..000000000
--- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V2/Order.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-namespace ApiVersioning.Examples.Models.V2;
-
-using System.ComponentModel.DataAnnotations;
-
-///
-/// Represents an order.
-///
-public class Order
-{
- ///
- /// Gets or sets the unique identifier for the order.
- ///
- /// The order's unique identifier.
- public int Id { get; set; }
-
- ///
- /// Gets or sets the date and time when the order was created.
- ///
- /// The order's creation date.
- public DateTimeOffset CreatedDate { get; set; } = DateTimeOffset.Now;
-
- ///
- /// Gets or sets the date and time when the order becomes effective.
- ///
- /// The order's effective date.
- public DateTimeOffset EffectiveDate { get; set; } = DateTimeOffset.Now;
-
- ///
- /// Gets or sets the name of the ordering customer.
- ///
- /// The name of the customer that placed the order.
- [Required]
- public string Customer { get; set; }
-}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V2/Person.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V2/Person.cs
deleted file mode 100644
index cb9838048..000000000
--- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V2/Person.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-namespace ApiVersioning.Examples.Models.V2;
-
-using System.ComponentModel.DataAnnotations;
-
-///
-/// Represents a person.
-///
-public class Person
-{
- ///
- /// Gets or sets the unique identifier for a person.
- ///
- /// The person's unique identifier.
- public int Id { get; set; }
-
- ///
- /// Gets or sets the first name of a person.
- ///
- /// The person's first name.
- [Required]
- [StringLength( 25 )]
- public string FirstName { get; set; }
-
- ///
- /// Gets or sets the last name of a person.
- ///
- /// The person's last name.
- [Required]
- [StringLength( 25 )]
- public string LastName { get; set; }
-
- ///
- /// Gets or sets the email address for a person.
- ///
- /// The person's email address.
- public string Email { get; set; }
-}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs
index 65dc596e7..635a8f55c 100644
--- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs
+++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs
@@ -1,6 +1,6 @@
+using ApiVersioning.Examples.Services;
using Asp.Versioning;
using Scalar.AspNetCore;
-using ApiVersioning.Examples.Services;
using System.Reflection;
[assembly: AssemblyDescription( "An example API" )]
diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Services/Orders.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Services/Orders.cs
index 9d30085ac..b9556c974 100644
--- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Services/Orders.cs
+++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Services/Orders.cs
@@ -1,6 +1,7 @@
-using Asp.Versioning;
+namespace ApiVersioning.Examples.Services;
-namespace ApiVersioning.Examples.Services;
+using ApiVersioning.Examples.Models;
+using Asp.Versioning;
///
/// Provides the endpoint extensions for the Orders service.
@@ -13,9 +14,9 @@ public static class Orders
/// Maps the Orders APIs for 1.0.
///
/// The next builder.
- public VersionedApiBuilder ToV1()
+ public VersionedApiBuilder ToV1()
{
- var orders = new VersionedApiBuilder( apiBuilder.Endpoints );
+ var orders = new VersionedApiBuilder( apiBuilder.Endpoints );
var builder = orders.Endpoints;
var api = builder.MapGroup( "/api/orders" )
.HasDeprecatedApiVersion( 0.9 )
@@ -42,13 +43,13 @@ public static class Orders
}
}
- extension( VersionedApiBuilder orders )
+ extension( VersionedApiBuilder orders )
{
///
/// Maps the Orders APIs for 2.0.
///
/// The next builder.
- public VersionedApiBuilder ToV2()
+ public VersionedApiBuilder ToV2()
{
var builder = orders.Endpoints;
var api = builder.MapGroup( "/api/orders" )
@@ -77,7 +78,7 @@ public static class Orders
}
}
- extension( VersionedApiBuilder orders )
+ extension( VersionedApiBuilder orders )
{
///
/// Maps the Orders APIs for 3.0.
@@ -118,7 +119,7 @@ public static class V1
/// The requested order.
/// The order was successfully retrieved.
/// The order does not exist.
- public static Models.V1.Order Get( int id ) => new() { Id = id, Customer = "John Doe" };
+ public static Order Get( int id ) => new() { Id = id, Customer = "John Doe" };
///
/// Place Order
@@ -129,7 +130,7 @@ public static class V1
/// The created order.
/// The order was successfully placed.
/// The order is invalid.
- public static IResult Post( HttpRequest request, Models.V1.Order order )
+ public static IResult Post( HttpRequest request, Order order )
{
order.Id = 42;
var scheme = request.Scheme;
@@ -148,7 +149,7 @@ public static IResult Post( HttpRequest request, Models.V1.Order order )
/// The order was successfully updated.
/// The order is invalid.
/// The order does not exist.
- public static IResult Patch( int id, Models.V1.Order order ) => Results.NoContent();
+ public static IResult Patch( int id, Order order ) => Results.NoContent();
}
///
@@ -163,7 +164,7 @@ public static class V2
///
/// All available orders.
/// The successfully retrieved orders.
- public static Models.V2.Order[] GetAll( ApiVersion version ) =>
+ public static Order[] GetAll( ApiVersion version ) =>
[
new (){ Id = 1, Customer = "John Doe" },
new (){ Id = 2, Customer = "Bob Smith" },
@@ -179,7 +180,7 @@ public static Models.V2.Order[] GetAll( ApiVersion version ) =>
/// The requested order.
/// The order was successfully retrieved.
/// The order does not exist.
- public static Models.V2.Order GetById( int id, ApiVersion version ) => new() { Id = id, Customer = "John Doe" };
+ public static Order GetById( int id, ApiVersion version ) => new() { Id = id, Customer = "John Doe" };
///
/// Place Order
@@ -190,7 +191,7 @@ public static Models.V2.Order[] GetAll( ApiVersion version ) =>
/// The created order.
/// The order was successfully placed.
/// The order is invalid.
- public static IResult Post( HttpRequest request, Models.V2.Order order )
+ public static IResult Post( HttpRequest request, Order order )
{
order.Id = 42;
var scheme = request.Scheme;
@@ -209,7 +210,7 @@ public static IResult Post( HttpRequest request, Models.V2.Order order )
/// The order was successfully updated.
/// The order is invalid.
/// The order does not exist.
- public static IResult Patch( int id, Models.V2.Order order ) => Results.NoContent();
+ public static IResult Patch( int id, Order order ) => Results.NoContent();
}
///
@@ -223,7 +224,7 @@ public static class V3
/// Retrieves all orders.
/// All available orders.
/// The successfully retrieved orders.
- public static Models.V3.Order[] GetAll() =>
+ public static Order[] GetAll() =>
[
new (){ Id = 1, Customer = "John Doe" },
new (){ Id = 2, Customer = "Bob Smith" },
@@ -238,7 +239,7 @@ public static Models.V3.Order[] GetAll() =>
/// The requested order.
/// The order was successfully retrieved.
/// The order does not exist.
- public static Models.V3.Order GetById( int id ) => new() { Id = id, Customer = "John Doe" };
+ public static Order GetById( int id ) => new() { Id = id, Customer = "John Doe" };
///
/// Place Order
@@ -249,7 +250,7 @@ public static Models.V3.Order[] GetAll() =>
/// The created order.
/// The order was successfully placed.
/// The order is invalid.
- public static IResult Post( HttpRequest request, Models.V3.Order order )
+ public static IResult Post( HttpRequest request, Order order )
{
order.Id = 42;
var scheme = request.Scheme;
diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Services/People.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Services/People.cs
index 816bb5de4..049a1ac89 100644
--- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Services/People.cs
+++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Services/People.cs
@@ -1,5 +1,6 @@
namespace ApiVersioning.Examples.Services;
+using ApiVersioning.Examples.Models;
using Asp.Versioning;
///
@@ -13,9 +14,9 @@ public static class People
/// Maps the People APIs for 1.0.
///
/// The next builder.
- public VersionedApiBuilder ToV1()
+ public VersionedApiBuilder ToV1()
{
- var people = new VersionedApiBuilder( apiBuilder.Endpoints );
+ var people = new VersionedApiBuilder( apiBuilder.Endpoints );
var builder = people.Endpoints;
var api = builder.MapGroup( "/api/v{version:apiVersion}/people" )
.HasDeprecatedApiVersion( 0.9 )
@@ -29,13 +30,13 @@ public static class People
}
}
- extension( VersionedApiBuilder people )
+ extension( VersionedApiBuilder people )
{
///
/// Maps the People APIs for 2.0.
///
/// The next builder.
- public VersionedApiBuilder ToV2()
+ public VersionedApiBuilder ToV2()
{
var builder = people.Endpoints;
var api = builder.MapGroup( "/api/v{version:apiVersion}/people" )
@@ -54,7 +55,7 @@ public static class People
}
}
- extension( VersionedApiBuilder people )
+ extension( VersionedApiBuilder people )
{
///
/// Maps the Person APIs for 3.0.
@@ -73,8 +74,8 @@ public void ToV3()
.Produces( 404 );
api.MapPost( "/", V3.Post )
- .Accepts( "application/json" )
- .Produces( 201 )
+ .Accepts( "application/json" )
+ .Produces( 201 )
.Produces( 400 );
}
}
@@ -92,7 +93,7 @@ public static class V1
/// The requested person.
/// The person was successfully retrieved.
/// The person does not exist.
- public static Models.V1.Person Get( int id ) => new()
+ public static Person Get( int id ) => new()
{
Id = id,
FirstName = "John",
@@ -111,7 +112,7 @@ public static class V2
/// Gets all people.
/// All available people.
/// The successfully retrieved people.
- public static Models.V2.Person[] GetAll() =>
+ public static Person[] GetAll() =>
[
new()
{
@@ -144,7 +145,7 @@ public static Models.V2.Person[] GetAll() =>
/// The requested person.
/// The person was successfully retrieved.
/// The person does not exist.
- public static Models.V2.Person GetById( int id ) => new()
+ public static Person GetById( int id ) => new()
{
Id = id,
FirstName = "John",
@@ -164,7 +165,7 @@ public static class V3
/// Gets all people.
/// All available people.
/// The successfully retrieved people.
- public static Models.V3.Person[] GetAll() =>
+ public static Person[] GetAll() =>
[
new()
{
@@ -200,7 +201,7 @@ public static Models.V3.Person[] GetAll() =>
/// The requested person.
/// The person was successfully retrieved.
/// The person does not exist.
- public static Models.V3.Person GetById( int id ) => new()
+ public static Person GetById( int id ) => new()
{
Id = id,
FirstName = "John",
@@ -219,7 +220,7 @@ public static Models.V3.Person[] GetAll() =>
/// The created person.
/// The person was successfully created.
/// The person was invalid.
- public static IResult Post( HttpRequest request, ApiVersion version, Models.V3.Person person )
+ public static IResult Post( HttpRequest request, ApiVersion version, Person person )
{
person.Id = 42;
var scheme = request.Scheme;
diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V3/Order.cs b/examples/AspNetCore/WebApi/OpenApiExample/Models/Order.cs
similarity index 90%
rename from examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V3/Order.cs
rename to examples/AspNetCore/WebApi/OpenApiExample/Models/Order.cs
index 4e72a1365..61171534c 100644
--- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Models/V3/Order.cs
+++ b/examples/AspNetCore/WebApi/OpenApiExample/Models/Order.cs
@@ -1,5 +1,6 @@
-namespace ApiVersioning.Examples.Models.V3;
+namespace ApiVersioning.Examples.Models;
+using Asp.Versioning;
using System.ComponentModel.DataAnnotations;
///
@@ -23,6 +24,7 @@ public class Order
/// Gets or sets the date and time when the order becomes effective.
///
/// The order's effective date.
+ [VisibleInApiVersion( "2.0" )]
public DateTimeOffset EffectiveDate { get; set; } = DateTimeOffset.Now;
///
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V3/Models/Person.cs b/examples/AspNetCore/WebApi/OpenApiExample/Models/Person.cs
similarity index 89%
rename from examples/AspNetCore/WebApi/OpenApiExample/V3/Models/Person.cs
rename to examples/AspNetCore/WebApi/OpenApiExample/Models/Person.cs
index 29d7a20cd..e28df3883 100644
--- a/examples/AspNetCore/WebApi/OpenApiExample/V3/Models/Person.cs
+++ b/examples/AspNetCore/WebApi/OpenApiExample/Models/Person.cs
@@ -1,5 +1,6 @@
-namespace ApiVersioning.Examples.V3.Models;
+namespace ApiVersioning.Examples.Models;
+using Asp.Versioning;
using System.ComponentModel.DataAnnotations;
///
@@ -33,11 +34,13 @@ public class Person
/// Gets or sets the email address for a person.
///
/// The person's email address.
+ [VisibleInApiVersion( "2.0" )]
public string Email { get; set; }
///
/// Gets or sets the telephone number for a person.
///
/// The person's telephone number.
+ [VisibleInApiVersion( "3.0" )]
public string Phone { get; set; }
}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V1/Models/Order.cs b/examples/AspNetCore/WebApi/OpenApiExample/V1/Models/Order.cs
deleted file mode 100644
index 1718eff42..000000000
--- a/examples/AspNetCore/WebApi/OpenApiExample/V1/Models/Order.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-namespace ApiVersioning.Examples.V1.Models;
-
-using System.ComponentModel.DataAnnotations;
-
-///
-/// Represents an order.
-///
-public class Order
-{
- ///
- /// Gets or sets the unique identifier for the order.
- ///
- /// The order's unique identifier.
- public int Id { get; set; }
-
- ///
- /// Gets or sets the date and time when the order was created.
- ///
- /// The order's creation date.
- public DateTimeOffset CreatedDate { get; set; } = DateTimeOffset.Now;
-
- ///
- /// Gets or sets the name of the ordering customer.
- ///
- /// The name of the customer that placed the order.
- [Required]
- public string Customer { get; set; }
-}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V1/Models/Person.cs b/examples/AspNetCore/WebApi/OpenApiExample/V1/Models/Person.cs
deleted file mode 100644
index e9cde705b..000000000
--- a/examples/AspNetCore/WebApi/OpenApiExample/V1/Models/Person.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-namespace ApiVersioning.Examples.V1.Models;
-
-using System.ComponentModel.DataAnnotations;
-
-///
-/// Represents a person.
-///
-public class Person
-{
- ///
- /// Gets or sets the unique identifier for a person.
- ///
- /// The person's unique identifier.
- public int Id { get; set; }
-
- ///
- /// Gets or sets the first name of a person.
- ///
- /// The person's first name.
- [Required]
- [StringLength( 25 )]
- public string FirstName { get; set; }
-
- ///
- /// Gets or sets the last name of a person.
- ///
- /// The person's last name.
- [Required]
- [StringLength( 25 )]
- public string LastName { get; set; }
-}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V1/Controllers/OrdersController.cs b/examples/AspNetCore/WebApi/OpenApiExample/V1/OrdersController.cs
similarity index 96%
rename from examples/AspNetCore/WebApi/OpenApiExample/V1/Controllers/OrdersController.cs
rename to examples/AspNetCore/WebApi/OpenApiExample/V1/OrdersController.cs
index 8ef178299..7115396ab 100644
--- a/examples/AspNetCore/WebApi/OpenApiExample/V1/Controllers/OrdersController.cs
+++ b/examples/AspNetCore/WebApi/OpenApiExample/V1/OrdersController.cs
@@ -1,6 +1,6 @@
-namespace ApiVersioning.Examples.V1.Controllers;
+namespace ApiVersioning.Examples.V1;
-using ApiVersioning.Examples.V1.Models;
+using ApiVersioning.Examples.Models;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V1/Controllers/PeopleController.cs b/examples/AspNetCore/WebApi/OpenApiExample/V1/PeopleController.cs
similarity index 91%
rename from examples/AspNetCore/WebApi/OpenApiExample/V1/Controllers/PeopleController.cs
rename to examples/AspNetCore/WebApi/OpenApiExample/V1/PeopleController.cs
index 026884566..13e863b8b 100644
--- a/examples/AspNetCore/WebApi/OpenApiExample/V1/Controllers/PeopleController.cs
+++ b/examples/AspNetCore/WebApi/OpenApiExample/V1/PeopleController.cs
@@ -1,6 +1,6 @@
-namespace ApiVersioning.Examples.V1.Controllers;
+namespace ApiVersioning.Examples.V1;
-using ApiVersioning.Examples.V1.Models;
+using ApiVersioning.Examples.Models;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V2/Models/Person.cs b/examples/AspNetCore/WebApi/OpenApiExample/V2/Models/Person.cs
deleted file mode 100644
index 6fa5fa90a..000000000
--- a/examples/AspNetCore/WebApi/OpenApiExample/V2/Models/Person.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-namespace ApiVersioning.Examples.V2.Models;
-
-using System.ComponentModel.DataAnnotations;
-
-///
-/// Represents a person.
-///
-public class Person
-{
- ///
- /// Gets or sets the unique identifier for a person.
- ///
- /// The person's unique identifier.
- public int Id { get; set; }
-
- ///
- /// Gets or sets the first name of a person.
- ///
- /// The person's first name.
- [Required]
- [StringLength( 25 )]
- public string FirstName { get; set; }
-
- ///
- /// Gets or sets the last name of a person.
- ///
- /// The person's last name.
- [Required]
- [StringLength( 25 )]
- public string LastName { get; set; }
-
- ///
- /// Gets or sets the email address for a person.
- ///
- /// The person's email address.
- public string Email { get; set; }
-}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V2/Controllers/OrdersController.cs b/examples/AspNetCore/WebApi/OpenApiExample/V2/OrdersController.cs
similarity index 97%
rename from examples/AspNetCore/WebApi/OpenApiExample/V2/Controllers/OrdersController.cs
rename to examples/AspNetCore/WebApi/OpenApiExample/V2/OrdersController.cs
index 06a512f76..93516e6c6 100644
--- a/examples/AspNetCore/WebApi/OpenApiExample/V2/Controllers/OrdersController.cs
+++ b/examples/AspNetCore/WebApi/OpenApiExample/V2/OrdersController.cs
@@ -1,6 +1,6 @@
-namespace ApiVersioning.Examples.V2.Controllers;
+namespace ApiVersioning.Examples.V2;
-using ApiVersioning.Examples.V2.Models;
+using ApiVersioning.Examples.Models;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V2/Controllers/PeopleController.cs b/examples/AspNetCore/WebApi/OpenApiExample/V2/PeopleController.cs
similarity index 95%
rename from examples/AspNetCore/WebApi/OpenApiExample/V2/Controllers/PeopleController.cs
rename to examples/AspNetCore/WebApi/OpenApiExample/V2/PeopleController.cs
index 326c1a31c..a5bbdcc3d 100644
--- a/examples/AspNetCore/WebApi/OpenApiExample/V2/Controllers/PeopleController.cs
+++ b/examples/AspNetCore/WebApi/OpenApiExample/V2/PeopleController.cs
@@ -1,6 +1,6 @@
-namespace ApiVersioning.Examples.V2.Controllers;
+namespace ApiVersioning.Examples.V2;
-using ApiVersioning.Examples.V2.Models;
+using ApiVersioning.Examples.Models;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V3/Models/Order.cs b/examples/AspNetCore/WebApi/OpenApiExample/V3/Models/Order.cs
deleted file mode 100644
index bf5bedc57..000000000
--- a/examples/AspNetCore/WebApi/OpenApiExample/V3/Models/Order.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-namespace ApiVersioning.Examples.V3.Models;
-
-using System.ComponentModel.DataAnnotations;
-
-///
-/// Represents an order.
-///
-public class Order
-{
- ///
- /// Gets or sets the unique identifier for the order.
- ///
- /// The order's unique identifier.
- public int Id { get; set; }
-
- ///
- /// Gets or sets the date and time when the order was created.
- ///
- /// The order's creation date.
- public DateTimeOffset CreatedDate { get; set; } = DateTimeOffset.Now;
-
- ///
- /// Gets or sets the date and time when the order becomes effective.
- ///
- /// The order's effective date.
- public DateTimeOffset EffectiveDate { get; set; } = DateTimeOffset.Now;
-
- ///
- /// Gets or sets the name of the ordering customer.
- ///
- /// The name of the customer that placed the order.
- [Required]
- public string Customer { get; set; }
-}
\ No newline at end of file
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V3/Controllers/OrdersController.cs b/examples/AspNetCore/WebApi/OpenApiExample/V3/OrdersController.cs
similarity index 96%
rename from examples/AspNetCore/WebApi/OpenApiExample/V3/Controllers/OrdersController.cs
rename to examples/AspNetCore/WebApi/OpenApiExample/V3/OrdersController.cs
index 6d3151817..d4bb889af 100644
--- a/examples/AspNetCore/WebApi/OpenApiExample/V3/Controllers/OrdersController.cs
+++ b/examples/AspNetCore/WebApi/OpenApiExample/V3/OrdersController.cs
@@ -1,6 +1,6 @@
-namespace ApiVersioning.Examples.V3.Controllers;
+namespace ApiVersioning.Examples.V3;
-using ApiVersioning.Examples.V3.Models;
+using ApiVersioning.Examples.Models;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
diff --git a/examples/AspNetCore/WebApi/OpenApiExample/V3/Controllers/PeopleController.cs b/examples/AspNetCore/WebApi/OpenApiExample/V3/PeopleController.cs
similarity index 97%
rename from examples/AspNetCore/WebApi/OpenApiExample/V3/Controllers/PeopleController.cs
rename to examples/AspNetCore/WebApi/OpenApiExample/V3/PeopleController.cs
index 43f587880..01892c445 100644
--- a/examples/AspNetCore/WebApi/OpenApiExample/V3/Controllers/PeopleController.cs
+++ b/examples/AspNetCore/WebApi/OpenApiExample/V3/PeopleController.cs
@@ -1,6 +1,6 @@
-namespace ApiVersioning.Examples.V3.Controllers;
+namespace ApiVersioning.Examples.V3;
-using ApiVersioning.Examples.V3.Models;
+using ApiVersioning.Examples.Models;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/AdvertiseApiVersionsAttribute.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/AdvertiseApiVersionsAttribute.cs
index c94063154..e550446aa 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/AdvertiseApiVersionsAttribute.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/AdvertiseApiVersionsAttribute.cs
@@ -85,7 +85,9 @@ public AdvertiseApiVersionsAttribute( double version, params double[] otherVersi
/// The API version string.
/// An array of other API version strings.
[CLSCompliant( false )]
- public AdvertiseApiVersionsAttribute( string version, params string[] otherVersions )
+ public AdvertiseApiVersionsAttribute(
+ [StringSyntax( "ApiVersion" )] string version,
+ [StringSyntax( "ApiVersion" )] params string[] otherVersions )
: base( version, otherVersions ) { }
ApiVersionProviderOptions IApiVersionProvider.Options => options;
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersion.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersion.cs
index e12c471f2..df1915c6a 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersion.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersion.cs
@@ -10,7 +10,12 @@ namespace Asp.Versioning;
///
/// Represents an application programming interface (API) version.
///
-public partial class ApiVersion : IEquatable, IComparable, IFormattable
+#if ANALYZER
+internal
+#else
+public
+#endif
+partial class ApiVersion : IEquatable, IComparable, IFormattable
{
private static ApiVersion? @default;
private static ApiVersion? neutral;
@@ -181,7 +186,8 @@ protected ApiVersion( ApiVersion other )
/// The format to return the text representation in. The value can be null or empty.
/// The string representation of the version.
/// The specified is not one of the supported format values.
- public virtual string ToString( string format ) => ToString( format, CultureInfo.InvariantCulture );
+ public virtual string ToString( [StringSyntax( "ApiVersionFormat" )] string format ) =>
+ ToString( format, CultureInfo.InvariantCulture );
///
public override string ToString() => ToString( null, CultureInfo.InvariantCulture );
@@ -335,7 +341,7 @@ public virtual int CompareTo( ApiVersion? other )
}
///
- public virtual string ToString( string? format, IFormatProvider? formatProvider )
+ public virtual string ToString( [StringSyntax( "ApiVersionFormat" )] string? format, IFormatProvider? formatProvider )
{
var provider = ApiVersionFormatProvider.GetInstance( formatProvider );
#pragma warning disable IDE0079
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionAttribute.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionAttribute.cs
index a532de97c..116c8ee27 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionAttribute.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionAttribute.cs
@@ -55,7 +55,7 @@ protected ApiVersionAttribute( IApiVersionParser parser, string version ) : base
/// Initializes a new instance of the class.
///
/// The API version string.
- public ApiVersionAttribute( string version ) : base( version ) { }
+ public ApiVersionAttribute( [StringSyntax( "ApiVersion" )] string version ) : base( version ) { }
ApiVersionProviderOptions IApiVersionProvider.Options => options;
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionFormatProvider.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionFormatProvider.cs
index 6ade14b69..8d1fb4ab1 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionFormatProvider.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionFormatProvider.cs
@@ -181,11 +181,24 @@ namespace Asp.Versioning;
///
///
///
-public partial class ApiVersionFormatProvider : IFormatProvider, ICustomFormatter
+#if ANALYZER
+internal
+#else
+public
+#endif
+partial class ApiVersionFormatProvider : IFormatProvider, ICustomFormatter
{
private const int FormatCapacity = 32;
internal const string GroupVersionFormat = "yyyy-MM-dd";
+ ///
+ /// The largest supported padding count in a custom format string.
+ ///
+ /// This is the largest precision the "D" standard numeric format supports on every
+ /// target framework. .NET Framework silently reinterprets a larger precision as a custom format,
+ /// which yields text such as "D255" rather than a padded number.
+ internal const int MaxPadding = 99;
+
///
/// Initializes a new instance of the class.
///
@@ -358,7 +371,10 @@ protected virtual void FormatStatusPart(
/// The argument to format.
/// The used to format the argument.
/// A string representing the formatted argument.
- public virtual string Format( string? format, object? arg, IFormatProvider? formatProvider )
+ public virtual string Format(
+ [StringSyntax( "ApiVersionFormat" )] string? format,
+ object? arg,
+ IFormatProvider? formatProvider )
{
if ( arg is not ApiVersion value )
{
@@ -468,12 +484,23 @@ private static void SplitFormatSpecifierWithNumber(
}
}
- count = end > start
- ? int.Parse(
+ if ( end == start )
+ {
+ count = 2;
+ return;
+ }
+
+ // the padding count comes from the format string, so an unbounded value would size the
+ // stack from input; a version component is at most 10 digits, which MaxPadding far exceeds
+ if ( !int.TryParse(
Str.StringOrSpan( Str.Slice( format, start, end ) ),
- default,
- formatProvider )
- : 2;
+ NumberStyles.None,
+ formatProvider,
+ out count ) ||
+ count > MaxPadding )
+ {
+ throw new FormatException( SR.InvalidFormatString );
+ }
}
private static void AppendStatus( StringBuilder text, string? status )
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionParser.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionParser.cs
index 97ec10bbf..1fb3c9254 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionParser.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionParser.cs
@@ -20,7 +20,12 @@ namespace Asp.Versioning;
///
/// Represents the default API version parser.
///
-public class ApiVersionParser : IApiVersionParser
+#if ANALYZER
+internal
+#else
+public
+#endif
+class ApiVersionParser : IApiVersionParser
{
private static ApiVersionParser? @default;
@@ -87,7 +92,11 @@ public virtual ApiVersion Parse( Text text )
case '-':
segment = Str.Substring( text, 11 );
- if ( ApiVersion.IsValidStatus( segment ) )
+ if ( segment.Length == 0 )
+ {
+ throw InvalidFormat();
+ }
+ else if ( ApiVersion.IsValidStatus( segment ) )
{
return new( date, status: segment.ToString() );
}
@@ -126,7 +135,11 @@ public virtual ApiVersion Parse( Text text )
{
segment = Str.Substring( text, index + 1 );
- if ( !ApiVersion.IsValidStatus( segment ) )
+ if ( segment.Length == 0 )
+ {
+ throw InvalidFormat();
+ }
+ else if ( !ApiVersion.IsValidStatus( segment ) )
{
throw InvalidStatus( segment.ToString() );
}
@@ -145,7 +158,7 @@ public virtual ApiVersion Parse( Text text )
{
if ( !int.TryParse(
Str.StringOrSpan( Str.Truncate( text, index ) ),
- NumberStyles.Integer,
+ NumberStyles.None,
FormatProvider,
out var num ) )
{
@@ -156,7 +169,7 @@ public virtual ApiVersion Parse( Text text )
if ( !int.TryParse(
Str.StringOrSpan( Str.Substring( text, index + 1 ) ),
- NumberStyles.Integer,
+ NumberStyles.None,
FormatProvider,
out num ) )
{
@@ -169,7 +182,7 @@ public virtual ApiVersion Parse( Text text )
{
if ( !int.TryParse(
Str.StringOrSpan( text ),
- NumberStyles.Integer,
+ NumberStyles.None,
FormatProvider,
out var num ) )
{
@@ -225,7 +238,7 @@ public virtual bool TryParse( Text text, [MaybeNullWhen( false )] out ApiVersion
case '-':
segment = Str.Substring( text, 11 );
- if ( ApiVersion.IsValidStatus( segment ) )
+ if ( segment.Length > 0 && ApiVersion.IsValidStatus( segment ) )
{
apiVersion = new( date, status: segment.ToString() );
return true;
@@ -268,7 +281,7 @@ public virtual bool TryParse( Text text, [MaybeNullWhen( false )] out ApiVersion
{
segment = Str.Substring( text, index + 1 );
- if ( !ApiVersion.IsValidStatus( segment ) )
+ if ( segment.Length == 0 || !ApiVersion.IsValidStatus( segment ) )
{
apiVersion = default!;
return false;
@@ -288,7 +301,7 @@ public virtual bool TryParse( Text text, [MaybeNullWhen( false )] out ApiVersion
{
if ( !int.TryParse(
Str.StringOrSpan( Str.Truncate( text, index ) ),
- NumberStyles.Integer,
+ NumberStyles.None,
FormatProvider,
out var num ) )
{
@@ -300,7 +313,7 @@ public virtual bool TryParse( Text text, [MaybeNullWhen( false )] out ApiVersion
if ( !int.TryParse(
Str.StringOrSpan( Str.Substring( text, index + 1 ) ),
- NumberStyles.Integer,
+ NumberStyles.None,
FormatProvider,
out num ) )
{
@@ -314,7 +327,7 @@ public virtual bool TryParse( Text text, [MaybeNullWhen( false )] out ApiVersion
{
if ( !int.TryParse(
Str.StringOrSpan( text ),
- NumberStyles.Integer,
+ NumberStyles.None,
FormatProvider,
out var num ) )
{
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionRange.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionRange.cs
index 26510838f..8a710240b 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionRange.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionRange.cs
@@ -19,7 +19,12 @@ namespace Asp.Versioning;
///
/// This class is used to match API version ranges. It is not intended to define an API version range. API
/// versions must be explicitly declared.
-public sealed partial class ApiVersionRange
+#if ANALYZER
+internal
+#else
+public
+#endif
+sealed partial class ApiVersionRange
{
private static ApiVersionRange? any;
private static ApiVersionRange? empty;
@@ -244,10 +249,15 @@ private static IRule ParseMinOrExact( IApiVersionParser parser, Text rule )
}
}
- private static Func? NewRule( char ch ) => ch switch
+ private static Func? NewLowerRule( char ch ) => ch switch
{
'[' => static version => new MinInclusive( version ),
'(' => static version => new MinExclusive( version ),
+ _ => default,
+ };
+
+ private static Func? NewUpperRule( char ch ) => ch switch
+ {
']' => static version => new MaxInclusive( version ),
')' => static version => new MaxExclusive( version ),
_ => default,
@@ -255,7 +265,7 @@ private static IRule ParseMinOrExact( IApiVersionParser parser, Text rule )
private static bool TryParseLower( IApiVersionParser parser, Text expression, out IRule? rule )
{
- if ( expression.Length == 0 || NewRule( expression[0] ) is not { } newRule )
+ if ( expression.Length == 0 || NewLowerRule( expression[0] ) is not { } newRule )
{
rule = default;
return false;
@@ -286,7 +296,7 @@ private static bool TryParseUpper( IApiVersionParser parser, Text expression, ou
{
var length = expression.Length - 1;
- if ( length < 0 || NewRule( expression[length] ) is not { } newRule )
+ if ( length < 0 || NewUpperRule( expression[length] ) is not { } newRule )
{
rule = default;
return false;
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/Asp.Versioning.Abstractions.csproj b/src/Abstractions/src/Asp.Versioning.Abstractions/Asp.Versioning.Abstractions.csproj
index 5db42c8c6..87091270e 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/Asp.Versioning.Abstractions.csproj
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/Asp.Versioning.Abstractions.csproj
@@ -1,8 +1,8 @@
- 10.1.0
- 10.1.0.0
+ 10.2.0
+ 10.2.0.0$(DefaultTargetFramework);netstandard1.0;netstandard2.0API Versioning AbstractionsThe abstractions library for API versioning.
@@ -48,6 +48,7 @@
+
@@ -60,4 +61,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IAnnotation.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IAnnotation.cs
new file mode 100644
index 000000000..19aecb94a
--- /dev/null
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IAnnotation.cs
@@ -0,0 +1,22 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning;
+
+///
+/// Defines the behavior of an annotation.
+///
+/// The type of annotated source.
+/// The type of annotation.
+/// An annotation describes a source; it does not act on it. An action taken from an annotation, such as
+/// deciding whether a data member is visible, is layered on top of the annotations reported here. A source can be
+/// anything that is annotated, which includes, but is not limited to, a data member.
+public interface IAnnotation
+{
+ ///
+ /// Attempts to retrieve the annotation for the specified source.
+ ///
+ /// The source to retrieve the annotation for.
+ /// The retrieved annotation, if any.
+ /// True if the is annotated; otherwise, false.
+ bool TryGet( TSource source, [MaybeNullWhen( false )] out TAnnotation annotation );
+}
\ No newline at end of file
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IAnnotationExtensions.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IAnnotationExtensions.cs
new file mode 100644
index 000000000..9df3e5423
--- /dev/null
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IAnnotationExtensions.cs
@@ -0,0 +1,30 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning;
+
+///
+/// Provides extension methods for the interface.
+///
+public static class IAnnotationExtensions
+{
+ /// The type of annotated source.
+ /// The extended annotations.
+ extension( IAnnotation annotations )
+ {
+ ///
+ /// Determines whether the provided source is visible to the specified API version.
+ ///
+ /// The source to evaluate.
+ /// The API version to compare against.
+ /// True if the source is visible in the specified API version;
+ /// otherwise, false.
+ /// This is the filtering action applied from an annotation. A source that is not annotated is
+ /// visible to every API version. A source that is evaluated repeatedly should resolve its annotation once
+ /// and evaluate the range directly.
+ public bool IsVisible( TSource source, ApiVersion apiVersion )
+ {
+ ArgumentNullException.ThrowIfNull( annotations );
+ return !annotations.TryGet( source, out var apiVersions ) || apiVersions.Contains( apiVersion );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/LinkHeaderValue.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/LinkHeaderValue.cs
index 3e1717cb8..049ea4b5c 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/LinkHeaderValue.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/LinkHeaderValue.cs
@@ -661,7 +661,12 @@ private static StringSegment UnescapeAsQuotedString( StringSegment input )
OnCreateString( buffer, input );
return new( buffer );
#elif NETSTANDARD2_0
- Span buffer = stackalloc char[input.Length - backSlashCount];
+ // the length comes from a header value, which cannot be capped the way a format string
+ // can, so anything beyond the stack budget is allocated on the heap instead
+ var length = input.Length - backSlashCount;
+ var overflow = length > Str.MaxStackAllocChars ? new char[length] : default;
+ Span buffer = overflow ?? stackalloc char[length];
+
OnCreateString( buffer, input );
return buffer.ToString();
#else
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/MapToApiVersionAttribute.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/MapToApiVersionAttribute.cs
index 9589248ce..22c9b14ac 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/MapToApiVersionAttribute.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/MapToApiVersionAttribute.cs
@@ -53,7 +53,7 @@ protected MapToApiVersionAttribute( IApiVersionParser parser, string version ) :
/// Initializes a new instance of the class.
///
/// The API version string.
- public MapToApiVersionAttribute( string version ) : base( version ) { }
+ public MapToApiVersionAttribute( [StringSyntax( "ApiVersion" )] string version ) : base( version ) { }
ApiVersionProviderOptions IApiVersionProvider.Options => ApiVersionProviderOptions.Mapped;
}
\ No newline at end of file
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/NamespaceParser.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/NamespaceParser.cs
index b6192e029..8276c188f 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/NamespaceParser.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/NamespaceParser.cs
@@ -22,7 +22,12 @@ namespace Asp.Versioning;
/// when the source folder starts with a number and the editor automatically prefixes it with an underscore. As an
/// example, Api._2018_04_01.Controllers is equivalent to Api.v2018_04_01.Controllers.
///
-public class NamespaceParser
+#if ANALYZER
+internal
+#else
+public
+#endif
+class NamespaceParser
{
private const string CompactDateFormat = "yyyyMMdd";
private const string ReadableDateFormat = "yyyy_MM_dd";
@@ -375,7 +380,7 @@ private bool TryConsumeNumber( ref Text identifier, out int? number )
#else
identifier[..length],
#endif
- NumberStyles.Integer,
+ NumberStyles.None,
FormatProvider,
out var result ) )
{
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/README.md b/src/Abstractions/src/Asp.Versioning.Abstractions/README.md
index f6cb2c67d..92856c154 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/README.md
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/README.md
@@ -11,13 +11,11 @@ client-based applications.
- Asp.Versioning.ApiVersionAttribute
- Asp.Versioning.ApiVersionMetadata
- Asp.Versioning.ApiVersionModel
+- Asp.Versioning.DeprecationPolicy
- Asp.Versioning.IApiVersionNeutral
- Asp.Versioning.IApiVersionParameterSource
- Asp.Versioning.IApiVersionParser
- Asp.Versioning.IApiVersionProvider
- Asp.Versioning.LinkHeaderValue
- Asp.Versioning.MapToApiVersionAttribute
-- Asp.Versioning.SunsetPolicy
-
-## Release Notes
-
+- Asp.Versioning.SunsetPolicy
\ No newline at end of file
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ReleaseNotes.txt b/src/Abstractions/src/Asp.Versioning.Abstractions/ReleaseNotes.txt
index 09c54b932..5f282702b 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/ReleaseNotes.txt
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ReleaseNotes.txt
@@ -1 +1 @@
-Added support for `ApiVersionRange`
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/Str.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/Str.cs
index a4eb70b10..bef2ce1b7 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/Str.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/Str.cs
@@ -14,6 +14,8 @@ namespace Asp.Versioning;
internal static class Str
{
+ internal const int MaxStackAllocChars = 256;
+
[MethodImpl( MethodImplOptions.AggressiveInlining )]
#if NETSTANDARD1_0
internal static bool IsNullOrEmpty( Text? text )
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/VisibleInApiVersionAttribute.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/VisibleInApiVersionAttribute.cs
new file mode 100644
index 000000000..fa7456047
--- /dev/null
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/VisibleInApiVersionAttribute.cs
@@ -0,0 +1,51 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0079
+#pragma warning disable CA1019
+#pragma warning disable CA1813
+
+namespace Asp.Versioning;
+
+using static System.AttributeTargets;
+
+///
+/// Represents the metadata to indicate whether a data member should be visible in a particular API version.
+///
+[AttributeUsage( Property, AllowMultiple = false, Inherited = true )]
+public class VisibleInApiVersionAttribute : Attribute
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The parser used in rule construction.
+ /// The range to parse.
+ /// Additional ranges to parse, if any.
+ /// See for more information on rule notation.
+ protected VisibleInApiVersionAttribute( IApiVersionParser parser, string rule, params string[] otherRules )
+ => Range = ApiVersionRange.Parse( parser, rule, otherRules );
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The range to parse.
+ /// See for more information on rule notation.
+ public VisibleInApiVersionAttribute( [StringSyntax( "ApiVersionRange" )] string rule )
+ => Range = ApiVersionRange.Parse( rule );
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The range to parse.
+ /// Additional ranges to parse, if any.
+ /// See for more information on rule notation.
+ public VisibleInApiVersionAttribute(
+ [StringSyntax( "ApiVersionRange" )] string rule,
+ [StringSyntax( "ApiVersionRange" )] params string[] otherRules )
+ => Range = ApiVersionRange.Parse( rule, otherRules );
+
+ ///
+ /// Gets the range of API versions the member is explored in.
+ ///
+ /// The associated API version range.
+ public ApiVersionRange Range { get; }
+}
\ No newline at end of file
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/net#.0/ApiVersion.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/net#.0/ApiVersion.cs
index c9eb3b008..b4b97de21 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/net#.0/ApiVersion.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/net#.0/ApiVersion.cs
@@ -8,7 +8,11 @@ namespace Asp.Versioning;
public partial class ApiVersion : ISpanFormattable
{
///
- public virtual bool TryFormat( Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider )
+ public virtual bool TryFormat(
+ Span destination,
+ out int charsWritten,
+ [StringSyntax( "ApiVersionFormat" )] ReadOnlySpan format,
+ IFormatProvider? provider )
{
var instance = ApiVersionFormatProvider.GetInstance( provider );
#pragma warning disable IDE0079
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/ApiVersion.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/ApiVersion.cs
index e7330e25a..1842ecb70 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/ApiVersion.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/ApiVersion.cs
@@ -5,7 +5,12 @@ namespace Asp.Versioning;
///
/// Contains additional implementation specific to .NET Standard 2.0.
///
-public partial class ApiVersion
+#if ANALYZER
+internal
+#else
+public
+#endif
+partial class ApiVersion
{
///
/// Gets a value indicating whether the specified status is valid.
@@ -35,11 +40,13 @@ public static bool IsValidStatus( ReadOnlySpan status )
return false;
}
+ var last = status.Length - 1;
+
for ( var i = 1; i < status.Length; i++ )
{
ch = ref status[i];
- if ( !char.IsLetterOrDigit( ch ) && ch != '.' )
+ if ( !char.IsLetterOrDigit( ch ) && ( ch != '.' || i == last ) )
{
return false;
}
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/ApiVersionFormatProvider.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/ApiVersionFormatProvider.cs
index 76ad58af8..74076ea32 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/ApiVersionFormatProvider.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/ApiVersionFormatProvider.cs
@@ -8,7 +8,12 @@ namespace Asp.Versioning;
///
/// Contains additional implementation specific to .NET Standard 2.0.
///
-public partial class ApiVersionFormatProvider
+#if ANALYZER
+internal
+#else
+public
+#endif
+partial class ApiVersionFormatProvider
{
///
/// Attempts to format the provided argument with the specified format and provider.
diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/IApiVersionParser.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/IApiVersionParser.cs
index cfbf616e9..712473a33 100644
--- a/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/IApiVersionParser.cs
+++ b/src/Abstractions/src/Asp.Versioning.Abstractions/netstandard2.0/IApiVersionParser.cs
@@ -5,7 +5,12 @@ namespace Asp.Versioning;
///
/// Defines the behavior of an API version parser.
///
-public interface IApiVersionParser
+#if ANALYZER
+internal
+#else
+public
+#endif
+interface IApiVersionParser
{
///
/// Parses the specified text.
diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionFormatProviderTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionFormatProviderTest.cs
index abd3684ed..7984c79ab 100644
--- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionFormatProviderTest.cs
+++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionFormatProviderTest.cs
@@ -124,6 +124,38 @@ public void format_should_not_allow_malformed_literal_string( FormatProviderKind
format.Should().Throw();
}
+ [Theory]
+ [MemberData( nameof( MalformedPaddingData ) )]
+ public void format_should_not_allow_malformed_padding( FormatProviderKind kind, string malformedFormat )
+ {
+ // arrange
+ // the padding count is taken from the format string, so it can neither size the stack
+ // nor surface as an arithmetic error
+ var provider = GetProvider( kind );
+ var apiVersion = ApiVersionParser.Default.Parse( "1.5" );
+
+ // act
+ Action format = () => provider.Format( malformedFormat, apiVersion, null );
+
+ // assert
+ format.Should().Throw();
+ }
+
+ [Theory]
+ [MemberData( nameof( FormatProvidersData ) )]
+ public void format_should_allow_maximum_padding( FormatProviderKind kind )
+ {
+ // arrange
+ var provider = GetProvider( kind );
+ var apiVersion = ApiVersionParser.Default.Parse( "1.5" );
+
+ // act
+ var format = provider.Format( "p99", apiVersion, null );
+
+ // assert
+ format.Should().Be( "5".PadLeft( 99, '0' ) );
+ }
+
[Theory]
[AssumeCulture( "en-us" )]
[MemberData( nameof( GroupVersionFormatData ) )]
@@ -422,6 +454,29 @@ public static TheoryData MalformedLiteralStringsData
}
}
+ public static TheoryData MalformedPaddingData
+ {
+ get
+ {
+ var data = new TheoryData();
+
+ foreach ( var provider in FormatProvidersData )
+ {
+ data.Add( provider, "p100" );
+ data.Add( provider, "P100" );
+ data.Add( provider, "p256" );
+ data.Add( provider, "p1000" );
+ data.Add( provider, "p2147483647" );
+ data.Add( provider, "p2147483648" );
+ data.Add( provider, "P2147483648" );
+ data.Add( provider, "p99999999999999999999" );
+ data.Add( provider, "P99999999999999999999" );
+ }
+
+ return data;
+ }
+ }
+
public static TheoryData GroupVersionFormatData
{
get
diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionParserTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionParserTest.cs
index 600926b30..b18505f7d 100644
--- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionParserTest.cs
+++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionParserTest.cs
@@ -36,6 +36,7 @@ public void parse_should_return_expected_result( string text, string groupVersio
[Theory]
[InlineData( "Alpha1", "The specified API version is invalid." )]
+ [InlineData( "1-", "The specified API version is invalid." )]
[InlineData( "1.1-Alpha-1", "The specified API version status 'Alpha-1' is invalid." )]
[InlineData( "2013-02-29.1.0", "The specified API group version '2013-02-29' is invalid." )]
public void parse_should_throw_format_exception_for_invalid_text( string text, string message )
@@ -82,6 +83,7 @@ public void try_parse_should_return_expected_api_version( string text, string gr
[Theory]
[InlineData( "Alpha1" )]
+ [InlineData( "1-" )]
[InlineData( "1.1-Alpha-1" )]
[InlineData( "2013-02-29.1.0" )]
public void try_parse_should_return_false_when_text_is_invalid( string text )
diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionRangeTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionRangeTest.cs
index c031b4523..af40ce147 100644
--- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionRangeTest.cs
+++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionRangeTest.cs
@@ -149,6 +149,29 @@ public void range_should_be_invalid()
parse.Should().Throw().WithMessage( "The API version range \"(1.0)\" is invalid." );
}
+ [Theory]
+ [InlineData( "]1.0,2.0[" )]
+ [InlineData( "]1.0,2.0]" )]
+ [InlineData( "]1.0,2.0)" )]
+ [InlineData( ")1.0,2.0[" )]
+ [InlineData( ")1.0,2.0]" )]
+ [InlineData( ")1.0,2.0)" )]
+ [InlineData( "[1.0,2.0[" )]
+ [InlineData( "[1.0,2.0(" )]
+ [InlineData( "(1.0,2.0[" )]
+ [InlineData( "(1.0,2.0(" )]
+ public void range_should_not_parse_mismatched_bounds( string rule )
+ {
+ // arrange
+
+
+ // act
+ Action parse = () => ApiVersionRange.Parse( rule );
+
+ // assert
+ parse.Should().Throw();
+ }
+
[Theory]
[InlineData( "1.0" )]
[InlineData( "[1.0,)" )]
diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionTest.cs
index 7d0a9899b..99324c511 100644
--- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionTest.cs
+++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionTest.cs
@@ -221,6 +221,7 @@ public void is_valid_status_should_return_true_for_valid_status( string status )
[InlineData( "Alpha-1" )]
[InlineData( "Beta-2" )]
[InlineData( "RC-1" )]
+ [InlineData( "preview." )]
public void is_valid_status_should_return_false_for_invalid_status( string status )
{
// arrange
diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/LinkHeaderValueTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/LinkHeaderValueTest.cs
index af8024bd7..f139d98fd 100644
--- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/LinkHeaderValueTest.cs
+++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/LinkHeaderValueTest.cs
@@ -265,4 +265,24 @@ public void try_parse_list_should_skip_invalid_input()
new( new( "http://tempuri.org/3" ), "test" ),
} );
}
+
+ [Theory]
+ [InlineData( 10 )]
+ [InlineData( 1000 )]
+ [InlineData( 1000000 )]
+ public void try_parse_should_unescape_quoted_string_of_any_length( int length )
+ {
+ // arrange
+ // a header value is remote input, so the buffer it is unescaped into cannot be
+ // allocated on the stack once the value grows beyond a fixed budget
+ var title = string.Concat( Enumerable.Repeat( "\\a", length ) );
+ var input = $"; rel=\"next\"; title=\"{title}\"";
+
+ // act
+ var result = LinkHeaderValue.TryParse( input, default, out var value );
+
+ // assert
+ result.Should().BeTrue();
+ value.Title.ToString().Should().Be( new string( 'a', length ) );
+ }
}
\ No newline at end of file
diff --git a/src/Analyzers/Directory.Build.props b/src/Analyzers/Directory.Build.props
new file mode 100644
index 000000000..39febd9a3
--- /dev/null
+++ b/src/Analyzers/Directory.Build.props
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/ApiVersionFormatValidator.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/ApiVersionFormatValidator.cs
new file mode 100644
index 000000000..4f068c307
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/ApiVersionFormatValidator.cs
@@ -0,0 +1,127 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// Whether a format can be applied is decided by applying it, which is what the rule does. This describes what is
+/// wrong with one that cannot, because the failure a format raises names no part of the format, and it reports the
+/// repetition the format provider accepts but does not act on. Being wrong here costs a less specific message or a
+/// missing suggestion; it cannot make a format read as valid when it is not, or the reverse.
+///
+internal static class ApiVersionFormatValidator
+{
+ public static void Validate( string format, ICollection problems )
+ {
+ var last = format.Length - 1;
+
+ for ( var i = 0; i < format.Length; i++ )
+ {
+ var ch = format[i];
+
+ if ( ch == '\'' || ch == '"' )
+ {
+ if ( !TryConsumeLiteral( format, ref i ) )
+ {
+ problems.Add( FormatProblem.UnterminatedLiteral( ch ) );
+ return;
+ }
+ }
+ else if ( ch == '\\' && i < last && IsEscapable( format[i + 1] ) )
+ {
+ i++;
+ }
+ else if ( ch == '%' && i < last && IsSpecifier( format[i + 1] ) )
+ {
+ // a single custom format specifier is never repeated, but may still be padded
+ i++;
+ ConsumeSpecifier( format, ref i, repeatable: false, problems );
+ }
+ else if ( IsSpecifier( ch ) )
+ {
+ ConsumeSpecifier( format, ref i, repeatable: true, problems );
+ }
+ }
+ }
+
+ private static bool TryConsumeLiteral( string format, ref int i )
+ {
+ var delimiter = format[i];
+
+ for ( var j = i + 1; j < format.Length; j++ )
+ {
+ if ( format[j] == delimiter )
+ {
+ i = j;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static void ConsumeSpecifier(
+ string format,
+ ref int i,
+ bool repeatable,
+ ICollection problems )
+ {
+ var specifier = format[i];
+ var start = i;
+ var length = 1;
+
+ if ( repeatable )
+ {
+ while ( i + 1 < format.Length && format[i + 1] == specifier )
+ {
+ i++;
+ length++;
+ }
+ }
+
+ var digits = i + 1;
+
+ while ( digits < format.Length && char.IsDigit( format[digits] ) )
+ {
+ digits++;
+ }
+
+ if ( digits > i + 1 )
+ {
+ // only padding uses the count; any other specifier ignores the digits that follow it
+ if ( specifier is 'P' or 'p' )
+ {
+ var text = format.Substring( i + 1, digits - i - 1 );
+
+ if ( !int.TryParse( text, out var count ) || count > ApiVersionFormatProvider.MaxPadding )
+ {
+ problems.Add( FormatProblem.PaddingOutOfRange( text ) );
+ }
+ }
+
+ i = digits - 1;
+ }
+
+ var max = MaxLength( specifier );
+
+ if ( length > max )
+ {
+ problems.Add( FormatProblem.RepeatedSpecifier( format.Substring( start, length ), specifier, max, length ) );
+ }
+ }
+
+ private static bool IsSpecifier( char ch ) =>
+ ch is 'F' or 'G' or 'M' or 'P' or 'S' or 'V' or 'd' or 'p' or 'v' or 'y';
+
+ private static bool IsEscapable( char ch ) =>
+ ch is '\'' or '\\' || IsSpecifier( ch );
+
+ /// A specifier repeated beyond its maximum is silently reinterpreted rather than
+ /// rejected. The year is unbounded because each additional 'y' adds a digit of padding.
+ private static int MaxLength( char specifier ) => specifier switch
+ {
+ 'F' or 'G' => 2,
+ 'M' or 'P' or 'V' or 'd' => 4,
+ 'S' or 'p' or 'v' => 1,
+ _ => int.MaxValue,
+ };
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/Arguments.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/Arguments.cs
new file mode 100644
index 000000000..0b9813686
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/Arguments.cs
@@ -0,0 +1,77 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Immutable;
+
+///
+/// Binds the arguments of an attribute, invocation, or object creation back to the symbol each argument is passed to,
+/// which is where the metadata that drives an analyzer is declared.
+///
+internal static class Arguments
+{
+ public static IParameterSymbol? ResolveParameter(
+ ImmutableArray parameters,
+ string? name,
+ int index )
+ {
+ if ( name is not null )
+ {
+ foreach ( var parameter in parameters )
+ {
+ if ( parameter.Name == name )
+ {
+ return parameter;
+ }
+ }
+
+ return default;
+ }
+
+ if ( index < parameters.Length )
+ {
+ return parameters[index];
+ }
+
+ // beyond the declared parameters the argument can only belong to an expanded params array
+ var last = parameters.Length - 1;
+
+ return last >= 0 && parameters[last].IsParams ? parameters[last] : default;
+ }
+
+ public static ISymbol? ResolveMember( INamedTypeSymbol? type, string name )
+ {
+ for ( var declaringType = type; declaringType is not null; declaringType = declaringType.BaseType )
+ {
+ foreach ( var member in declaringType.GetMembers( name ) )
+ {
+ if ( member is IPropertySymbol or IFieldSymbol )
+ {
+ return member;
+ }
+ }
+ }
+
+ return default;
+ }
+
+ // an extension member is declared in a synthetic, nested extension type, so the type that declares the member is
+ // its containing type. The synthetic type cannot be referred to by name, which identifies it without an API that
+ // only a newer compiler would provide
+ public static INamedTypeSymbol? ResolveDeclaringType( IMethodSymbol method )
+ {
+ var type = method.ContainingType;
+
+ return type is { ContainingType: { } declaringType } && !type.CanBeReferencedByName
+ ? declaringType
+ : type;
+ }
+
+ public static SeparatedSyntaxList? GetArrayElements( ExpressionSyntax expression ) =>
+ expression switch
+ {
+ ArrayCreationExpressionSyntax { Initializer: { } initializer } => initializer.Expressions,
+ ImplicitArrayCreationExpressionSyntax array => array.Initializer.Expressions,
+ _ => default( SeparatedSyntaxList? ),
+ };
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/Asp.Versioning.Analyzers.csproj b/src/Analyzers/src/Asp.Versioning.Analyzers/Asp.Versioning.Analyzers.csproj
new file mode 100644
index 000000000..a7d5d4704
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/Asp.Versioning.Analyzers.csproj
@@ -0,0 +1,15 @@
+
+
+
+ 1.0.0.0
+ netstandard2.0
+ Asp.Versioning.Analyzers
+ ASP.NET API Versioning Analyzers (Core)
+ The foundational analyzers for API versioning.
+
+
+
+
+
+
+
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/Category.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/Category.cs
new file mode 100644
index 000000000..0c8af7906
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/Category.cs
@@ -0,0 +1,8 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+internal static class Category
+{
+ public const string Usage = nameof( Usage );
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/Descriptor.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/Descriptor.cs
new file mode 100644
index 000000000..e3d1c0456
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/Descriptor.cs
@@ -0,0 +1,101 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+using static Asp.Versioning.Analyzers.Category;
+using static Microsoft.CodeAnalysis.DiagnosticSeverity;
+
+internal static class Descriptor
+{
+ private static DiagnosticDescriptor Diagnostic(
+ string id,
+ string title,
+ string category,
+ DiagnosticSeverity defaultSeverity,
+ string messageFormat )
+ {
+ var helpLink = $"https://github.com/dotnet/aspnet-api-versioning/wiki/analyzer-rules-{id}";
+
+ return new( id, title, messageFormat, category, defaultSeverity, isEnabledByDefault: true, helpLinkUri: helpLink );
+ }
+
+ public static DiagnosticDescriptor AV0001_InvalidApiVersionSyntax { get; } =
+ Diagnostic(
+ "AV0001",
+ "Invalid API version",
+ Usage,
+ Error,
+ "An API version must be a date or a number, optionally with a status." );
+
+ public static DiagnosticDescriptor AV0002_InvalidApiVersionRangeSyntax { get; } =
+ Diagnostic(
+ "AV0002",
+ "Invalid API version range",
+ Usage,
+ Error,
+ "A range must include 1-2 valid API versions, optionally with inclusive ('[', ']') or exclusive ('(', ')') bounds." );
+
+ public static DiagnosticDescriptor AV0003_InvalidApiVersionStatus { get; } =
+ Diagnostic(
+ "AV0003",
+ "Invalid API version status",
+ Usage,
+ Error,
+ "An API version status may only be a letter followed by letters or numbers with optional periods in between." );
+
+ public static DiagnosticDescriptor AV0004_InvalidApiVersionNumber { get; } =
+ Diagnostic(
+ "AV0004",
+ "Invalid API version number",
+ Usage,
+ Error,
+ "An API version number cannot be negative." );
+
+ public static DiagnosticDescriptor AV0005_InvalidApiVersionYear { get; } =
+ Diagnostic(
+ "AV0005",
+ "Invalid API version year",
+ Usage,
+ Error,
+ "An API version year must be between 1 and 9999." );
+
+ public static DiagnosticDescriptor AV0006_InvalidApiVersionMonth { get; } =
+ Diagnostic(
+ "AV0006",
+ "Invalid API version month",
+ Usage,
+ Error,
+ "An API version month must be between 1 and 12." );
+
+ public static DiagnosticDescriptor AV0007_InvalidApiVersionDay { get; } =
+ Diagnostic(
+ "AV0007",
+ "Invalid API version day",
+ Usage,
+ Error,
+ "An API version day must be between 1 and 31." );
+
+ public static DiagnosticDescriptor AV0008_InvalidApiVersionDate { get; } =
+ Diagnostic(
+ "AV0008",
+ "Invalid API version date",
+ Usage,
+ Error,
+ "The specified API version is not a valid date." );
+
+ public static DiagnosticDescriptor AV0009_InvalidApiVersionFormat { get; } =
+ Diagnostic(
+ "AV0009",
+ "Invalid API version format",
+ Usage,
+ Error,
+ "The API version format string is malformed and will throw when applied. {0}" );
+
+ public static DiagnosticDescriptor AV0010_UnexpectedApiVersionFormat { get; } =
+ Diagnostic(
+ "AV0010",
+ "Unexpected API version format",
+ Usage,
+ Warning,
+ "The API version format specifier '{0}' is only meaningful up to {1} time(s); repeating it {2} times does not produce the expected result." );
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/FormatProblem.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/FormatProblem.cs
new file mode 100644
index 000000000..468cbb37d
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/FormatProblem.cs
@@ -0,0 +1,36 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// Describes a problem found in an API version format string. The validator that produces these has no
+/// knowledge of diagnostics so that it can mirror the tokenizer it is ported from; mapping a problem
+/// onto a descriptor is the analyzer's concern.
+///
+internal readonly struct FormatProblem
+{
+ private FormatProblem( FormatProblemKind kind, string specifier, int maxLength, int length )
+ {
+ Kind = kind;
+ Specifier = specifier;
+ MaxLength = maxLength;
+ Length = length;
+ }
+
+ public FormatProblemKind Kind { get; }
+
+ public string Specifier { get; }
+
+ public int MaxLength { get; }
+
+ public int Length { get; }
+
+ public static FormatProblem UnterminatedLiteral( char delimiter ) =>
+ new( FormatProblemKind.UnterminatedLiteral, delimiter.ToString(), 0, 0 );
+
+ public static FormatProblem PaddingOutOfRange( string count ) =>
+ new( FormatProblemKind.PaddingOutOfRange, count, ApiVersionFormatProvider.MaxPadding, 0 );
+
+ public static FormatProblem RepeatedSpecifier( string text, char specifier, int maxLength, int length ) =>
+ new( FormatProblemKind.RepeatedSpecifier, specifier.ToString(), maxLength, length );
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/FormatProblemKind.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/FormatProblemKind.cs
new file mode 100644
index 000000000..95bea259d
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/FormatProblemKind.cs
@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+internal enum FormatProblemKind
+{
+ /// A quoted literal is never closed, which throws when the format is applied.
+ UnterminatedLiteral,
+
+ /// A padding count is not a number or exceeds the supported maximum.
+ PaddingOutOfRange,
+
+ /// A specifier is repeated more times than is meaningful.
+ RepeatedSpecifier,
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionArgumentsMustBeValid.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionArgumentsMustBeValid.cs
new file mode 100644
index 000000000..8408753f0
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionArgumentsMustBeValid.cs
@@ -0,0 +1,290 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using Microsoft.CodeAnalysis.Text;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that validates the arguments projected onto an API version.
+///
+///
+/// The numeric and date components of an API version carry no string syntax to key off of, so the API surface that
+/// accepts them is matched by name and each argument is then validated according to the parameter it is bound to.
+/// A status is judged by the same method an API version judges one with. The numeric and date components are judged
+/// against the range each is declared to accept, which the argument guards state and the calendar fixes, rather than
+/// by constructing a version per argument to see whether it is refused.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class ApiVersionArgumentsMustBeValid : DiagnosticAnalyzer
+{
+ private const int MinYear = 1;
+ private const int MaxYear = 9999;
+ private const int MonthsPerYear = 12;
+ private const int MaxDaysPerMonth = 31;
+
+ private static readonly HashSet DeclaringTypes = new( StringComparer.Ordinal )
+ {
+ "Asp.Versioning.ApiVersionAttribute",
+ "Asp.Versioning.AdvertiseApiVersionsAttribute",
+ "Asp.Versioning.MapToApiVersionAttribute",
+ "Asp.Versioning.Conventions.ApiVersionConventionBuilderExtensions",
+ };
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(
+ AV0003_InvalidApiVersionStatus,
+ AV0004_InvalidApiVersionNumber,
+ AV0005_InvalidApiVersionYear,
+ AV0006_InvalidApiVersionMonth,
+ AV0007_InvalidApiVersionDay,
+ AV0008_InvalidApiVersionDate );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterSyntaxNodeAction( OnAttribute, SyntaxKind.Attribute );
+ context.RegisterSyntaxNodeAction( OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterSyntaxNodeAction(
+ OnObjectCreation,
+ SyntaxKind.ObjectCreationExpression,
+ SyntaxKind.ImplicitObjectCreationExpression );
+ }
+
+ private static void OnAttribute( SyntaxNodeAnalysisContext context )
+ {
+ var attribute = (AttributeSyntax) context.Node;
+
+ if ( attribute.ArgumentList is not { Arguments.Count: > 0 } list ||
+ !TryGetDeclaredApi( context, attribute, out var ctor ) )
+ {
+ return;
+ }
+
+ var arguments = list.Arguments;
+ var date = default( DateArguments );
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ var argument = arguments[i];
+
+ // a named argument is an initializer for a property or field, which is never a version component
+ if ( argument.NameEquals is not null )
+ {
+ continue;
+ }
+
+ var parameter = Arguments.ResolveParameter( ctor.Parameters, argument.NameColon?.Name.Identifier.ValueText, i );
+
+ Validate( context, parameter, argument.Expression, ref date );
+ }
+
+ ValidateDate( context, ref date );
+ }
+
+ private static void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ ValidateArguments( context, invocation, invocation.ArgumentList );
+ }
+
+ private static void OnObjectCreation( SyntaxNodeAnalysisContext context )
+ {
+ var creation = (BaseObjectCreationExpressionSyntax) context.Node;
+
+ ValidateArguments( context, creation, creation.ArgumentList );
+ }
+
+ private static void ValidateArguments( SyntaxNodeAnalysisContext context, SyntaxNode node, ArgumentListSyntax? list )
+ {
+ if ( list is not { Arguments.Count: > 0 } || !TryGetDeclaredApi( context, node, out var method ) )
+ {
+ return;
+ }
+
+ var arguments = list.Arguments;
+ var date = default( DateArguments );
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ var argument = arguments[i];
+ var parameter = Arguments.ResolveParameter( method.Parameters, argument.NameColon?.Name.Identifier.ValueText, i );
+
+ Validate( context, parameter, argument.Expression, ref date );
+ }
+
+ ValidateDate( context, ref date );
+ }
+
+ private static bool TryGetDeclaredApi( SyntaxNodeAnalysisContext context, SyntaxNode node, out IMethodSymbol method )
+ {
+ if ( context.SemanticModel.GetSymbolInfo( node, context.CancellationToken ).Symbol is IMethodSymbol symbol &&
+ Arguments.ResolveDeclaringType( symbol ) is { } type &&
+ DeclaringTypes.Contains( type.ToDisplayString() ) )
+ {
+ method = symbol;
+ return true;
+ }
+
+ method = default!;
+ return false;
+ }
+
+ private static void Validate(
+ SyntaxNodeAnalysisContext context,
+ IParameterSymbol? parameter,
+ ExpressionSyntax expression,
+ ref DateArguments date )
+ {
+ if ( parameter is null )
+ {
+ return;
+ }
+
+ // a name alone is ambiguous; a version can also be a string and a date can also be a group version
+ switch ( parameter.Name )
+ {
+ case "version" when parameter.Type.SpecialType == SpecialType.System_Double:
+ case "majorVersion" or "minorVersion" when IsInt32( parameter.Type ):
+ ValidateNumber( context, expression );
+ break;
+ case "otherVersions" when parameter.Type is IArrayTypeSymbol { ElementType.SpecialType: SpecialType.System_Double }:
+ ValidateNumbers( context, expression );
+ break;
+ case "year" when IsInt32( parameter.Type ):
+ date.Year = Capture( context, expression, AV0005_InvalidApiVersionYear, IsValidYear );
+ break;
+ case "month" when IsInt32( parameter.Type ):
+ date.Month = Capture( context, expression, AV0006_InvalidApiVersionMonth, IsValidMonth );
+ break;
+ case "day" when IsInt32( parameter.Type ):
+ date.Day = Capture( context, expression, AV0007_InvalidApiVersionDay, IsValidDay );
+ break;
+ case "status" when parameter.Type.SpecialType == SpecialType.System_String:
+ ValidateStatus( context, expression );
+ break;
+ }
+ }
+
+ private static void ValidateNumbers( SyntaxNodeAnalysisContext context, ExpressionSyntax expression )
+ {
+ // a params array can be passed as an array rather than expanded; validate each element
+ if ( Arguments.GetArrayElements( expression ) is { } elements )
+ {
+ foreach ( var element in elements )
+ {
+ ValidateNumber( context, element );
+ }
+
+ return;
+ }
+
+ ValidateNumber( context, expression );
+ }
+
+ private static void ValidateNumber( SyntaxNodeAnalysisContext context, ExpressionSyntax expression )
+ {
+ var constant = context.SemanticModel.GetConstantValue( expression, context.CancellationToken );
+
+ // only a compile-time constant can be validated; anything else is unknowable until run time
+ var valid = constant switch
+ {
+ { HasValue: true, Value: double number } => number >= 0d && !double.IsNaN( number ) && !double.IsInfinity( number ),
+ { HasValue: true, Value: int number } => number >= 0,
+ _ => true,
+ };
+
+ if ( !valid )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0004_InvalidApiVersionNumber, expression.GetLocation() ) );
+ }
+ }
+
+ private static void ValidateStatus( SyntaxNodeAnalysisContext context, ExpressionSyntax expression )
+ {
+ var constant = context.SemanticModel.GetConstantValue( expression, context.CancellationToken );
+
+ if ( constant is { HasValue: true, Value: string status } && !ApiVersion.IsValidStatus( status ) )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0003_InvalidApiVersionStatus, expression.GetLocation() ) );
+ }
+ }
+
+ private static DateArgument Capture(
+ SyntaxNodeAnalysisContext context,
+ ExpressionSyntax expression,
+ DiagnosticDescriptor descriptor,
+ Func isValid )
+ {
+ var constant = context.SemanticModel.GetConstantValue( expression, context.CancellationToken );
+
+ if ( constant is not { HasValue: true, Value: int component } )
+ {
+ return default;
+ }
+
+ var valid = isValid( component );
+
+ if ( !valid )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( descriptor, expression.GetLocation() ) );
+ }
+
+ return new() { Expression = expression, Value = component, Valid = valid };
+ }
+
+ private static void ValidateDate( SyntaxNodeAnalysisContext context, ref DateArguments date )
+ {
+ var year = date.Year;
+ var month = date.Month;
+ var day = date.Day;
+
+ // the composed date is only meaningful once every component is known and individually in range
+ if ( !year.Valid || !month.Valid || !day.Valid )
+ {
+ return;
+ }
+
+ // the individual components are already in range, and the Gregorian calendar is the one DateOnly composes from
+ if ( day.Value <= DateTime.DaysInMonth( year.Value, month.Value ) )
+ {
+ return;
+ }
+
+ var span = TextSpan.FromBounds( year.Expression!.SpanStart, day.Expression!.Span.End );
+ var location = Location.Create( year.Expression.SyntaxTree, span );
+
+ context.ReportDiagnostic( Diagnostic.Create( AV0008_InvalidApiVersionDate, location ) );
+ }
+
+ private static bool IsValidYear( int year ) => year is >= MinYear and <= MaxYear;
+
+ private static bool IsValidMonth( int month ) => month is >= 1 and <= MonthsPerYear;
+
+ private static bool IsValidDay( int day ) => day is >= 1 and <= MaxDaysPerMonth;
+
+ private static bool IsInt32( ITypeSymbol type ) =>
+ type.SpecialType == SpecialType.System_Int32 ||
+ ( type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable &&
+ nullable.TypeArguments[0].SpecialType == SpecialType.System_Int32 );
+
+ private struct DateArgument
+ {
+ public ExpressionSyntax? Expression;
+ public int Value;
+ public bool Valid;
+ }
+
+ private struct DateArguments
+ {
+ public DateArgument Year;
+ public DateArgument Month;
+ public DateArgument Day;
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionFormatStringSyntaxMustBeValid.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionFormatStringSyntaxMustBeValid.cs
new file mode 100644
index 000000000..cf96a551c
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionFormatStringSyntaxMustBeValid.cs
@@ -0,0 +1,88 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using static Descriptor;
+
+///
+/// Represents an analyzer that validates an API version format.
+///
+///
+/// Whether a format is valid is decided by applying it to an API version, which is what it will be used for. The
+/// version applied to declares every component so that each specifier resolves to something; a format that fails
+/// does so because of the format rather than what it was given.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class ApiVersionFormatStringSyntaxMustBeValid : StringSyntaxAnalyzer
+{
+ private const string ApiVersionFormat = nameof( ApiVersionFormat );
+
+ private static readonly ApiVersion Sample = ApiVersionParser.Default.Parse( "2000-01-01.1.1-alpha".AsSpan() );
+
+ public ApiVersionFormatStringSyntaxMustBeValid()
+ : base( ApiVersionFormat, AV0009_InvalidApiVersionFormat, AV0010_UnexpectedApiVersionFormat ) { }
+
+ protected override void Validate( string text, Reporter reporter )
+ {
+ // an empty format is the full format, so there is nothing to validate
+ if ( text.Length == 0 )
+ {
+ return;
+ }
+
+ var problems = new List();
+
+ ApiVersionFormatValidator.Validate( text, problems );
+
+ if ( !CanBeApplied( text, out var reason ) )
+ {
+ reporter.Report( AV0009_InvalidApiVersionFormat, Explain( problems, reason ) );
+ }
+
+ // a repeated specifier is applied rather than rejected, so it is only ever reported as unexpected
+ foreach ( var problem in problems.Where( p => p.Kind == FormatProblemKind.RepeatedSpecifier ) )
+ {
+ reporter.Report(
+ AV0010_UnexpectedApiVersionFormat,
+ problem.Specifier,
+ problem.MaxLength,
+ problem.Length );
+ }
+ }
+
+ private static bool CanBeApplied( string format, out string reason )
+ {
+ try
+ {
+ Sample.ToString( format );
+ }
+ catch ( FormatException ex )
+ {
+ reason = ex.Message;
+ return false;
+ }
+
+ reason = string.Empty;
+ return true;
+ }
+
+ /// The failure names no part of the format, so what was found in it is preferred when there is
+ /// something to say; the failure is what remains when there is not.
+ private static string Explain( List problems, string reason )
+ {
+ foreach ( var problem in problems )
+ {
+ switch ( problem.Kind )
+ {
+ case FormatProblemKind.UnterminatedLiteral:
+ return $"The literal delimited by {problem.Specifier} is not terminated.";
+ case FormatProblemKind.PaddingOutOfRange:
+ return $"The padding count '{problem.Specifier}' must be between 0 and {problem.MaxLength}.";
+ }
+ }
+
+ return reason;
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionRangeStringSyntaxMustBeValid.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionRangeStringSyntaxMustBeValid.cs
new file mode 100644
index 000000000..0cd25c05d
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionRangeStringSyntaxMustBeValid.cs
@@ -0,0 +1,36 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using static Descriptor;
+
+///
+/// Represents an analyzer that validates an API version range.
+///
+///
+/// What a range accepts is decided by the range itself, which is compiled into this assembly rather than described a
+/// second time here. A range cannot be asked whether a rule parses without parsing it, so the failure it raises is
+/// caught instead; only a compile-time constant reaches this, so it happens where a rule is written and nowhere else.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class ApiVersionRangeStringSyntaxMustBeValid : StringSyntaxAnalyzer
+{
+ private const string ApiVersionRange = nameof( ApiVersionRange );
+
+ public ApiVersionRangeStringSyntaxMustBeValid()
+ : base( ApiVersionRange, AV0002_InvalidApiVersionRangeSyntax ) { }
+
+ protected override void Validate( string text, Reporter reporter )
+ {
+ try
+ {
+ Versioning.ApiVersionRange.Parse( text );
+ }
+ catch ( Exception ex ) when ( ex is FormatException or System.ArgumentException )
+ {
+ reporter.Report( AV0002_InvalidApiVersionRangeSyntax );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionStringSyntaxMustBeValid.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionStringSyntaxMustBeValid.cs
new file mode 100644
index 000000000..f9680cc50
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/Rules/ApiVersionStringSyntaxMustBeValid.cs
@@ -0,0 +1,31 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using static Descriptor;
+
+///
+/// Represents an analyzer that validates an API version.
+///
+///
+/// What an API version accepts is decided by the parser that reads one, which is compiled into this assembly rather
+/// than described a second time here. A value the parser rejects is a value that throws where it is read.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class ApiVersionStringSyntaxMustBeValid : StringSyntaxAnalyzer
+{
+ private const string ApiVersion = nameof( ApiVersion );
+
+ public ApiVersionStringSyntaxMustBeValid()
+ : base( ApiVersion, AV0001_InvalidApiVersionSyntax ) { }
+
+ protected override void Validate( string text, Reporter reporter )
+ {
+ if ( !ApiVersionParser.Default.TryParse( text.AsSpan(), out _ ) )
+ {
+ reporter.Report( AV0001_InvalidApiVersionSyntax );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Analyzers/StringSyntaxAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Analyzers/StringSyntaxAnalyzer.cs
new file mode 100644
index 000000000..a58f6c956
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Analyzers/StringSyntaxAnalyzer.cs
@@ -0,0 +1,193 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Immutable;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents the base implementation of an analyzer that validates values annotated with a
+/// string syntax.
+///
+///
+/// Reports a diagnostic for every compile-time constant passed to a parameter, property, or field
+/// annotated with [StringSyntax] for a particular syntax, whose value fails validation. The
+/// annotation is discovered on the resolved symbol rather than on a known set of types, so any API
+/// annotated with the syntax is covered without being enumerated here. A property or field is reached
+/// by assignment as much as by being passed, so what is assigned to one is validated the same way.
+///
+public abstract class StringSyntaxAnalyzer : DiagnosticAnalyzer
+{
+ private const string StringSyntaxAttribute = nameof( StringSyntaxAttribute );
+ private const string StringSyntaxNamespace = "System.Diagnostics.CodeAnalysis";
+ private readonly string syntax;
+
+ protected StringSyntaxAnalyzer( string syntax, params DiagnosticDescriptor[] descriptors )
+ {
+ this.syntax = syntax;
+ SupportedDiagnostics = ImmutableArray.Create( descriptors );
+ }
+
+ public sealed override ImmutableArray SupportedDiagnostics { get; }
+
+ public sealed override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterSyntaxNodeAction( OnAttribute, SyntaxKind.Attribute );
+ context.RegisterSyntaxNodeAction( OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterSyntaxNodeAction(
+ OnObjectCreation,
+ SyntaxKind.ObjectCreationExpression,
+ SyntaxKind.ImplicitObjectCreationExpression );
+
+ // an object initializer assigns through the same expression as a property does
+ context.RegisterSyntaxNodeAction( OnAssignment, SyntaxKind.SimpleAssignmentExpression );
+ }
+
+ protected abstract void Validate( string text, Reporter reporter );
+
+ ///
+ /// Reports the diagnostics found in an annotated value.
+ ///
+ /// The value is reported at the location of the expression that produced it. A constant
+ /// may be declared elsewhere, and the offsets within a literal do not survive escaping, so a
+ /// diagnostic identifies the offending part of the value through its message.
+ protected readonly struct Reporter
+ {
+ private readonly SyntaxNodeAnalysisContext context;
+ private readonly Location location;
+
+ internal Reporter( SyntaxNodeAnalysisContext context, Location location )
+ {
+ this.context = context;
+ this.location = location;
+ }
+
+ public void Report( DiagnosticDescriptor descriptor, params object?[] messageArgs ) =>
+ context.ReportDiagnostic( Diagnostic.Create( descriptor, location, messageArgs ) );
+ }
+
+ private void OnAttribute( SyntaxNodeAnalysisContext context )
+ {
+ var attribute = (AttributeSyntax) context.Node;
+
+ if ( attribute.ArgumentList is not { Arguments.Count: > 0 } list ||
+ context.SemanticModel.GetSymbolInfo( attribute, context.CancellationToken ).Symbol is not IMethodSymbol ctor )
+ {
+ return;
+ }
+
+ var arguments = list.Arguments;
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ var argument = arguments[i];
+
+ // a named argument is an initializer for a property or field; everything else maps to a parameter
+ var target = argument.NameEquals is { } nameEquals
+ ? Arguments.ResolveMember( ctor.ContainingType, nameEquals.Name.Identifier.ValueText )
+ : Arguments.ResolveParameter( ctor.Parameters, argument.NameColon?.Name.Identifier.ValueText, i );
+
+ if ( HasStringSyntax( target ) )
+ {
+ Validate( context, argument.Expression );
+ }
+ }
+ }
+
+ private void OnAssignment( SyntaxNodeAnalysisContext context )
+ {
+ var assignment = (AssignmentExpressionSyntax) context.Node;
+ var assigned = context.SemanticModel.GetSymbolInfo( assignment.Left, context.CancellationToken ).Symbol;
+
+ if ( assigned is IPropertySymbol or IFieldSymbol && HasStringSyntax( assigned ) )
+ {
+ Validate( context, assignment.Right );
+ }
+ }
+
+ private void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+ var symbol = context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol;
+
+ ValidateArguments( context, invocation.ArgumentList, symbol );
+ }
+
+ private void OnObjectCreation( SyntaxNodeAnalysisContext context )
+ {
+ var creation = (BaseObjectCreationExpressionSyntax) context.Node;
+ var symbol = context.SemanticModel.GetSymbolInfo( creation, context.CancellationToken ).Symbol;
+
+ ValidateArguments( context, creation.ArgumentList, symbol );
+ }
+
+ private void ValidateArguments( SyntaxNodeAnalysisContext context, ArgumentListSyntax? list, ISymbol? symbol )
+ {
+ if ( list is not { Arguments.Count: > 0 } || symbol is not IMethodSymbol method )
+ {
+ return;
+ }
+
+ var arguments = list.Arguments;
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ var argument = arguments[i];
+ var parameter = Arguments.ResolveParameter( method.Parameters, argument.NameColon?.Name.Identifier.ValueText, i );
+
+ if ( HasStringSyntax( parameter ) )
+ {
+ Validate( context, argument.Expression );
+ }
+ }
+ }
+
+ private void Validate( SyntaxNodeAnalysisContext context, ExpressionSyntax expression )
+ {
+ // a params array can be passed as an array rather than expanded; validate each element
+ if ( Arguments.GetArrayElements( expression ) is { } elements )
+ {
+ foreach ( var element in elements )
+ {
+ Validate( context, element );
+ }
+
+ return;
+ }
+
+ var constant = context.SemanticModel.GetConstantValue( expression, context.CancellationToken );
+
+ // only a compile-time constant can be validated; anything else is unknowable until run time
+ if ( !constant.HasValue || constant.Value is not string text )
+ {
+ return;
+ }
+
+ Validate( text, new Reporter( context, expression.GetLocation() ) );
+ }
+
+ private bool HasStringSyntax( ISymbol? symbol )
+ {
+ if ( symbol is null )
+ {
+ return false;
+ }
+
+ foreach ( var attribute in symbol.GetAttributes() )
+ {
+ // the attribute is internal in some assemblies and defined by the BCL in others,
+ // so it is matched by name rather than a resolved type symbol
+ if ( attribute.AttributeClass is { Name: StringSyntaxAttribute } type &&
+ type.ContainingNamespace?.ToDisplayString() == StringSyntaxNamespace &&
+ attribute.ConstructorArguments.Length > 0 &&
+ attribute.ConstructorArguments[0].Value as string == syntax )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Asp.Versioning.Api.Analyzers.csproj b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Asp.Versioning.Api.Analyzers.csproj
new file mode 100644
index 000000000..2d221b7a2
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Asp.Versioning.Api.Analyzers.csproj
@@ -0,0 +1,19 @@
+
+
+
+ 1.0.0.0
+ netstandard2.0
+ Asp.Versioning.Analyzers
+ ASP.NET API Versioning Analyzers
+ The analyzers for API versioning.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Category.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Category.cs
new file mode 100644
index 000000000..19a228580
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Category.cs
@@ -0,0 +1,11 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+internal static class Category
+{
+ public const string Documentation = nameof( Documentation );
+ public const string Performance = nameof( Performance );
+ public const string Style = nameof( Style );
+ public const string Usage = nameof( Usage );
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/ControllerName.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/ControllerName.cs
new file mode 100644
index 000000000..9f07775e8
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/ControllerName.cs
@@ -0,0 +1,70 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// This is a compile-time port of Asp.Versioning.Conventions.ControllerNameConvention. Controllers are
+/// collated by a logical name rather than by route template, because templates that differ across
+/// versions can still describe the same API. Any change to the convention must be reflected here.
+///
+internal static class ControllerName
+{
+ private const string Controller = nameof( Controller );
+ private const string ControllerNameAttribute = "Asp.Versioning.ControllerNameAttribute";
+
+ ///
+ /// Gets the logical name a controller is collated under.
+ ///
+ /// An explicitly declared name is taken as given. Otherwise the Controller suffix is
+ /// removed and trailing numbers are trimmed, so that Example, ExampleController, and
+ /// Example2Controller all collate together.
+ public static bool TryResolve( INamedTypeSymbol type, out string name )
+ {
+ foreach ( var attribute in type.GetAttributes() )
+ {
+ if ( attribute.AttributeClass?.ToDisplayString() != ControllerNameAttribute )
+ {
+ continue;
+ }
+
+ if ( attribute.ConstructorArguments.Length > 0 &&
+ attribute.ConstructorArguments[0].Value is string declared )
+ {
+ name = declared;
+ return true;
+ }
+
+ // a name that cannot be read leaves the collation unknown
+ name = string.Empty;
+ return false;
+ }
+
+ name = TrimTrailingNumbers( RemoveSuffix( type.Name ) );
+ return true;
+ }
+
+ private static string RemoveSuffix( string name ) =>
+ name.Length > Controller.Length && name.EndsWith( Controller, StringComparison.Ordinal )
+ ? name.Substring( 0, name.Length - Controller.Length )
+ : name;
+
+ private static string TrimTrailingNumbers( string name )
+ {
+ if ( name.Length == 0 )
+ {
+ return string.Empty;
+ }
+
+ var last = name.Length - 1;
+
+ for ( var i = last; i >= 0; i-- )
+ {
+ if ( !char.IsNumber( name[i] ) )
+ {
+ return i < last ? name.Substring( 0, i + 1 ) : name;
+ }
+ }
+
+ return name;
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Descriptor.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Descriptor.cs
new file mode 100644
index 000000000..a39841e87
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Descriptor.cs
@@ -0,0 +1,206 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+using static Asp.Versioning.Analyzers.Category;
+using static Microsoft.CodeAnalysis.DiagnosticSeverity;
+using static Microsoft.CodeAnalysis.WellKnownDiagnosticTags;
+
+internal static class Descriptor
+{
+ private static DiagnosticDescriptor Diagnostic(
+ string id,
+ string title,
+ string category,
+ DiagnosticSeverity defaultSeverity,
+ string messageFormat,
+ params string[] customTags )
+ {
+ var helpLink = $"https://github.com/dotnet/aspnet-api-versioning/wiki/analyzer-rules-{id}";
+
+ return new(
+ id,
+ title,
+ messageFormat,
+ category,
+ defaultSeverity,
+ isEnabledByDefault: true,
+ helpLinkUri: helpLink,
+ customTags: customTags );
+ }
+
+ public static DiagnosticDescriptor AV0011_UnnecessaryDefaultApiVersion { get; } =
+ Diagnostic(
+ "AV0011",
+ "Remove unnecessary default API version",
+ Style,
+ Info,
+ "The default API version is 1.0.",
+ Unnecessary );
+
+ public static DiagnosticDescriptor AV0012_NeutralDefaultApiVersion { get; } =
+ Diagnostic(
+ "AV0012",
+ "Invalid default API version",
+ Usage,
+ Error,
+ "The default API version cannot be version-neutral." );
+
+ public static DiagnosticDescriptor AV0013_MissingAddMvc { get; } =
+ Diagnostic(
+ "AV0013",
+ "Missing AddMvc",
+ Usage,
+ Warning,
+ "Call Services.AddApiVersioning().AddMvc() to version MVC (Core) controller-based APIs." );
+
+ public static DiagnosticDescriptor AV0014_MissingApiBehavior { get; } =
+ Diagnostic(
+ "AV0014",
+ "Missing API behavior",
+ Usage,
+ Warning,
+ "Add [ApiController] to the controller or assembly." );
+
+ public static DiagnosticDescriptor AV0015_UseSpecificApiVersionReader { get; } =
+ Diagnostic(
+ "AV0015",
+ "Use a specific API version reader",
+ Performance,
+ Warning,
+ "Configure 'ApiVersioningOptions.ApiVersionReader = new {0}();' to optimize performance." );
+
+ public static DiagnosticDescriptor AV0016_DoNotAssumeDefaultApiVersion { get; } =
+ Diagnostic(
+ "AV0016",
+ "Do not assume default API version",
+ Usage,
+ Warning,
+ "AssumeDefaultVersionWhenUnspecified = true is only necessary for existing APIs that do not have an explicit API version.",
+ Unnecessary );
+
+ public static DiagnosticDescriptor AV0017_DoNotSetDefaultValue { get; } =
+ Diagnostic(
+ "AV0017",
+ "Remove unnecessary default value",
+ Usage,
+ Info,
+ "The default value is unnecessary.",
+ Unnecessary );
+
+ public static DiagnosticDescriptor AV0018_AllEndpointsAreVersionNeutral { get; } =
+ Diagnostic(
+ "AV0018",
+ "All endpoints are version-neutral",
+ Usage,
+ Error,
+ "At least one endpoint should have an explicit API version." );
+
+ public static DiagnosticDescriptor AV0019_VersionedAndNeutral { get; } =
+ Diagnostic(
+ "AV0019",
+ "An API cannot be versioned and version-neutral at the same time",
+ Usage,
+ Error,
+ "Detected a version-neutral API that also has versioned endpoints." );
+
+ public static DiagnosticDescriptor AV0020_UnnecessaryEndpointsApiExplorer { get; } =
+ Diagnostic(
+ "AV0020",
+ "Remove unnecessary API explorer",
+ Style,
+ Info,
+ "AddApiExplorer() already adds the endpoints API explorer.",
+ Unnecessary );
+
+ public static DiagnosticDescriptor AV0021_UseVersionedApiExplorer { get; } =
+ Diagnostic(
+ "AV0021",
+ "Use the versioned API explorer",
+ Usage,
+ Warning,
+ "Call AddApiVersioning().AddApiExplorer() so that API versions are described." );
+
+ public static DiagnosticDescriptor AV0022_MissingAddOData { get; } =
+ Diagnostic(
+ "AV0022",
+ "Missing AddOData",
+ Usage,
+ Warning,
+ "Call Services.AddApiVersioning().AddOData() to version OData APIs." );
+
+ public static DiagnosticDescriptor AV0023_IgnoredRouteComponents { get; } =
+ Diagnostic(
+ "AV0023",
+ "Route components are ignored",
+ Usage,
+ Warning,
+ "Configure 'AddOData( options => options.AddRouteComponents() )' so that route components are applied per API version." );
+
+ public static DiagnosticDescriptor AV0024_InheritedApiExplorerOption { get; } =
+ Diagnostic(
+ "AV0024",
+ "Remove unnecessary API explorer option",
+ Usage,
+ Info,
+ "The API explorer already uses this value from the API versioning options.",
+ Unnecessary );
+
+ public static DiagnosticDescriptor AV0025_MissingDocumentDescription { get; } =
+ Diagnostic(
+ "AV0025",
+ "Missing OpenAPI document description",
+ Documentation,
+ Info,
+ "Set in the project or add [assembly: AssemblyDescription] to describe the OpenAPI document." );
+
+ public static DiagnosticDescriptor AV0026_UnusedGroupNameFormat { get; } =
+ Diagnostic(
+ "AV0026",
+ "Remove unnecessary group name format",
+ Usage,
+ Info,
+ "FormatGroupName is only used by an API that sets a group name.",
+ Unnecessary );
+
+ public static DiagnosticDescriptor AV0027_UseDescribeApiVersions { get; } =
+ Diagnostic(
+ "AV0027",
+ "Use DescribeApiVersions",
+ Usage,
+ Warning,
+ "Call app.DescribeApiVersions() so that minimal APIs mapped after the services are built are described." );
+
+ public static DiagnosticDescriptor AV0028_SunsetBeforeDeprecation { get; } =
+ Diagnostic(
+ "AV0028",
+ "Sunset policy takes effect before deprecation",
+ Usage,
+ Warning,
+ "An API cannot be sunset before it is deprecated." );
+
+ public static DiagnosticDescriptor AV0029_UnnecessaryOpenApiServices { get; } =
+ Diagnostic(
+ "AV0029",
+ "Remove unnecessary OpenAPI services",
+ Usage,
+ Warning,
+ "AddApiVersioning().AddOpenApi() registers the OpenAPI services that describe API versions.",
+ Unnecessary );
+
+ public static DiagnosticDescriptor AV0030_MissingDocumentPerVersion { get; } =
+ Diagnostic(
+ "AV0030",
+ "Missing WithDocumentPerVersion",
+ Usage,
+ Warning,
+ "Call MapOpenApi().WithDocumentPerVersion() so that a document is generated for each API version." );
+
+ public static DiagnosticDescriptor AV0031_MissingApiExplorer { get; } =
+ Diagnostic(
+ "AV0031",
+ "Missing API explorer",
+ Usage,
+ Warning,
+ "Call AddApiVersioning().{0}() so that the OpenAPI document describes the APIs." );
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Endpoint.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Endpoint.cs
new file mode 100644
index 000000000..dee32f75b
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Endpoint.cs
@@ -0,0 +1,41 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// The templates an endpoint answers to, and whether it declares an API version of its own. An
+/// endpoint answering to both a constrained and an unconstrained template is registered twice on
+/// purpose, which is the one way a default version can apply to a URL segment.
+///
+internal readonly struct Endpoint(
+ IReadOnlyList templates,
+ bool versioned,
+ bool neutral,
+ string? @namespace = default )
+{
+ public IReadOnlyList Templates { get; } = templates;
+
+ ///
+ /// Gets a value indicating whether the endpoint declares an explicit API version.
+ ///
+ public bool Versioned { get; } = versioned;
+
+ ///
+ /// Gets a value indicating whether the endpoint declares that it is version-neutral.
+ ///
+ /// Neutrality is metadata in its own right, so it takes an endpoint out of the
+ /// arrangement a default version is meant for without giving it a version of its own.
+ public bool Neutral { get; } = neutral;
+
+ ///
+ /// Gets a value indicating whether the endpoint declares any versioning metadata at all.
+ ///
+ public bool Declared => Versioned || Neutral;
+
+ ///
+ /// Gets the namespace declaring the endpoint, if it came from a controller.
+ ///
+ /// Whether a namespace confers a version depends on which conventions are registered,
+ /// which is not known until the compilation has been seen in full.
+ public string? Namespace { get; } = @namespace;
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Endpoints.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Endpoints.cs
new file mode 100644
index 000000000..77ec10225
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Endpoints.cs
@@ -0,0 +1,99 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// An endpoint is an action or a mapped route, along with every template it answers to. A controller
+/// contributes the templates it declares to each of its actions, which is how the same action comes to
+/// be registered more than once.
+///
+internal static class Endpoints
+{
+ private static readonly HashSet MapMethods = new( StringComparer.Ordinal )
+ {
+ "MapGet", "MapPost", "MapPut", "MapDelete", "MapPatch", "MapMethods", "Map",
+ };
+
+ public static bool IsMapped( string methodName ) => MapMethods.Contains( methodName );
+
+ public static IEnumerable FromController( INamedTypeSymbol type )
+ {
+ var controllerTemplates = Routes.GetTemplates( type );
+ var versionedByController = Symbols.HasAttribute( type, Symbols.ApiVersionAttribute );
+ var neutralByController = Symbols.HasAttribute( type, Symbols.ApiVersionNeutralAttribute );
+
+ foreach ( var member in type.GetMembers() )
+ {
+ if ( member is not IMethodSymbol action ||
+ action.MethodKind != MethodKind.Ordinary ||
+ action.DeclaredAccessibility != Accessibility.Public ||
+ action.IsStatic )
+ {
+ continue;
+ }
+
+ var actionTemplates = Routes.GetTemplates( action );
+ var versioned = versionedByController || Symbols.HasAttribute( action, Symbols.ApiVersionAttribute );
+ var neutral = neutralByController || Symbols.HasAttribute( action, Symbols.ApiVersionNeutralAttribute );
+
+ yield return new(
+ Combine( controllerTemplates, actionTemplates ),
+ versioned,
+ neutral,
+ type.ContainingNamespace?.ToDisplayString() );
+ }
+ }
+
+ public static bool TryResolveConstraintName( IEnumerable names, out string constraintName )
+ {
+ constraintName = RouteTemplate.DefaultConstraintName;
+ var configured = false;
+
+ foreach ( var name in names )
+ {
+ // more than one name in a compilation cannot resolve to a single answer
+ if ( configured && name != constraintName )
+ {
+ return false;
+ }
+
+ constraintName = name;
+ configured = true;
+ }
+
+ return true;
+ }
+
+ private static IReadOnlyList Combine(
+ IReadOnlyList controllerTemplates,
+ IReadOnlyList actionTemplates )
+ {
+ if ( controllerTemplates.Count == 0 && actionTemplates.Count == 0 )
+ {
+ // routed by convention rather than by template, which cannot carry a constraint
+ return [string.Empty];
+ }
+
+ if ( controllerTemplates.Count == 0 )
+ {
+ return actionTemplates;
+ }
+
+ if ( actionTemplates.Count == 0 )
+ {
+ return controllerTemplates;
+ }
+
+ var combined = new List( controllerTemplates.Count * actionTemplates.Count );
+
+ foreach ( var controllerTemplate in controllerTemplates )
+ {
+ foreach ( var actionTemplate in actionTemplates )
+ {
+ combined.Add( controllerTemplate + "/" + actionTemplate );
+ }
+ }
+
+ return combined;
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/NamespaceVersion.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/NamespaceVersion.cs
new file mode 100644
index 000000000..228d00338
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/NamespaceVersion.cs
@@ -0,0 +1,55 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// Determines whether a namespace declares an API version.
+///
+///
+/// What an identifier has to look like to declare a version is decided by the parser that reads one, which is
+/// compiled into this assembly and reached by deriving from it. The parser reads the namespace of a type, which an
+/// analyzer has as text rather than as a type, so the parts are walked here and each is handed to the parser. Only
+/// whether a version was found matters, not which one, so the parsed value is discarded.
+///
+public static class NamespaceVersion
+{
+ ///
+ /// Determines whether any part of a namespace declares an API version.
+ ///
+ /// The namespace to evaluate.
+ /// True if any part of the declares an API version;
+ /// otherwise, false.
+ public static bool IsVersioned( string? @namespace )
+ {
+ if ( string.IsNullOrEmpty( @namespace ) )
+ {
+ return false;
+ }
+
+ var start = 0;
+
+ for ( var end = 0; end <= @namespace!.Length; end++ )
+ {
+ if ( end < @namespace.Length && @namespace[end] != '.' )
+ {
+ continue;
+ }
+
+ if ( Identifier.Parser.IsVersion( @namespace.Substring( start, end - start ) ) )
+ {
+ return true;
+ }
+
+ start = end + 1;
+ }
+
+ return false;
+ }
+
+ private sealed class Identifier : NamespaceParser
+ {
+ internal static readonly Identifier Parser = new();
+
+ internal bool IsVersion( string identifier ) => TryParse( identifier, out var version ) && version is not null;
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/OptionValue.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/OptionValue.cs
new file mode 100644
index 000000000..1484dd9d0
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/OptionValue.cs
@@ -0,0 +1,276 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Globalization;
+using System.Text;
+
+///
+/// Represents the value assigned to an option, reduced to a form that can be compared.
+///
+///
+/// The same value can be written more than one way, so values are compared by what they mean rather than
+/// by how they are spelled. A value that cannot be decided as it is written has no form at all, which is
+/// never equal to anything.
+///
+internal static class OptionValue
+{
+ private const string Default = nameof( Default );
+ private const string Empty = nameof( Empty );
+ private const string ApiVersionType = "Asp.Versioning.ApiVersion";
+
+ ///
+ /// Gets the form of the API version that options default to.
+ ///
+ public static string DefaultApiVersion { get; } = Version( 1, 0, default );
+
+ ///
+ /// Returns the form of a value known at compile time.
+ ///
+ /// The value to reduce.
+ /// The form of the .
+ public static string Constant( object? value ) =>
+ value is null
+ ? "c:"
+ : "c:" + value.GetType().Name + ":" + Convert.ToString( value, CultureInfo.InvariantCulture );
+
+ ///
+ /// Returns the form of an API version stated as its parts.
+ ///
+ /// The major version.
+ /// The minor version.
+ /// The version status, if any.
+ /// The form of the API version.
+ public static string ApiVersion( int major, int minor, string? status ) => Version( major, minor, status );
+
+ ///
+ /// Returns the form of an API version stated as a number.
+ ///
+ /// The version number.
+ /// The version status, if any.
+ /// The form of the API version, or null if the number is not one.
+ public static string? ApiVersion( double version, string? status ) =>
+ TrySplit( version, out var major, out var minor ) ? Version( major, minor, status ) : default;
+
+ ///
+ /// Returns the form of an API version stated as a date.
+ ///
+ /// The year of the group version.
+ /// The month of the group version.
+ /// The day of the group version.
+ /// The version status, if any.
+ /// The form of the API version.
+ public static string GroupVersion( int year, int month, int day, string? status ) =>
+ "g:" + year.ToString( "D4", CultureInfo.InvariantCulture ) + "-" +
+ month.ToString( "D2", CultureInfo.InvariantCulture ) + "-" +
+ day.ToString( "D2", CultureInfo.InvariantCulture ) + ":" + status;
+
+ ///
+ /// Returns the form of a value reached through a static member.
+ ///
+ /// The fully qualified name of the member.
+ /// The form of the member.
+ public static string Member( string name ) => "s:" + name;
+
+ ///
+ /// Reduces the expression assigned to an option to a form that can be compared.
+ ///
+ /// The semantic model the expression belongs to.
+ /// The assigned expression.
+ /// The token that can be used to cancel the operation.
+ /// The form of the expression, or null if it cannot be decided.
+ public static string? Resolve(
+ SemanticModel model,
+ ExpressionSyntax expression,
+ CancellationToken cancellationToken )
+ {
+ var constant = model.GetConstantValue( expression, cancellationToken );
+
+ if ( constant.HasValue )
+ {
+ return Constant( constant.Value );
+ }
+
+ var symbol = model.GetSymbolInfo( expression, cancellationToken ).Symbol;
+
+ switch ( symbol )
+ {
+ // string.Empty is a static field rather than a constant, but it is the same value
+ case IFieldSymbol { Name: Empty, ContainingType.SpecialType: SpecialType.System_String }:
+ return Constant( string.Empty );
+
+ // the version the options already default to, spelled the way the library spells it
+ case IPropertySymbol { IsStatic: true, Name: Default } version
+ when version.ContainingType?.ToDisplayString() == ApiVersionType:
+ return DefaultApiVersion;
+
+ case IPropertySymbol { IsStatic: true } or IFieldSymbol { IsStatic: true }:
+ return Member( symbol.ToDisplayString() );
+ }
+
+ if ( expression is not BaseObjectCreationExpressionSyntax creation ||
+ symbol is not IMethodSymbol constructor ||
+ constructor.ContainingType?.ToDisplayString() is not { } type )
+ {
+ return default;
+ }
+
+ return type == ApiVersionType
+ ? ResolveApiVersion( model, constructor, creation.ArgumentList, cancellationToken )
+ : ResolveCreation( model, type, creation.ArgumentList, cancellationToken );
+ }
+
+ private static string Version( int major, int minor, string? status ) =>
+ "v:" + major.ToString( CultureInfo.InvariantCulture ) + "." +
+ minor.ToString( CultureInfo.InvariantCulture ) + ":" + status;
+
+ private static string? ResolveApiVersion(
+ SemanticModel model,
+ IMethodSymbol constructor,
+ ArgumentListSyntax? list,
+ CancellationToken cancellationToken )
+ {
+ var arguments = list is null ? default : list.Arguments;
+ var major = default( int? );
+ var minor = default( int? );
+ var status = default( string );
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ var argument = arguments[i];
+ var parameter = ResolveParameter(
+ constructor.Parameters,
+ argument.NameColon?.Name.Identifier.ValueText,
+ i );
+
+ if ( parameter is null )
+ {
+ return default;
+ }
+
+ var constant = model.GetConstantValue( argument.Expression, cancellationToken );
+
+ if ( !constant.HasValue )
+ {
+ return default;
+ }
+
+ switch ( parameter.Name )
+ {
+ case "version" when constant.Value is double number:
+ if ( !TrySplit( number, out var whole, out var fraction ) )
+ {
+ return default;
+ }
+
+ major = whole;
+ minor = fraction;
+ break;
+ case "version" when constant.Value is int number:
+ major = number;
+ minor = 0;
+ break;
+ case "majorVersion" when constant.Value is int number:
+ major = number;
+ break;
+ case "minorVersion" when constant.Value is null:
+ break;
+ case "minorVersion" when constant.Value is int number:
+ minor = number;
+ break;
+ case "status" when constant.Value is null:
+ break;
+ case "status" when constant.Value is string text:
+ status = text;
+ break;
+
+ // a group version is a date rather than a number, which this form cannot carry
+ default:
+ return default;
+ }
+ }
+
+ // a minor version that is not stated is implied to be zero
+ return major is { } value ? Version( value, minor ?? 0, status ) : default;
+ }
+
+ /// The version is split the same way the constructor that takes a number splits it, so that
+ /// a version written as a number reduces to the same form as one written as its parts.
+ private static bool TrySplit( double version, out int major, out int minor )
+ {
+ major = 0;
+ minor = 0;
+
+ if ( version < 0d || double.IsNaN( version ) || double.IsInfinity( version ) )
+ {
+ return false;
+ }
+
+ var number = new decimal( version );
+ var scale = ( decimal.GetBits( number )[3] >> 16 ) & 31;
+ var whole = decimal.Truncate( number );
+
+ if ( whole > int.MaxValue )
+ {
+ return false;
+ }
+
+ major = (int) whole;
+ minor = (int) ( ( number - whole ) * new decimal( Math.Pow( 10, scale ) ) );
+ return true;
+ }
+
+ /// An argument stated by name can appear in any order, which the form cannot represent, so a
+ /// value written that way is left undecided rather than reduced to the wrong thing.
+ private static string? ResolveCreation(
+ SemanticModel model,
+ string type,
+ ArgumentListSyntax? list,
+ CancellationToken cancellationToken )
+ {
+ var arguments = list is null ? default : list.Arguments;
+ var form = new StringBuilder( "n:" ).Append( type );
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ var argument = arguments[i];
+
+ if ( argument.NameColon is not null )
+ {
+ return default;
+ }
+
+ var constant = model.GetConstantValue( argument.Expression, cancellationToken );
+
+ if ( !constant.HasValue )
+ {
+ return default;
+ }
+
+ form.Append( ',' ).Append( Constant( constant.Value ) );
+ }
+
+ return form.ToString();
+ }
+
+ private static IParameterSymbol? ResolveParameter(
+ ImmutableArray parameters,
+ string? name,
+ int index )
+ {
+ if ( name is null )
+ {
+ return index < parameters.Length ? parameters[index] : default;
+ }
+
+ foreach ( var parameter in parameters )
+ {
+ if ( parameter.Name == name )
+ {
+ return parameter;
+ }
+ }
+
+ return default;
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/PolicyKey.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/PolicyKey.cs
new file mode 100644
index 000000000..fa978513b
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/PolicyKey.cs
@@ -0,0 +1,210 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// Represents what a policy is keyed by.
+///
+///
+/// A policy is keyed by an API name, an API version, or both. A policy resolves by name and version
+/// first, then by name, and finally by version, so a policy that leaves one of them unstated is reached
+/// by every API that agrees with the part it does state.
+///
+internal sealed class PolicyKey
+{
+ private const string Name = "name";
+ private const string ApiVersion = "apiVersion";
+ private const string MajorVersion = "majorVersion";
+ private const string MinorVersion = "minorVersion";
+ private const string Version = "version";
+ private const string Status = "status";
+ private const string Year = "year";
+ private const string Month = "month";
+ private const string Day = "day";
+
+ private PolicyKey( string? name, string? version )
+ {
+ ApiName = name;
+ ApiVersionForm = version;
+ }
+
+ ///
+ /// Gets the name the policy is keyed by, if any.
+ ///
+ public string? ApiName { get; }
+
+ ///
+ /// Gets the form of the API version the policy is keyed by, if any.
+ ///
+ public string? ApiVersionForm { get; }
+
+ ///
+ /// Gets a value indicating whether no API reaches the policy.
+ ///
+ /// A version reaches every API of that version whatever it is named, and a name reaches
+ /// every version of that API. Stating neither reaches nothing at all rather than everything.
+ public bool Unreachable => ApiName is null && ApiVersionForm is null;
+
+ ///
+ /// Determines whether both policies can be reached by the same API.
+ ///
+ /// The policy key to compare against.
+ /// True if some API reaches both policies; otherwise, false.
+ /// A part that is unstated is agreed with by every API, so two keys are reached together
+ /// unless a part they both state disagrees.
+ public bool Intersects( PolicyKey other ) =>
+ Agrees( ApiName, other.ApiName ) && Agrees( ApiVersionForm, other.ApiVersionForm );
+
+ ///
+ /// Attempts to resolve what a policy is keyed by.
+ ///
+ /// The context the policy was declared in.
+ /// The expression declaring the policy.
+ /// The method the expression resolves to.
+ /// The resolved key, if any.
+ /// True if the key was resolved; otherwise, false.
+ /// A key written in a way that cannot be read leaves nothing to compare, which is not the
+ /// same as a key that states nothing.
+ public static bool TryResolve(
+ SyntaxNodeAnalysisContext context,
+ InvocationExpressionSyntax invocation,
+ IMethodSymbol method,
+ out PolicyKey key )
+ {
+ key = default!;
+
+ var arguments = invocation.ArgumentList.Arguments;
+ var name = default( string );
+ var version = default( string );
+ var major = default( int? );
+ var minor = default( int? );
+ var number = default( double? );
+ var year = default( int? );
+ var month = default( int? );
+ var day = default( int? );
+ var status = default( string );
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ var argument = arguments[i];
+ var parameter = ResolveParameter(
+ method.Parameters,
+ argument.NameColon?.Name.Identifier.ValueText,
+ i );
+
+ if ( parameter is null )
+ {
+ return false;
+ }
+
+ if ( parameter.Name == ApiVersion )
+ {
+ // the version can be an expression of its own, which reduces the same way it does elsewhere
+ if ( OptionValue.Resolve( context.SemanticModel, argument.Expression, context.CancellationToken )
+ is not { } form )
+ {
+ return false;
+ }
+
+ version = form;
+ continue;
+ }
+
+ var constant = context.SemanticModel.GetConstantValue( argument.Expression, context.CancellationToken );
+
+ if ( !constant.HasValue )
+ {
+ return false;
+ }
+
+ switch ( parameter.Name )
+ {
+ case Name when constant.Value is null:
+ break;
+ case Name when constant.Value is string text:
+ name = string.IsNullOrEmpty( text ) ? default : text;
+ break;
+ case MajorVersion when constant.Value is int value:
+ major = value;
+ break;
+ case MinorVersion when constant.Value is null:
+ break;
+ case MinorVersion when constant.Value is int value:
+ minor = value;
+ break;
+ case Version when constant.Value is double value:
+ number = value;
+ break;
+ case Version when constant.Value is int value:
+ number = value;
+ break;
+ case Year when constant.Value is int value:
+ year = value;
+ break;
+ case Month when constant.Value is int value:
+ month = value;
+ break;
+ case Day when constant.Value is int value:
+ day = value;
+ break;
+ case Status when constant.Value is null:
+ break;
+ case Status when constant.Value is string text:
+ status = text;
+ break;
+
+ // a group version stated as a date is not a form this can carry
+ default:
+ return false;
+ }
+ }
+
+ if ( version is null )
+ {
+ if ( major is { } value )
+ {
+ version = OptionValue.ApiVersion( value, minor ?? 0, status );
+ }
+ else if ( number is { } stated )
+ {
+ if ( OptionValue.ApiVersion( stated, status ) is not { } form )
+ {
+ return false;
+ }
+
+ version = form;
+ }
+ else if ( year is { } y && month is { } m && day is { } d )
+ {
+ version = OptionValue.GroupVersion( y, m, d, status );
+ }
+ }
+
+ key = new( name, version );
+ return true;
+ }
+
+ private static bool Agrees( string? left, string? right ) =>
+ left is null || right is null || left == right;
+
+ private static IParameterSymbol? ResolveParameter(
+ ImmutableArray parameters,
+ string? name,
+ int index )
+ {
+ if ( name is null )
+ {
+ return index < parameters.Length ? parameters[index] : default;
+ }
+
+ foreach ( var parameter in parameters )
+ {
+ if ( parameter.Name == name )
+ {
+ return parameter;
+ }
+ }
+
+ return default;
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Route.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Route.cs
new file mode 100644
index 000000000..85d1f9829
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Route.cs
@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// The template of an endpoint, along with whether every part of it was resolved. A template that
+/// could not be followed to its origin may be missing a prefix that carries the constraint, so it can
+/// only be trusted when the constraint was already found in the part that was resolved.
+///
+internal readonly struct Route( string template, bool complete )
+{
+ public string Template { get; } = template;
+
+ public bool Complete { get; } = complete;
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/RouteTemplate.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/RouteTemplate.cs
new file mode 100644
index 000000000..cd8c9b7c0
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/RouteTemplate.cs
@@ -0,0 +1,59 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// A route template expresses a constraint as {parameter:constraint}, so the constraint is
+/// present when its name appears after a colon within a parameter. A constraint may be parameterized,
+/// as in {version:apiVersion(1.0)}, and a parameter may end with a default or be optional.
+///
+internal static class RouteTemplate
+{
+ public const string DefaultConstraintName = "apiVersion";
+
+ public static bool HasConstraint( string template, string constraintName )
+ {
+ var start = -1;
+
+ for ( var i = 0; i < template.Length; i++ )
+ {
+ switch ( template[i] )
+ {
+ case '{':
+ start = -1;
+ break;
+ case ':':
+ start = i + 1;
+ break;
+ case '}' or '(' or '=' or '?':
+ if ( Matches( template, start, i, constraintName ) )
+ {
+ return true;
+ }
+
+ start = -1;
+ break;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool Matches( string template, int start, int end, string constraintName )
+ {
+ if ( start < 0 || end - start != constraintName.Length )
+ {
+ return false;
+ }
+
+ for ( var i = 0; i < constraintName.Length; i++ )
+ {
+ if ( template[start + i] != constraintName[i] )
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Routes.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Routes.cs
new file mode 100644
index 000000000..690f80bd4
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Routes.cs
@@ -0,0 +1,168 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// An endpoint is reached through a chain of builders. A group contributes a prefix to the routes
+/// mapped onto it, and other calls in the chain pass the builder along unchanged. Following that chain
+/// is how a route template and anything applied to it along the way are recovered.
+///
+internal static class Routes
+{
+ private const string MapGroup = nameof( MapGroup );
+
+ public static IReadOnlyList GetTemplates( ISymbol symbol )
+ {
+ List? templates = default;
+
+ foreach ( var attribute in symbol.GetAttributes() )
+ {
+ if ( attribute.AttributeClass is not { } type || attribute.ConstructorArguments.Length == 0 )
+ {
+ continue;
+ }
+
+ var name = type.ToDisplayString();
+ var routed = name == Symbols.RouteAttribute ||
+ ( name.StartsWith( Symbols.HttpMethodAttributePrefix, StringComparison.Ordinal ) &&
+ name.EndsWith( "Attribute", StringComparison.Ordinal ) );
+
+ if ( routed && attribute.ConstructorArguments[0].Value is string template )
+ {
+ ( templates ??= [] ).Add( template );
+ }
+ }
+
+ return (IReadOnlyList?) templates ?? [];
+ }
+
+ public static string? GetArgument(
+ SyntaxNodeAnalysisContext context,
+ InvocationExpressionSyntax invocation,
+ IMethodSymbol method,
+ string parameterName )
+ {
+ var arguments = invocation.ArgumentList.Arguments;
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ var argument = arguments[i];
+ var name = argument.NameColon?.Name.Identifier.ValueText
+ ?? ( i < method.Parameters.Length ? method.Parameters[i].Name : default );
+
+ if ( name != parameterName )
+ {
+ continue;
+ }
+
+ return context.SemanticModel.GetConstantValue( argument.Expression, context.CancellationToken )
+ is { HasValue: true, Value: string value }
+ ? value
+ : default;
+ }
+
+ return default;
+ }
+
+ ///
+ /// Follows the chain an endpoint was built from, gathering the prefixes applied to it.
+ ///
+ /// The chain is complete when it reaches the application itself. A chain that ends at a
+ /// parameter, field, or property may be missing a prefix, so the template it produces can only be
+ /// trusted when what was resolved already answers the question being asked.
+ public static string ResolveChain(
+ SyntaxNodeAnalysisContext context,
+ ExpressionSyntax? expression,
+ ISet applied,
+ out bool complete )
+ {
+ var prefixes = string.Empty;
+
+ for ( var node = expression; node is not null; )
+ {
+ switch ( node )
+ {
+ case InvocationExpressionSyntax invocation:
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is IMethodSymbol method )
+ {
+ applied.Add( method.Name );
+
+ if ( method.Name == MapGroup &&
+ Symbols.ResolveDeclaringType( method )?.ToDisplayString() ==
+ Symbols.EndpointRouteBuilderExtensions )
+ {
+ if ( GetArgument( context, invocation, method, "prefix" ) is not { } prefix )
+ {
+ complete = false;
+ return prefixes;
+ }
+
+ prefixes = prefix + "/" + prefixes;
+ }
+ }
+
+ node = Receiver( invocation );
+ break;
+
+ case IdentifierNameSyntax or MemberAccessExpressionSyntax:
+ var symbol = context.SemanticModel.GetSymbolInfo( node, context.CancellationToken ).Symbol;
+
+ if ( symbol is ILocalSymbol local && GetInitializer( local ) is { } initializer )
+ {
+ node = initializer;
+ break;
+ }
+
+ // the application itself is the origin, so nothing further can prefix a route
+ complete = TypeOf( context, node ) == Symbols.WebApplication;
+ return prefixes;
+
+ default:
+ complete = false;
+ return prefixes;
+ }
+ }
+
+ complete = false;
+ return prefixes;
+ }
+
+ ///
+ /// Collects the calls chained onto an endpoint after it was mapped.
+ ///
+ public static void CollectChainedCalls( InvocationExpressionSyntax invocation, ISet applied )
+ {
+ for ( SyntaxNode? node = invocation; node is not null; )
+ {
+ if ( node.Parent is not MemberAccessExpressionSyntax access ||
+ access.Parent is not InvocationExpressionSyntax chained )
+ {
+ return;
+ }
+
+ applied.Add( access.Name.Identifier.ValueText );
+ node = chained;
+ }
+ }
+
+ public static ExpressionSyntax? Receiver( InvocationExpressionSyntax invocation ) =>
+ invocation.Expression is MemberAccessExpressionSyntax access ? access.Expression : default;
+
+ private static ExpressionSyntax? GetInitializer( ILocalSymbol local )
+ {
+ foreach ( var reference in local.DeclaringSyntaxReferences )
+ {
+ if ( reference.GetSyntax() is VariableDeclaratorSyntax { Initializer.Value: { } value } )
+ {
+ return value;
+ }
+ }
+
+ return default;
+ }
+
+ private static string? TypeOf( SyntaxNodeAnalysisContext context, SyntaxNode node ) =>
+ context.SemanticModel.GetTypeInfo( (ExpressionSyntax) node, context.CancellationToken )
+ .Type?.ToDisplayString();
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/AllEndpointsVersionNeutralAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/AllEndpointsVersionNeutralAnalyzer.cs
new file mode 100644
index 000000000..304e7f574
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/AllEndpointsVersionNeutralAnalyzer.cs
@@ -0,0 +1,149 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports an API where nothing is versioned.
+///
+///
+/// A version-neutral endpoint belongs to every API version that has been defined. Requests still route
+/// when nothing else is defined, which is why this can go unnoticed, but the API explorer describes an
+/// endpoint once per explicitly defined version. With none defined, it describes nothing at all.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class AllEndpointsVersionNeutralAnalyzer : DiagnosticAnalyzer
+{
+ private const string AddApiVersioning = nameof( AddApiVersioning );
+ private const string IsApiVersionNeutral = nameof( IsApiVersionNeutral );
+
+ private static readonly HashSet VersioningCalls = new( StringComparer.Ordinal )
+ {
+ "HasApiVersion", "HasDeprecatedApiVersion",
+ };
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0018_AllEndpointsAreVersionNeutral );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+
+ // controllers cannot exist without MVC, so there is nothing to walk the declared types for
+ if ( Symbols.IsReferenced( context.Compilation, Symbols.ControllerBase ) )
+ {
+ context.RegisterSymbolAction( analysis.OnNamedType, SymbolKind.NamedType );
+ }
+
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag apiVersioningCallSites = [];
+ private volatile bool anyEndpoint;
+ private volatile bool anyVersioned;
+ private volatile bool anyUndeclared;
+ private volatile bool unknown;
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ var declaringType = type.ToDisplayString();
+
+ if ( method.Name == AddApiVersioning && declaringType == Symbols.ServiceCollectionExtensions )
+ {
+ apiVersioningCallSites.Add( Symbols.GetLocation( invocation ) );
+ }
+ else if ( declaringType == Symbols.EndpointRouteBuilderExtensions && Endpoints.IsMapped( method.Name ) )
+ {
+ AddEndpoint( context, invocation );
+ }
+ }
+
+ public void OnNamedType( SymbolAnalysisContext context )
+ {
+ var type = (INamedTypeSymbol) context.Symbol;
+
+ if ( !Symbols.IsApiController( type ) )
+ {
+ return;
+ }
+
+ foreach ( var endpoint in Endpoints.FromController( type ) )
+ {
+ Add( endpoint.Versioned, endpoint.Neutral );
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ // an endpoint that declares nothing is a separate problem, and a version declared
+ // anywhere gives the API explorer something to describe every neutral endpoint against
+ if ( unknown || !anyEndpoint || anyVersioned || anyUndeclared || apiVersioningCallSites.IsEmpty )
+ {
+ return;
+ }
+
+ foreach ( var callSite in apiVersioningCallSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0018_AllEndpointsAreVersionNeutral, callSite ) );
+ }
+ }
+
+ private void Add( bool versioned, bool neutral )
+ {
+ anyEndpoint = true;
+
+ if ( versioned )
+ {
+ anyVersioned = true;
+ }
+
+ if ( !neutral )
+ {
+ anyUndeclared = true;
+ }
+ }
+
+ private void AddEndpoint( SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation )
+ {
+ var applied = new HashSet( StringComparer.Ordinal );
+
+ Routes.CollectChainedCalls( invocation, applied );
+ Routes.ResolveChain( context, Routes.Receiver( invocation ), applied, out var complete );
+
+ if ( !complete )
+ {
+ // a group that could not be followed may have declared a version of its own
+ unknown = true;
+ return;
+ }
+
+ Add( applied.Overlaps( VersioningCalls ), applied.Contains( IsApiVersionNeutral ) );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/ApiExplorerAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/ApiExplorerAnalyzer.cs
new file mode 100644
index 000000000..c7d1263bb
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/ApiExplorerAnalyzer.cs
@@ -0,0 +1,121 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports an API explorer which is unaware of API versions.
+///
+///
+/// The versioned API explorer adds the endpoints API explorer itself, so adding it alongside is
+/// redundant. Adding it on its own describes endpoints without their versions, which is rarely what
+/// was intended once API versioning is in use.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class ApiExplorerAnalyzer : DiagnosticAnalyzer
+{
+ private const string AddApiVersioning = nameof( AddApiVersioning );
+ private const string AddApiExplorer = nameof( AddApiExplorer );
+ private const string AddODataApiExplorer = nameof( AddODataApiExplorer );
+ private const string AddOpenApi = nameof( AddOpenApi );
+ private const string AddEndpointsApiExplorer = nameof( AddEndpointsApiExplorer );
+ private const string ApiVersioningBuilderExtensions =
+ "Microsoft.Extensions.DependencyInjection.IApiVersioningBuilderExtensions";
+ private const string EndpointMetadataApiExplorerServiceCollectionExtensions =
+ "Microsoft.Extensions.DependencyInjection.EndpointMetadataApiExplorerServiceCollectionExtensions";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(
+ AV0020_UnnecessaryEndpointsApiExplorer,
+ AV0021_UseVersionedApiExplorer );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ if ( !Symbols.IsReferenced( context.Compilation, EndpointMetadataApiExplorerServiceCollectionExtensions ) )
+ {
+ return;
+ }
+
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag endpointsApiExplorerCallSites = [];
+ private volatile bool versioned;
+ private volatile bool versionedApiExplorer;
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ var declaringType = type.ToDisplayString();
+
+ switch ( method.Name )
+ {
+ case AddApiVersioning when declaringType == Symbols.ServiceCollectionExtensions:
+ versioned = true;
+ break;
+
+ // the OData and OpenAPI variants add the versioned explorer on their way to their own
+ case AddApiExplorer when declaringType == ApiVersioningBuilderExtensions:
+ case AddODataApiExplorer when declaringType == ApiVersioningBuilderExtensions:
+ case AddOpenApi when declaringType == ApiVersioningBuilderExtensions:
+ versionedApiExplorer = true;
+ break;
+ case AddEndpointsApiExplorer
+ when declaringType == EndpointMetadataApiExplorerServiceCollectionExtensions:
+ endpointsApiExplorerCallSites.Add( invocation.Parent is ExpressionStatementSyntax statement
+ ? statement.GetLocation()
+ : invocation.GetLocation() );
+ break;
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ if ( endpointsApiExplorerCallSites.IsEmpty )
+ {
+ return;
+ }
+
+ // the versioned explorer adds this one itself, so the call is redundant rather than wrong
+ var descriptor = versionedApiExplorer ? AV0020_UnnecessaryEndpointsApiExplorer
+ : versioned ? AV0021_UseVersionedApiExplorer
+ : default;
+
+ if ( descriptor is null )
+ {
+ return;
+ }
+
+ foreach ( var callSite in endpointsApiExplorerCallSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( descriptor, callSite ) );
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/AssumeDefaultApiVersionAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/AssumeDefaultApiVersionAnalyzer.cs
new file mode 100644
index 000000000..73b8bd5c9
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/AssumeDefaultApiVersionAnalyzer.cs
@@ -0,0 +1,354 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports a default API version assumed where none can apply.
+///
+///
+/// A default version is only ever applied to an endpoint carrying no versioning metadata at all, which
+/// grandfathers the clients of a service that was not versioned before. Declaring any version, even a
+/// neutral one, takes an endpoint out of that arrangement. The setting therefore does nothing once
+/// every endpoint either declares a version or can only be reached by naming one in the URL.
+/// Reading the version from the media type is the exception. A client asking for
+/// application/json has named no version and never will, whereas every version after the first is
+/// asked for as something like application/json; v=2.0. Assuming a default is what keeps the
+/// original clients working, so it is left alone however the endpoints are declared.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class AssumeDefaultApiVersionAnalyzer : DiagnosticAnalyzer
+{
+ private const string AssumeDefaultVersionWhenUnspecified = nameof( AssumeDefaultVersionWhenUnspecified );
+ private const string RouteConstraintName = nameof( RouteConstraintName );
+ private const string ApiVersionReader = nameof( ApiVersionReader );
+ private const string Combine = nameof( Combine );
+ private const string Conventions = nameof( Conventions );
+ private const string Add = nameof( Add );
+ private const string VersionByNamespaceConvention = "Asp.Versioning.Conventions.VersionByNamespaceConvention";
+ private const string IsApiVersionNeutral = nameof( IsApiVersionNeutral );
+ private const string ApiVersionReaderType = "Asp.Versioning.ApiVersionReader";
+ private const string MediaTypeApiVersionReader = "Asp.Versioning.MediaTypeApiVersionReader";
+ private const string MediaTypeApiVersionReaderBuilder = "Asp.Versioning.MediaTypeApiVersionReaderBuilder";
+
+ private static readonly HashSet VersioningCalls = new( StringComparer.Ordinal )
+ {
+ "HasApiVersion", "HasDeprecatedApiVersion",
+ };
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0016_DoNotAssumeDefaultApiVersion );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnAssignment, SyntaxKind.SimpleAssignmentExpression );
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+
+ // controllers cannot exist without MVC, so there is nothing to walk the declared types for
+ if ( Symbols.IsReferenced( context.Compilation, Symbols.ControllerBase ) )
+ {
+ context.RegisterSymbolAction( analysis.OnNamedType, SymbolKind.NamedType );
+ }
+
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ ///
+ /// Represents what an API version is read from.
+ ///
+ private enum Reader
+ {
+ /// The version is read from somewhere a client must name it.
+ Named,
+
+ /// The version is read from the media type, which a client can leave unsaid.
+ MediaType,
+
+ /// The version is read from something that cannot be decided as it is written.
+ Undecided,
+ }
+
+ /// A reader combined from others reads the version from the media type when any one of them
+ /// does, and a reader that cannot be read as written may well be one of them.
+ private static Reader ResolveReader( SyntaxNodeAnalysisContext context, ExpressionSyntax expression )
+ {
+ var model = context.SemanticModel;
+ var cancellationToken = context.CancellationToken;
+
+ if ( expression is BaseObjectCreationExpressionSyntax creation )
+ {
+ return Symbols.Declares(
+ model.GetTypeInfo( creation, cancellationToken ).Type as INamedTypeSymbol,
+ MediaTypeApiVersionReader )
+ ? Reader.MediaType
+ : Reader.Named;
+ }
+
+ if ( expression is not InvocationExpressionSyntax invocation ||
+ model.GetSymbolInfo( invocation, cancellationToken ).Symbol is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } declaringType )
+ {
+ return Reader.Undecided;
+ }
+
+ // a reader built from media type parameters is one whatever the built type is called
+ if ( Symbols.Declares( declaringType, MediaTypeApiVersionReaderBuilder ) )
+ {
+ return Reader.MediaType;
+ }
+
+ if ( method.Name != Combine || declaringType.ToDisplayString() != ApiVersionReaderType )
+ {
+ return Reader.Undecided;
+ }
+
+ var arguments = invocation.ArgumentList.Arguments;
+ var combined = Reader.Named;
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ switch ( ResolveReader( context, arguments[i].Expression ) )
+ {
+ case Reader.MediaType:
+ return Reader.MediaType;
+ case Reader.Undecided:
+ combined = Reader.Undecided;
+ break;
+ }
+ }
+
+ return combined;
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag assumeDefaultSites = [];
+ private readonly ConcurrentBag endpoints = [];
+ private readonly ConcurrentBag constraintNames = [];
+ private volatile bool versionByNamespace;
+ private volatile bool mediaType;
+ private volatile bool unknown;
+
+ public void OnAssignment( SyntaxNodeAnalysisContext context )
+ {
+ var assignment = (AssignmentExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( assignment.Left, context.CancellationToken ).Symbol
+ is not IPropertySymbol property ||
+ property.ContainingType?.ToDisplayString() != Symbols.ApiVersioningOptions )
+ {
+ return;
+ }
+
+ var constant = context.SemanticModel.GetConstantValue( assignment.Right, context.CancellationToken );
+
+ switch ( property.Name )
+ {
+ case AssumeDefaultVersionWhenUnspecified:
+ if ( constant is { HasValue: true, Value: true } )
+ {
+ assumeDefaultSites.Add( assignment.GetLocation() );
+ }
+ else
+ {
+ // assigned away from, or assigned something that cannot be evaluated
+ unknown = true;
+ }
+
+ break;
+ case RouteConstraintName:
+ if ( constant is { HasValue: true, Value: string name } )
+ {
+ constraintNames.Add( name );
+ }
+ else
+ {
+ unknown = true;
+ }
+
+ break;
+ case ApiVersionReader:
+ switch ( ResolveReader( context, assignment.Right ) )
+ {
+ case Reader.MediaType:
+ mediaType = true;
+ break;
+ case Reader.Undecided:
+ unknown = true;
+ break;
+ }
+
+ break;
+ }
+ }
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ if ( method.Name == Add && IsConventions( context, invocation ) )
+ {
+ OnConvention( context, invocation );
+ return;
+ }
+
+ if ( type.ToDisplayString() == Symbols.EndpointRouteBuilderExtensions &&
+ Endpoints.IsMapped( method.Name ) )
+ {
+ AddEndpoint( context, invocation, method );
+ }
+ }
+
+ public void OnNamedType( SymbolAnalysisContext context )
+ {
+ var type = (INamedTypeSymbol) context.Symbol;
+
+ if ( !Symbols.IsApiController( type ) )
+ {
+ return;
+ }
+
+ foreach ( var endpoint in Endpoints.FromController( type ) )
+ {
+ endpoints.Add( endpoint );
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ // a client that names no version is asking for the first one, whatever the endpoints declare
+ if ( unknown || mediaType || assumeDefaultSites.IsEmpty || endpoints.IsEmpty )
+ {
+ return;
+ }
+
+ if ( !Endpoints.TryResolveConstraintName( constraintNames, out var constraintName ) )
+ {
+ return;
+ }
+
+ foreach ( var endpoint in endpoints )
+ {
+ var constrained = 0;
+ var unconstrained = 0;
+
+ foreach ( var template in endpoint.Templates )
+ {
+ if ( RouteTemplate.HasConstraint( template, constraintName ) )
+ {
+ constrained++;
+ }
+ else
+ {
+ unconstrained++;
+ }
+ }
+
+ // registering the same endpoint with and without the constraint is the one way a
+ // default version can be applied to a URL segment, so it is a deliberate arrangement
+ if ( constrained > 0 && unconstrained > 0 )
+ {
+ return;
+ }
+
+ // an endpoint declaring nothing and reachable without naming a version is exactly
+ // what the default was meant for
+ var versioned = endpoint.Declared ||
+ ( versionByNamespace && NamespaceVersion.IsVersioned( endpoint.Namespace ) );
+
+ if ( !versioned && unconstrained > 0 )
+ {
+ return;
+ }
+ }
+
+ foreach ( var site in assumeDefaultSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0016_DoNotAssumeDefaultApiVersion, site ) );
+ }
+ }
+
+ /// Versioning by namespace is understood, so a controller declared in a versioned
+ /// namespace is versioned by it. Any other convention may version anything at all, which
+ /// leaves nothing that can be concluded about the endpoints.
+ private void OnConvention( SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation )
+ {
+ var arguments = invocation.ArgumentList.Arguments;
+
+ if ( arguments.Count != 1 ||
+ context.SemanticModel.GetTypeInfo( arguments[0].Expression, context.CancellationToken ).Type
+ is not { } type )
+ {
+ unknown = true;
+ return;
+ }
+
+ if ( type.ToDisplayString() == VersionByNamespaceConvention )
+ {
+ versionByNamespace = true;
+ }
+ else
+ {
+ unknown = true;
+ }
+ }
+
+ private static bool IsConventions( SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation ) =>
+ Routes.Receiver( invocation ) is { } receiver &&
+ context.SemanticModel.GetSymbolInfo( receiver, context.CancellationToken ).Symbol
+ is IPropertySymbol { Name: Conventions } property &&
+ property.ContainingType?.ToDisplayString() == Symbols.MvcApiVersioningOptions;
+
+ private void AddEndpoint(
+ SyntaxNodeAnalysisContext context,
+ InvocationExpressionSyntax invocation,
+ IMethodSymbol method )
+ {
+ var applied = new HashSet( StringComparer.Ordinal );
+
+ Routes.CollectChainedCalls( invocation, applied );
+
+ if ( Routes.GetArgument( context, invocation, method, "pattern" ) is not { } pattern )
+ {
+ unknown = true;
+ return;
+ }
+
+ var prefix = Routes.ResolveChain( context, Routes.Receiver( invocation ), applied, out var complete );
+
+ if ( !complete )
+ {
+ // a prefix that could not be followed may have carried the constraint
+ unknown = true;
+ return;
+ }
+
+ var versioned = applied.Overlaps( VersioningCalls );
+ var neutral = applied.Contains( IsApiVersionNeutral );
+
+ endpoints.Add( new( [prefix + "/" + pattern], versioned, neutral ) );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/DefaultApiVersionAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/DefaultApiVersionAnalyzer.cs
new file mode 100644
index 000000000..b61116fc0
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/DefaultApiVersionAnalyzer.cs
@@ -0,0 +1,192 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports a default API version which is either already the default or cannot be a default
+/// at all.
+///
+/// The options are matched by name because the API surface that declares them is not available to an analyzer,
+/// which the compiler requires to target netstandard2.0. A version-neutral default is invalid wherever it is written,
+/// whereas a redundant one is only redundant against the options that decide the default in the first place.
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class DefaultApiVersionAnalyzer : DiagnosticAnalyzer
+{
+ private const string DefaultApiVersion = nameof( DefaultApiVersion );
+ private const string Default = nameof( Default );
+ private const string Neutral = nameof( Neutral );
+ private const string ApiVersion = "Asp.Versioning.ApiVersion";
+
+ private static readonly HashSet OptionsTypes = new( StringComparer.Ordinal )
+ {
+ "Asp.Versioning.ApiVersioningOptions",
+ "Asp.Versioning.ApiExplorer.ApiExplorerOptions",
+ };
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(
+ AV0011_UnnecessaryDefaultApiVersion,
+ AV0012_NeutralDefaultApiVersion );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+
+ // an object initializer assigns through the same expression as a property does
+ context.RegisterSyntaxNodeAction( OnAssignment, SyntaxKind.SimpleAssignmentExpression );
+ }
+
+ private static void OnAssignment( SyntaxNodeAnalysisContext context )
+ {
+ var assignment = (AssignmentExpressionSyntax) context.Node;
+ var assigned = context.SemanticModel.GetSymbolInfo( assignment.Left, context.CancellationToken ).Symbol;
+
+ if ( assigned is not IPropertySymbol { Name: DefaultApiVersion } property ||
+ !IsVersioningOptions( property.ContainingType ) )
+ {
+ return;
+ }
+
+ if ( Classify( context, assignment.Right ) is not { } descriptor )
+ {
+ return;
+ }
+
+ // the API explorer is given whatever default the versioning options were given, so a version that
+ // matches is reported against what it came from rather than against the version declared here
+ if ( ReferenceEquals( descriptor, AV0011_UnnecessaryDefaultApiVersion ) &&
+ !Symbols.Declares( property.ContainingType, Symbols.ApiVersioningOptions ) )
+ {
+ return;
+ }
+
+ // the whole assignment is what is unnecessary and gets faded out, whereas a neutral version is a problem with
+ // the value alone
+ var location = ReferenceEquals( descriptor, AV0011_UnnecessaryDefaultApiVersion )
+ ? assignment.GetLocation()
+ : assignment.Right.GetLocation();
+
+ context.ReportDiagnostic( Diagnostic.Create( descriptor, location ) );
+ }
+
+ private static DiagnosticDescriptor? Classify( SyntaxNodeAnalysisContext context, ExpressionSyntax expression )
+ {
+ var symbol = context.SemanticModel.GetSymbolInfo( expression, context.CancellationToken ).Symbol;
+
+ if ( symbol is IPropertySymbol { IsStatic: true } wellKnown && IsApiVersion( wellKnown.ContainingType ) )
+ {
+ return wellKnown.Name switch
+ {
+ Default => AV0011_UnnecessaryDefaultApiVersion,
+ Neutral => AV0012_NeutralDefaultApiVersion,
+ _ => default,
+ };
+ }
+
+ if ( expression is BaseObjectCreationExpressionSyntax creation &&
+ symbol is IMethodSymbol ctor &&
+ IsApiVersion( ctor.ContainingType ) &&
+ IsDefaultApiVersion( context, ctor, creation.ArgumentList ) )
+ {
+ return AV0011_UnnecessaryDefaultApiVersion;
+ }
+
+ return default;
+ }
+
+ private static bool IsDefaultApiVersion(
+ SyntaxNodeAnalysisContext context,
+ IMethodSymbol ctor,
+ ArgumentListSyntax? list )
+ {
+ var arguments = list is null ? default : list.Arguments;
+ var version = default( double? );
+ var major = default( int? );
+ var minor = default( int? );
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ var argument = arguments[i];
+ var parameter = ResolveParameter( ctor.Parameters, argument.NameColon?.Name.Identifier.ValueText, i );
+
+ if ( parameter is null )
+ {
+ return false;
+ }
+
+ var constant = context.SemanticModel.GetConstantValue( argument.Expression, context.CancellationToken );
+
+ if ( !constant.HasValue )
+ {
+ return false;
+ }
+
+ switch ( parameter.Name )
+ {
+ case "version" when constant.Value is double number:
+ version = number;
+ break;
+ case "version" when constant.Value is int number:
+ version = number;
+ break;
+ case "majorVersion" when constant.Value is int number:
+ major = number;
+ break;
+ case "minorVersion" when constant.Value is null:
+ break;
+ case "minorVersion" when constant.Value is int number:
+ minor = number;
+ break;
+ case "status" when constant.Value is null:
+ break;
+ default:
+ return false;
+ }
+ }
+
+ return version is { } value ? value == 1d : major == 1 && minor is null or 0;
+ }
+
+ private static IParameterSymbol? ResolveParameter(
+ ImmutableArray parameters,
+ string? name,
+ int index )
+ {
+ if ( name is null )
+ {
+ return index < parameters.Length ? parameters[index] : default;
+ }
+
+ foreach ( var parameter in parameters )
+ {
+ if ( parameter.Name == name )
+ {
+ return parameter;
+ }
+ }
+
+ return default;
+ }
+
+ private static bool IsVersioningOptions( INamedTypeSymbol? type )
+ {
+ for ( var declaringType = type; declaringType is not null; declaringType = declaringType.BaseType )
+ {
+ if ( OptionsTypes.Contains( declaringType.ToDisplayString() ) )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool IsApiVersion( INamedTypeSymbol? type ) => type?.ToDisplayString() == ApiVersion;
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/DefaultValueAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/DefaultValueAnalyzer.cs
new file mode 100644
index 000000000..53a2d34f4
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/DefaultValueAnalyzer.cs
@@ -0,0 +1,103 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports an option assigned the value it already has.
+///
+///
+/// The default of a property is looked up by the type declaring it rather than by name alone, because
+/// the same name can carry a different default on a different option. An option whose default is an
+/// object rather than a value is left alone, as is the default API version, which is reported on its
+/// own because it can be spelled more than one way.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class DefaultValueAnalyzer : DiagnosticAnalyzer
+{
+ private const string Empty = nameof( Empty );
+
+ private static readonly Dictionary> Defaults =
+ new( StringComparer.Ordinal )
+ {
+ [Symbols.ApiVersioningOptions] = new( StringComparer.Ordinal )
+ {
+ ["RouteConstraintName"] = "apiVersion",
+ ["ReportApiVersions"] = false,
+ ["AssumeDefaultVersionWhenUnspecified"] = false,
+ ["UnsupportedApiVersionStatusCode"] = 400,
+ },
+
+ // the values the API explorer shares with API versioning are reported on their own, because
+ // what they default to is whatever the versioning options were given rather than what the
+ // property was declared with
+ [Symbols.ApiExplorerOptions] = new( StringComparer.Ordinal )
+ {
+ ["GroupNameFormat"] = string.Empty,
+ ["SubstitutionFormat"] = "VVV",
+ ["SubstituteApiVersionInUrl"] = false,
+ ["AddApiVersionParametersWhenVersionNeutral"] = false,
+ ["FormatGroupName"] = null,
+ },
+ [Symbols.ODataApiExplorerOptions] = new( StringComparer.Ordinal )
+ {
+ ["UseQualifiedNames"] = false,
+ ["MetadataOptions"] = 0,
+ },
+ };
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0017_DoNotSetDefaultValue );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ if ( Symbols.IsReferenced( context.Compilation, Symbols.ApiVersioningOptions ) )
+ {
+ context.RegisterSyntaxNodeAction( OnAssignment, SyntaxKind.SimpleAssignmentExpression );
+ }
+ }
+
+ private static void OnAssignment( SyntaxNodeAnalysisContext context )
+ {
+ var assignment = (AssignmentExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( assignment.Left, context.CancellationToken ).Symbol
+ is not IPropertySymbol property ||
+ property.ContainingType?.ToDisplayString() is not { } declaringType ||
+ !Defaults.TryGetValue( declaringType, out var defaults ) ||
+ !defaults.TryGetValue( property.Name, out var expected ) ||
+ !IsDefault( context, assignment.Right, expected ) )
+ {
+ return;
+ }
+
+ context.ReportDiagnostic( Diagnostic.Create( AV0017_DoNotSetDefaultValue, assignment.GetLocation() ) );
+ }
+
+ private static bool IsDefault( SyntaxNodeAnalysisContext context, ExpressionSyntax expression, object? expected )
+ {
+ var constant = context.SemanticModel.GetConstantValue( expression, context.CancellationToken );
+
+ if ( constant.HasValue )
+ {
+ return Equals( constant.Value, expected );
+ }
+
+ // string.Empty is a static field rather than a constant, but it is the same value
+ return expected is "" &&
+ context.SemanticModel.GetSymbolInfo( expression, context.CancellationToken ).Symbol
+ is IFieldSymbol { Name: Empty, ContainingType.SpecialType: SpecialType.System_String };
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/DescribeApiVersionsAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/DescribeApiVersionsAnalyzer.cs
new file mode 100644
index 000000000..1cf10ab75
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/DescribeApiVersionsAnalyzer.cs
@@ -0,0 +1,149 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports API version descriptions resolved before they can be complete.
+///
+///
+/// A description provider resolved from the services describes the APIs that were known when the services
+/// were built. Minimal APIs are mapped onto the application afterward, so they are not among them.
+/// Describing the versions from the application itself waits until every API has been mapped, which is why
+/// there was nothing to choose between before minimal APIs existed.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class DescribeApiVersionsAnalyzer : DiagnosticAnalyzer
+{
+ private const string AddApiExplorer = nameof( AddApiExplorer );
+ private const string AddODataApiExplorer = nameof( AddODataApiExplorer );
+ private const string AddGrpcApiExplorer = nameof( AddGrpcApiExplorer );
+ private const string AddOpenApi = nameof( AddOpenApi );
+ private const string GetService = nameof( GetService );
+ private const string GetRequiredService = nameof( GetRequiredService );
+ private const string ApiVersioningBuilderExtensions =
+ "Microsoft.Extensions.DependencyInjection.IApiVersioningBuilderExtensions";
+ private const string ServiceProviderServiceExtensions =
+ "Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions";
+ private const string ServiceProvider = "System.IServiceProvider";
+ private const string ApiVersionDescriptionProvider =
+ "Asp.Versioning.ApiExplorer.IApiVersionDescriptionProvider";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0027_UseDescribeApiVersions );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ if ( !Symbols.IsReferenced( context.Compilation, ApiVersionDescriptionProvider ) )
+ {
+ return;
+ }
+
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ /// The service can be asked for by type argument or by type, and either way through the
+ /// service provider itself or through the extensions that wrap it.
+ private static bool ResolvesDescriptions(
+ SyntaxNodeAnalysisContext context,
+ InvocationExpressionSyntax invocation,
+ IMethodSymbol method,
+ string declaringType )
+ {
+ if ( declaringType != ServiceProviderServiceExtensions && declaringType != ServiceProvider )
+ {
+ return false;
+ }
+
+ if ( method.TypeArguments.Length == 1 )
+ {
+ return method.TypeArguments[0].ToDisplayString() == ApiVersionDescriptionProvider;
+ }
+
+ var arguments = invocation.ArgumentList.Arguments;
+
+ for ( var i = 0; i < arguments.Count; i++ )
+ {
+ if ( arguments[i].Expression is TypeOfExpressionSyntax typeOf &&
+ context.SemanticModel.GetTypeInfo( typeOf.Type, context.CancellationToken ).Type
+ is { } type &&
+ type.ToDisplayString() == ApiVersionDescriptionProvider )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag resolutionCallSites = [];
+ private volatile bool explored;
+ private volatile bool mapped;
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ var declaringType = type.ToDisplayString();
+
+ switch ( method.Name )
+ {
+ case AddApiExplorer or AddODataApiExplorer or AddGrpcApiExplorer or AddOpenApi
+ when declaringType == ApiVersioningBuilderExtensions:
+ explored = true;
+ break;
+ case GetService or GetRequiredService
+ when ResolvesDescriptions( context, invocation, method, declaringType ):
+ resolutionCallSites.Add( Symbols.GetLocation( invocation ) );
+ break;
+ default:
+ if ( declaringType == Symbols.EndpointRouteBuilderExtensions &&
+ Endpoints.IsMapped( method.Name ) )
+ {
+ mapped = true;
+ }
+
+ break;
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ // without a minimal API there is nothing the services were built too early to know about
+ if ( !explored || !mapped || resolutionCallSites.IsEmpty )
+ {
+ return;
+ }
+
+ foreach ( var callSite in resolutionCallSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0027_UseDescribeApiVersions, callSite ) );
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/IgnoredRouteComponentsAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/IgnoredRouteComponentsAnalyzer.cs
new file mode 100644
index 000000000..fcda6dc48
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/IgnoredRouteComponentsAnalyzer.cs
@@ -0,0 +1,98 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports OData route components which are never applied.
+///
+///
+/// Versioned OData resolves the options for the API version of the current request, which the options
+/// configured for OData itself are not part of. Route components added without saying which API version
+/// they belong to are left behind when the options are resolved, and a prefix stated in both places
+/// collides once they are. Neither is a supported way to reach a versioned OData API.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class IgnoredRouteComponentsAnalyzer : DiagnosticAnalyzer
+{
+ private const string AddOData = nameof( AddOData );
+ private const string AddRouteComponents = nameof( AddRouteComponents );
+ private const string ODataOptions = "Microsoft.AspNetCore.OData.ODataOptions";
+ private const string ApiVersioningBuilderExtensions =
+ "Microsoft.Extensions.DependencyInjection.IApiVersioningBuilderExtensions";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0023_IgnoredRouteComponents );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ // the options that carry route components cannot be configured without the library that declares them
+ if ( !Symbols.IsReferenced( context.Compilation, ODataOptions ) )
+ {
+ return;
+ }
+
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag routeComponentCallSites = [];
+ private volatile bool versionsOData;
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ // the versioned options declare AddRouteComponents of their own, which is the correct one
+ var declaringType = type.ToDisplayString();
+
+ switch ( method.Name )
+ {
+ case AddOData when declaringType == ApiVersioningBuilderExtensions:
+ versionsOData = true;
+ break;
+ case AddRouteComponents when declaringType == ODataOptions:
+ routeComponentCallSites.Add( Symbols.GetLocation( invocation ) );
+ break;
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ // route components are applied as they are written until versioned OData replaces the options
+ if ( !versionsOData || routeComponentCallSites.IsEmpty )
+ {
+ return;
+ }
+
+ foreach ( var callSite in routeComponentCallSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0023_IgnoredRouteComponents, callSite ) );
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/InheritedApiExplorerOptionAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/InheritedApiExplorerOptionAnalyzer.cs
new file mode 100644
index 000000000..ae632d102
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/InheritedApiExplorerOptionAnalyzer.cs
@@ -0,0 +1,146 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports an API explorer option restating the value it already has.
+///
+///
+/// The API explorer takes the options it shares with API versioning before its own configuration runs, so
+/// stating a shared value again only repeats what it was already given. A value that differs is a
+/// deliberate departure from the versioning options and is left alone.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class InheritedApiExplorerOptionAnalyzer : DiagnosticAnalyzer
+{
+ /// The two options do not always agree on the name of a value they share.
+ private static readonly Dictionary Shared = new( StringComparer.Ordinal )
+ {
+ ["AssumeDefaultVersionWhenUnspecified"] = "AssumeDefaultVersionWhenUnspecified",
+ ["ApiVersionParameterSource"] = "ApiVersionReader",
+ ["DefaultApiVersion"] = "DefaultApiVersion",
+ ["RouteConstraintName"] = "RouteConstraintName",
+ ["ApiVersionSelector"] = "ApiVersionSelector",
+ };
+
+ /// The value the API explorer is given when the versioning options state none of their own.
+ /// The default selector is built from the options it belongs to and cannot be written by hand, so
+ /// there is nothing for a selector to match.
+ private static readonly Dictionary Defaults = new( StringComparer.Ordinal )
+ {
+ ["AssumeDefaultVersionWhenUnspecified"] = OptionValue.Constant( false ),
+ ["ApiVersionReader"] = OptionValue.Member( "Asp.Versioning.ApiVersionReader.Default" ),
+ ["DefaultApiVersion"] = OptionValue.DefaultApiVersion,
+ ["RouteConstraintName"] = OptionValue.Constant( "apiVersion" ),
+ ["ApiVersionSelector"] = default,
+ };
+
+ private static readonly HashSet Sources = new( Shared.Values, StringComparer.Ordinal );
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0024_InheritedApiExplorerOption );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ // nothing shares a value with options that are not there to be configured
+ if ( !Symbols.IsReferenced( context.Compilation, Symbols.ApiExplorerOptions ) )
+ {
+ return;
+ }
+
+ var analysis = new Analysis();
+
+ // an object initializer assigns through the same expression as a property does
+ context.RegisterSyntaxNodeAction( analysis.OnAssignment, SyntaxKind.SimpleAssignmentExpression );
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentDictionary versioning = new( StringComparer.Ordinal );
+ private readonly ConcurrentBag explorer = [];
+
+ public void OnAssignment( SyntaxNodeAnalysisContext context )
+ {
+ var assignment = (AssignmentExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( assignment.Left, context.CancellationToken ).Symbol
+ is not IPropertySymbol property )
+ {
+ return;
+ }
+
+ var declaringType = property.ContainingType;
+
+ if ( Symbols.Declares( declaringType, Symbols.ApiVersioningOptions ) )
+ {
+ if ( !Sources.Contains( property.Name ) )
+ {
+ return;
+ }
+
+ var value = Resolve( context, assignment );
+
+ // the same value stated twice is still that value, but two of them disagreeing leaves
+ // nothing that can be said about what the API explorer is given
+ versioning.AddOrUpdate(
+ property.Name,
+ value,
+ ( _, existing ) => existing == value ? existing : default );
+ }
+ else if ( Symbols.Declares( declaringType, Symbols.ApiExplorerOptions ) &&
+ Shared.ContainsKey( property.Name ) )
+ {
+ explorer.Add( new( property.Name, Resolve( context, assignment ), assignment.GetLocation() ) );
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ foreach ( var assignment in explorer )
+ {
+ if ( assignment.Value is null )
+ {
+ continue;
+ }
+
+ var source = Shared[assignment.Property];
+ var inherited = versioning.TryGetValue( source, out var configured )
+ ? configured
+ : Defaults[source];
+
+ if ( inherited == assignment.Value )
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create( AV0024_InheritedApiExplorerOption, assignment.Location ) );
+ }
+ }
+ }
+
+ private static string? Resolve( SyntaxNodeAnalysisContext context, AssignmentExpressionSyntax assignment ) =>
+ OptionValue.Resolve( context.SemanticModel, assignment.Right, context.CancellationToken );
+
+ private sealed class Assignment( string property, string? value, Location location )
+ {
+ public string Property { get; } = property;
+
+ public string? Value { get; } = value;
+
+ public Location Location { get; } = location;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingAddMvcAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingAddMvcAnalyzer.cs
new file mode 100644
index 000000000..4c97c1297
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingAddMvcAnalyzer.cs
@@ -0,0 +1,125 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports controller-based APIs which have not been versioned.
+///
+///
+/// Whether MVC is versioned cannot be decided from any single statement, so the calls that opt into controllers, into
+/// API versioning, and into versioning MVC are collected across the compilation and evaluated once it completes.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class MissingAddMvcAnalyzer : DiagnosticAnalyzer
+{
+ private const string AddControllers = nameof( AddControllers );
+ private const string AddMvcCore = nameof( AddMvcCore );
+ private const string AddMvc = nameof( AddMvc );
+ private const string AddApiVersioning = nameof( AddApiVersioning );
+ private const string MvcServiceCollectionExtensions =
+ "Microsoft.Extensions.DependencyInjection.MvcServiceCollectionExtensions";
+ private const string MvcCoreServiceCollectionExtensions =
+ "Microsoft.Extensions.DependencyInjection.MvcCoreServiceCollectionExtensions";
+ private const string ApiVersioningBuilderExtensions =
+ "Microsoft.Extensions.DependencyInjection.IApiVersioningBuilderExtensions";
+ private const string ServiceCollectionExtensions =
+ "Microsoft.Extensions.DependencyInjection.IServiceCollectionExtensions";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0013_MissingAddMvc );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ // controllers cannot exist without MVC, so there is nothing to walk the declared types for
+ if ( !Symbols.IsReferenced( context.Compilation, MvcServiceCollectionExtensions ) )
+ {
+ return;
+ }
+
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ // An extension member is declared in a synthetic, nested type that cannot be referred to by name, so the type that
+ // declares the member is its containing type
+ private static INamedTypeSymbol? ResolveDeclaringType( IMethodSymbol method )
+ {
+ var type = method.ContainingType;
+
+ return type is { ContainingType: { } declaringType } && !type.CanBeReferencedByName
+ ? declaringType
+ : type;
+ }
+
+ private static Location GetLocation( InvocationExpressionSyntax invocation ) =>
+ invocation.Expression is MemberAccessExpressionSyntax access
+ ? access.Name.GetLocation()
+ : invocation.Expression.GetLocation();
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag apiVersioningCallSites = [];
+ private volatile bool usesControllers;
+ private volatile bool versionsMvc;
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ // the name alone is ambiguous; MVC declares an unrelated AddMvc of its own
+ var declaringType = type.ToDisplayString();
+
+ switch ( method.Name )
+ {
+ case AddControllers when declaringType == MvcServiceCollectionExtensions:
+ case AddMvcCore when declaringType == MvcCoreServiceCollectionExtensions:
+ usesControllers = true;
+ break;
+ case AddMvc when declaringType == ApiVersioningBuilderExtensions:
+ versionsMvc = true;
+ break;
+ case AddApiVersioning when declaringType == ServiceCollectionExtensions:
+ apiVersioningCallSites.Add( GetLocation( invocation ) );
+ break;
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ // without a call to opt into API versioning there is nothing to report against, and nothing to correct
+ // because MVC was never versioned in the first place
+ if ( !usesControllers || versionsMvc || apiVersioningCallSites.IsEmpty )
+ {
+ return;
+ }
+
+ foreach ( var callSite in apiVersioningCallSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0013_MissingAddMvc, callSite ) );
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingAddODataAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingAddODataAnalyzer.cs
new file mode 100644
index 000000000..a69aff727
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingAddODataAnalyzer.cs
@@ -0,0 +1,109 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports OData APIs which have not been versioned.
+///
+///
+/// OData routes by its own conventions rather than by the routes API versioning otherwise observes, so
+/// versioning an OData API takes an explicit opt in. The API Explorer variant registers the versioned
+/// services it needs on its own, which is a supported way to describe an OData API without taking on the
+/// rest of them.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class MissingAddODataAnalyzer : DiagnosticAnalyzer
+{
+ private const string AddOData = nameof( AddOData );
+ private const string AddODataApiExplorer = nameof( AddODataApiExplorer );
+ private const string AddApiVersioning = nameof( AddApiVersioning );
+ private const string ODataMvcBuilderExtensions =
+ "Microsoft.AspNetCore.OData.ODataMvcBuilderExtensions";
+ private const string ODataMvcCoreBuilderExtensions =
+ "Microsoft.AspNetCore.OData.ODataMvcCoreBuilderExtensions";
+ private const string ApiVersioningBuilderExtensions =
+ "Microsoft.Extensions.DependencyInjection.IApiVersioningBuilderExtensions";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0022_MissingAddOData );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ // OData cannot be registered without the library that declares it, so there is nothing to match
+ if ( !Symbols.IsReferenced( context.Compilation, ODataMvcBuilderExtensions ) )
+ {
+ return;
+ }
+
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag apiVersioningCallSites = [];
+ private volatile bool usesOData;
+ private volatile bool versionsOData;
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ // the name alone is ambiguous; OData declares an AddOData of its own for MVC
+ var declaringType = type.ToDisplayString();
+
+ switch ( method.Name )
+ {
+ case AddOData when declaringType == ODataMvcBuilderExtensions:
+ case AddOData when declaringType == ODataMvcCoreBuilderExtensions:
+ usesOData = true;
+ break;
+ case AddOData when declaringType == ApiVersioningBuilderExtensions:
+ case AddODataApiExplorer when declaringType == ApiVersioningBuilderExtensions:
+ versionsOData = true;
+ break;
+ case AddApiVersioning when declaringType == Symbols.ServiceCollectionExtensions:
+ apiVersioningCallSites.Add( Symbols.GetLocation( invocation ) );
+ break;
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ // without a call to opt into API versioning there is nothing to report against, and nothing
+ // to correct because OData was never versioned in the first place
+ if ( !usesOData || versionsOData || apiVersioningCallSites.IsEmpty )
+ {
+ return;
+ }
+
+ foreach ( var callSite in apiVersioningCallSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0022_MissingAddOData, callSite ) );
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingApiBehaviorAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingApiBehaviorAnalyzer.cs
new file mode 100644
index 000000000..2b287f161
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingApiBehaviorAnalyzer.cs
@@ -0,0 +1,113 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports a controller which has not opted into API behavior.
+///
+///
+/// API behavior can be applied to an assembly, either in code or generated from a build, in which case it covers every
+/// controller and there is nothing left to report. It is otherwise applied per controller. A controller derived from
+/// Controller is assumed to serve a user interface rather than an API, which is the ambiguity that applying the
+/// attribute resolves.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class MissingApiBehaviorAnalyzer : DiagnosticAnalyzer
+{
+ private const string ApiControllerAttribute = "Microsoft.AspNetCore.Mvc.ApiControllerAttribute";
+ private const string ControllerBase = "Microsoft.AspNetCore.Mvc.ControllerBase";
+ private const string Controller = "Microsoft.AspNetCore.Mvc.Controller";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0014_MissingApiBehavior );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ // without MVC there are no controllers to apply API behavior to
+ if ( !Symbols.IsReferenced( context.Compilation, ControllerBase ) )
+ {
+ return;
+ }
+
+ if ( HasApiBehavior( context.Compilation.Assembly.GetAttributes() ) )
+ {
+ return;
+ }
+
+ context.RegisterSymbolAction( OnNamedType, SymbolKind.NamedType );
+ }
+
+ private static void OnNamedType( SymbolAnalysisContext context )
+ {
+ var type = (INamedTypeSymbol) context.Symbol;
+
+ if ( type is not { TypeKind: TypeKind.Class, IsAbstract: false, ContainingType: null } ||
+ !IsApiController( type ) ||
+ HasApiBehavior( type ) )
+ {
+ return;
+ }
+
+ var location = type.Locations.FirstOrDefault( location => location.IsInSource );
+
+ if ( location is not null )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0014_MissingApiBehavior, location ) );
+ }
+ }
+
+ private static bool IsApiController( INamedTypeSymbol type )
+ {
+ for ( var baseType = type.BaseType; baseType is not null; baseType = baseType.BaseType )
+ {
+ switch ( baseType.ToDisplayString() )
+ {
+ case Controller:
+ return false;
+ case ControllerBase:
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool HasApiBehavior( INamedTypeSymbol type )
+ {
+ for ( var declaringType = type; declaringType is not null; declaringType = declaringType.BaseType )
+ {
+ if ( HasApiBehavior( declaringType.GetAttributes() ) )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool HasApiBehavior( ImmutableArray attributes )
+ {
+ foreach ( var attribute in attributes )
+ {
+ if ( attribute.AttributeClass?.ToDisplayString() == ApiControllerAttribute )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingApiExplorerAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingApiExplorerAnalyzer.cs
new file mode 100644
index 000000000..4cefa5941
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingApiExplorerAnalyzer.cs
@@ -0,0 +1,147 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports an OpenAPI document generated without the API explorer that
+/// describes the APIs it is generated for.
+///
+///
+/// An OpenAPI document is generated from what the API explorer discovered, and what it discovers depends
+/// on how the APIs were built. OData and gRPC are each described by an explorer of their own, which
+/// nothing else registers on their behalf. Which builder the calls were made against is not tracked,
+/// because an application configures API versioning once however the calls are arranged.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class MissingApiExplorerAnalyzer : DiagnosticAnalyzer
+{
+ private const string AddOpenApi = nameof( AddOpenApi );
+ private const string AddOData = nameof( AddOData );
+ private const string AddGrpc = nameof( AddGrpc );
+ private const string AddApiExplorer = nameof( AddApiExplorer );
+ private const string AddODataApiExplorer = nameof( AddODataApiExplorer );
+ private const string AddGrpcApiExplorer = nameof( AddGrpcApiExplorer );
+ private const string ApiVersioningBuilderExtensions =
+ "Microsoft.Extensions.DependencyInjection.IApiVersioningBuilderExtensions";
+ private const string VersionedOpenApiOptions = "Asp.Versioning.OpenApi.VersionedOpenApiOptions";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0031_MissingApiExplorer );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ // no document is generated without the library that generates it
+ if ( !Symbols.IsReferenced( context.Compilation, VersionedOpenApiOptions ) )
+ {
+ return;
+ }
+
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag openApiCallSites = [];
+ private volatile bool odata;
+ private volatile bool grpc;
+ private volatile bool apiExplorer;
+ private volatile bool odataApiExplorer;
+ private volatile bool grpcApiExplorer;
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type ||
+ type.ToDisplayString() != ApiVersioningBuilderExtensions )
+ {
+ return;
+ }
+
+ // the names are shared with the services, which declare unrelated methods of their own
+ switch ( method.Name )
+ {
+ case AddOpenApi:
+ openApiCallSites.Add( Symbols.GetLocation( invocation ) );
+ break;
+ case AddOData:
+ odata = true;
+ break;
+ case AddGrpc:
+ grpc = true;
+ break;
+ case AddApiExplorer:
+ apiExplorer = true;
+ break;
+ case AddODataApiExplorer:
+ odataApiExplorer = true;
+ break;
+ case AddGrpcApiExplorer:
+ grpcApiExplorer = true;
+ break;
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ if ( openApiCallSites.IsEmpty )
+ {
+ return;
+ }
+
+ var missing = ImmutableArray.CreateBuilder( initialCapacity: 2 );
+
+ if ( odata && !odataApiExplorer )
+ {
+ missing.Add( AddODataApiExplorer );
+ }
+
+ if ( grpc && !grpcApiExplorer )
+ {
+ missing.Add( AddGrpcApiExplorer );
+ }
+
+ // an API built any other way is described by the explorer the rest of them build on, and a
+ // specialized explorer can be configured without the APIs it specializes in
+ if ( !odata && !grpc && !apiExplorer && !odataApiExplorer && !grpcApiExplorer )
+ {
+ missing.Add( AddApiExplorer );
+ }
+
+ if ( missing.Count == 0 )
+ {
+ return;
+ }
+
+ var explorers = missing.ToImmutable();
+
+ foreach ( var callSite in openApiCallSites )
+ {
+ for ( var i = 0; i < explorers.Length; i++ )
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create( AV0031_MissingApiExplorer, callSite, explorers[i] ) );
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingDocumentInfoAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingDocumentInfoAnalyzer.cs
new file mode 100644
index 000000000..5e9de62da
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/MissingDocumentInfoAnalyzer.cs
@@ -0,0 +1,91 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports an OpenAPI document left without the information describing it.
+///
+///
+/// What a document says about itself is taken from the assembly it is generated for, whether the attribute
+/// carrying it was written by hand or generated from the project. The assembly it is taken from is the one
+/// the application was started from, so a library that configures OpenAPI on the application's behalf has
+/// nothing to give. The title of a document is taken the same way, but the project supplies one whether it
+/// was asked for or not, so there is nothing to report about it.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class MissingDocumentInfoAnalyzer : DiagnosticAnalyzer
+{
+ private const string AddOpenApi = nameof( AddOpenApi );
+ private const string ApiVersioningBuilderExtensions =
+ "Microsoft.Extensions.DependencyInjection.IApiVersioningBuilderExtensions";
+ private const string AssemblyDescriptionAttribute = "System.Reflection.AssemblyDescriptionAttribute";
+ private const string VersionedOpenApiOptions = "Asp.Versioning.OpenApi.VersionedOpenApiOptions";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0025_MissingDocumentDescription );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ var compilation = context.Compilation;
+
+ if ( !Symbols.IsReferenced( compilation, VersionedOpenApiOptions ) ||
+ !IsApplication( compilation ) ||
+ HasValue( compilation.Assembly, AssemblyDescriptionAttribute ) )
+ {
+ return;
+ }
+
+ context.RegisterSyntaxNodeAction( OnInvocation, SyntaxKind.InvocationExpression );
+ }
+
+ /// The document is described from the assembly the application was started from, which is
+ /// only the assembly being compiled when that assembly is the application itself.
+ private static bool IsApplication( Compilation compilation ) =>
+ compilation.Options.OutputKind is OutputKind.ConsoleApplication or OutputKind.WindowsApplication;
+
+ private static bool HasValue( IAssemblySymbol assembly, string attributeName )
+ {
+ foreach ( var attribute in assembly.GetAttributes() )
+ {
+ if ( attribute.AttributeClass?.ToDisplayString() != attributeName )
+ {
+ continue;
+ }
+
+ // a value that is empty is left out of the document the same way a missing one is
+ return attribute.ConstructorArguments.Length == 1 &&
+ attribute.ConstructorArguments[0].Value is string value &&
+ !string.IsNullOrEmpty( value );
+ }
+
+ return false;
+ }
+
+ private static void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol { Name: AddOpenApi } method ||
+ Symbols.ResolveDeclaringType( method )?.ToDisplayString() != ApiVersioningBuilderExtensions )
+ {
+ return;
+ }
+
+ context.ReportDiagnostic(
+ Diagnostic.Create( AV0025_MissingDocumentDescription, Symbols.GetLocation( invocation ) ) );
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/PolicyEffectiveDateAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/PolicyEffectiveDateAnalyzer.cs
new file mode 100644
index 000000000..94c8bc419
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/PolicyEffectiveDateAnalyzer.cs
@@ -0,0 +1,194 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports an API sunset before it is deprecated.
+///
+///
+/// Deprecation announces that an API is going away and sunset is when it does, so the two are only in
+/// order when deprecation comes first; taking effect on the same day is allowed. Only policies some API
+/// reaches together are compared, and only when both state a date that can be read as written. A date
+/// that comes from somewhere else is left alone, because what it will be is not decided here.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class PolicyEffectiveDateAnalyzer : DiagnosticAnalyzer
+{
+ private const string Deprecate = nameof( Deprecate );
+ private const string Sunset = nameof( Sunset );
+ private const string Effective = nameof( Effective );
+ private const string PolicyBuilderExtensions = "Asp.Versioning.IApiVersioningPolicyBuilderExtensions";
+ private const string PolicyBuilder = "Asp.Versioning.IApiVersioningPolicyBuilder";
+ private const string EffectiveDateExtensions = "Asp.Versioning.IPolicyBuilderExtensions";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0028_SunsetBeforeDeprecation );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ if ( !Symbols.IsReferenced( context.Compilation, PolicyBuilder ) )
+ {
+ return;
+ }
+
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ /// The date is stated on the builder the policy returns, which is reached by continuing the
+ /// expression that declared it.
+ private static InvocationExpressionSyntax? FindEffective(
+ SyntaxNodeAnalysisContext context,
+ InvocationExpressionSyntax policy )
+ {
+ var expression = (ExpressionSyntax) policy;
+
+ while ( expression.Parent is MemberAccessExpressionSyntax access &&
+ access.Expression == expression &&
+ access.Parent is InvocationExpressionSyntax invocation )
+ {
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is IMethodSymbol { Name: Effective } method &&
+ Symbols.ResolveDeclaringType( method )?.ToDisplayString() == EffectiveDateExtensions )
+ {
+ return invocation;
+ }
+
+ expression = invocation;
+ }
+
+ return default;
+ }
+
+ /// A date is compared as the number it reads as, which orders the same way a date does
+ /// without having to be a valid one. A date is stated either as its parts or as a value built from
+ /// them, and one that comes from anywhere else cannot be read as written.
+ private static bool TryResolveDate(
+ SyntaxNodeAnalysisContext context,
+ SeparatedSyntaxList arguments,
+ out int date )
+ {
+ date = 0;
+
+ if ( arguments.Count == 1 )
+ {
+ return arguments[0].Expression is BaseObjectCreationExpressionSyntax creation &&
+ creation.ArgumentList is { } inner &&
+ TryResolveDate( context, inner.Arguments, out date );
+ }
+
+ if ( arguments.Count < 3 )
+ {
+ return false;
+ }
+
+ var parts = new int[3];
+
+ for ( var i = 0; i < parts.Length; i++ )
+ {
+ var constant = context.SemanticModel.GetConstantValue(
+ arguments[i].Expression,
+ context.CancellationToken );
+
+ if ( !constant.HasValue || constant.Value is not int part )
+ {
+ return false;
+ }
+
+ parts[i] = part;
+ }
+
+ date = ( parts[0] * 10000 ) + ( parts[1] * 100 ) + parts[2];
+ return true;
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag deprecations = [];
+ private readonly ConcurrentBag sunsets = [];
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ var declaringType = type.ToDisplayString();
+
+ if ( declaringType != PolicyBuilderExtensions && declaringType != PolicyBuilder )
+ {
+ return;
+ }
+
+ var declared = method.Name switch
+ {
+ Deprecate => deprecations,
+ Sunset => sunsets,
+ _ => default,
+ };
+
+ if ( declared is null ||
+ !PolicyKey.TryResolve( context, invocation, method, out var key ) ||
+ key.Unreachable ||
+ FindEffective( context, invocation ) is not { } effective ||
+ !TryResolveDate( context, effective.ArgumentList.Arguments, out var date ) )
+ {
+ return;
+ }
+
+ declared.Add( new( key, date, Symbols.GetLocation( effective ) ) );
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ if ( deprecations.IsEmpty || sunsets.IsEmpty )
+ {
+ return;
+ }
+
+ foreach ( var sunset in sunsets )
+ {
+ foreach ( var deprecation in deprecations )
+ {
+ // taking effect on the same day is in order, and only an earlier one is not
+ if ( sunset.Date < deprecation.Date && sunset.Key.Intersects( deprecation.Key ) )
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create( AV0028_SunsetBeforeDeprecation, sunset.Location ) );
+ break;
+ }
+ }
+ }
+ }
+
+ private sealed class Policy( PolicyKey key, int date, Location location )
+ {
+ public PolicyKey Key { get; } = key;
+
+ public int Date { get; } = date;
+
+ public Location Location { get; } = location;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/SpecificApiVersionReaderAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/SpecificApiVersionReaderAnalyzer.cs
new file mode 100644
index 000000000..78f5b0212
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/SpecificApiVersionReaderAnalyzer.cs
@@ -0,0 +1,241 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports an API reading versions one way without having said so.
+///
+///
+/// Without an explicit reader, a version is looked for in both the query string and the URL segment.
+/// Every route is examined to decide which of the two is actually used. A route that cannot be
+/// followed to its origin, and any mixture of the two styles, leaves the default in place, because
+/// narrowing the reader would then break a form the application relies on.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class SpecificApiVersionReaderAnalyzer : DiagnosticAnalyzer
+{
+ private const string UrlSegmentApiVersionReader = nameof( UrlSegmentApiVersionReader );
+ private const string QueryStringApiVersionReader = nameof( QueryStringApiVersionReader );
+ private const string ApiVersionReader = nameof( ApiVersionReader );
+ private const string RouteConstraintName = nameof( RouteConstraintName );
+ private const string AddApiVersioning = nameof( AddApiVersioning );
+ private const string AddRouteComponents = nameof( AddRouteComponents );
+ private const string IsApiVersionNeutral = nameof( IsApiVersionNeutral );
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0015_UseSpecificApiVersionReader );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnAssignment, SyntaxKind.SimpleAssignmentExpression );
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+
+ // controllers cannot exist without MVC, so there is nothing to walk the declared types for
+ if ( Symbols.IsReferenced( context.Compilation, Symbols.ControllerBase ) )
+ {
+ context.RegisterSymbolAction( analysis.OnNamedType, SymbolKind.NamedType );
+ }
+
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag apiVersioningCallSites = [];
+ private readonly ConcurrentBag routes = [];
+ private readonly ConcurrentBag constraintNames = [];
+ private volatile bool readerConfigured;
+ private volatile bool unknown;
+
+ public void OnAssignment( SyntaxNodeAnalysisContext context )
+ {
+ var assignment = (AssignmentExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( assignment.Left, context.CancellationToken ).Symbol
+ is not IPropertySymbol property ||
+ property.ContainingType?.ToDisplayString() != Symbols.ApiVersioningOptions )
+ {
+ return;
+ }
+
+ switch ( property.Name )
+ {
+ case ApiVersionReader:
+ readerConfigured = true;
+ break;
+ case RouteConstraintName:
+ var constant = context.SemanticModel.GetConstantValue( assignment.Right, context.CancellationToken );
+
+ if ( constant is { HasValue: true, Value: string name } )
+ {
+ constraintNames.Add( name );
+ }
+ else
+ {
+ // a constraint cannot be recognized in a template without knowing its name
+ unknown = true;
+ }
+
+ break;
+ }
+ }
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ var declaringType = type.ToDisplayString();
+
+ if ( method.Name == AddApiVersioning && declaringType == Symbols.ServiceCollectionExtensions )
+ {
+ apiVersioningCallSites.Add( Symbols.GetLocation( invocation ) );
+ }
+ else if ( method.Name == AddRouteComponents && declaringType == Symbols.ODataApiVersioningOptions )
+ {
+ AddODataRoute( context, invocation, method );
+ }
+ else if ( declaringType == Symbols.EndpointRouteBuilderExtensions && Endpoints.IsMapped( method.Name ) )
+ {
+ AddEndpointRoute( context, invocation, method );
+ }
+ }
+
+ public void OnNamedType( SymbolAnalysisContext context )
+ {
+ var type = (INamedTypeSymbol) context.Symbol;
+
+ if ( !Symbols.IsApiController( type ) ||
+ Symbols.HasAttribute( type, Symbols.ApiVersionNeutralAttribute ) )
+ {
+ return;
+ }
+
+ foreach ( var endpoint in Endpoints.FromController( type ) )
+ {
+ foreach ( var template in endpoint.Templates )
+ {
+ routes.Add( new( template, complete: true ) );
+ }
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ if ( readerConfigured || unknown || apiVersioningCallSites.IsEmpty || routes.IsEmpty )
+ {
+ return;
+ }
+
+ if ( !Endpoints.TryResolveConstraintName( constraintNames, out var constraintName ) )
+ {
+ return;
+ }
+
+ var urlSegment = false;
+ var queryString = false;
+
+ foreach ( var route in routes )
+ {
+ if ( RouteTemplate.HasConstraint( route.Template, constraintName ) )
+ {
+ urlSegment = true;
+ }
+ else if ( route.Complete )
+ {
+ queryString = true;
+ }
+ else
+ {
+ // a prefix that could not be followed may have carried the constraint
+ return;
+ }
+
+ // the first mixture of the two styles is enough to leave the default alone
+ if ( urlSegment && queryString )
+ {
+ return;
+ }
+ }
+
+ var reader = urlSegment ? UrlSegmentApiVersionReader : QueryStringApiVersionReader;
+
+ foreach ( var callSite in apiVersioningCallSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0015_UseSpecificApiVersionReader, callSite, reader ) );
+ }
+ }
+
+ private void AddODataRoute(
+ SyntaxNodeAnalysisContext context,
+ InvocationExpressionSyntax invocation,
+ IMethodSymbol method )
+ {
+ // the prefix applies to every OData controller registered with it
+ if ( method.Parameters.Length == 0 || method.Parameters[0].Type.SpecialType != SpecialType.System_String )
+ {
+ routes.Add( new( string.Empty, complete: true ) );
+ }
+ else if ( Routes.GetArgument( context, invocation, method, "prefix" ) is { } prefix )
+ {
+ routes.Add( new( prefix, complete: true ) );
+ }
+ else
+ {
+ unknown = true;
+ }
+ }
+
+ private void AddEndpointRoute(
+ SyntaxNodeAnalysisContext context,
+ InvocationExpressionSyntax invocation,
+ IMethodSymbol method )
+ {
+ var applied = new HashSet( StringComparer.Ordinal );
+
+ Routes.CollectChainedCalls( invocation, applied );
+
+ if ( applied.Contains( IsApiVersionNeutral ) )
+ {
+ return;
+ }
+
+ if ( Routes.GetArgument( context, invocation, method, "pattern" ) is not { } pattern )
+ {
+ unknown = true;
+ return;
+ }
+
+ var prefix = Routes.ResolveChain( context, Routes.Receiver( invocation ), applied, out var complete );
+
+ if ( applied.Contains( IsApiVersionNeutral ) )
+ {
+ return;
+ }
+
+ routes.Add( new( prefix + "/" + pattern, complete ) );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/UnusedGroupNameFormatAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/UnusedGroupNameFormatAnalyzer.cs
new file mode 100644
index 000000000..40333df9b
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/UnusedGroupNameFormatAnalyzer.cs
@@ -0,0 +1,188 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports a group name format which nothing is formatted by.
+///
+///
+/// The callback is only reached for an API that has a group name; an API without one is described by its
+/// API version alone. A group name can be stated by a controller, by a mapped endpoint, or by a group of
+/// them, and one anywhere in the application is enough to put the callback to use. Group names can also be
+/// supplied by an implementation of their own, which says nothing about whether any are set.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class UnusedGroupNameFormatAnalyzer : DiagnosticAnalyzer
+{
+ private const string FormatGroupName = nameof( FormatGroupName );
+ private const string GroupName = nameof( GroupName );
+ private const string WithGroupName = nameof( WithGroupName );
+ private const string ApiExplorerSettingsAttribute = "Microsoft.AspNetCore.Mvc.ApiExplorerSettingsAttribute";
+ private const string EndpointGroupNameAttribute = "Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute";
+ private const string RoutingEndpointConventionBuilderExtensions =
+ "Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions";
+ private const string ApiDescriptionGroupNameProvider =
+ "Microsoft.AspNetCore.Mvc.ApiExplorer.IApiDescriptionGroupNameProvider";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0026_UnusedGroupNameFormat );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ if ( !Symbols.IsReferenced( context.Compilation, Symbols.ApiExplorerOptions ) )
+ {
+ return;
+ }
+
+ var analysis = new Analysis();
+
+ // an object initializer assigns through the same expression as a property does
+ context.RegisterSyntaxNodeAction( analysis.OnAssignment, SyntaxKind.SimpleAssignmentExpression );
+ context.RegisterSyntaxNodeAction( analysis.OnAttribute, SyntaxKind.Attribute );
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterSymbolAction( analysis.OnNamedType, SymbolKind.NamedType );
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ /// A name that cannot be read as it is written may still be one at run time, so it counts as
+ /// a name rather than as the absence of one.
+ private static bool IsGroupName( SyntaxNodeAnalysisContext context, ExpressionSyntax expression )
+ {
+ var constant = context.SemanticModel.GetConstantValue( expression, context.CancellationToken );
+
+ return !constant.HasValue ||
+ ( constant.Value is string name && !string.IsNullOrEmpty( name ) );
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag formatCallSites = [];
+ private volatile bool grouped;
+ private volatile bool surfaced;
+ private volatile bool unknown;
+
+ public void OnAssignment( SyntaxNodeAnalysisContext context )
+ {
+ var assignment = (AssignmentExpressionSyntax) context.Node;
+
+ // a callback that is cleared rather than provided is never reached to begin with
+ if ( context.SemanticModel.GetSymbolInfo( assignment.Left, context.CancellationToken ).Symbol
+ is not IPropertySymbol { Name: FormatGroupName } property ||
+ !Symbols.Declares( property.ContainingType, Symbols.ApiExplorerOptions ) ||
+ assignment.Right.IsKind( SyntaxKind.NullLiteralExpression ) ||
+ assignment.Right.IsKind( SyntaxKind.DefaultLiteralExpression ) )
+ {
+ return;
+ }
+
+ formatCallSites.Add( assignment.GetLocation() );
+ }
+
+ public void OnAttribute( SyntaxNodeAnalysisContext context )
+ {
+ var attribute = (AttributeSyntax) context.Node;
+
+ if ( attribute.ArgumentList is not { } list ||
+ context.SemanticModel.GetSymbolInfo( attribute, context.CancellationToken ).Symbol
+ is not IMethodSymbol constructor )
+ {
+ return;
+ }
+
+ var type = constructor.ContainingType;
+
+ if ( Symbols.Declares( type, ApiExplorerSettingsAttribute ) )
+ {
+ foreach ( var argument in list.Arguments )
+ {
+ if ( argument.NameEquals?.Name.Identifier.ValueText == GroupName &&
+ IsGroupName( context, argument.Expression ) )
+ {
+ grouped = true;
+ return;
+ }
+ }
+ }
+ else if ( Symbols.Declares( type, EndpointGroupNameAttribute ) &&
+ list.Arguments.Count > 0 &&
+ IsGroupName( context, list.Arguments[0].Expression ) )
+ {
+ grouped = true;
+ }
+ }
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ var declaringType = type.ToDisplayString();
+ var arguments = invocation.ArgumentList.Arguments;
+
+ if ( method.Name == WithGroupName && declaringType == RoutingEndpointConventionBuilderExtensions )
+ {
+ if ( arguments.Count > 0 && IsGroupName( context, arguments[0].Expression ) )
+ {
+ grouped = true;
+ }
+ }
+ else if ( declaringType == Symbols.EndpointRouteBuilderExtensions && Endpoints.IsMapped( method.Name ) )
+ {
+ surfaced = true;
+ }
+ }
+
+ public void OnNamedType( SymbolAnalysisContext context )
+ {
+ var type = (INamedTypeSymbol) context.Symbol;
+
+ if ( Symbols.IsApiController( type ) )
+ {
+ surfaced = true;
+ }
+
+ foreach ( var contract in type.AllInterfaces )
+ {
+ if ( contract.ToDisplayString() == ApiDescriptionGroupNameProvider )
+ {
+ unknown = true;
+ break;
+ }
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ // an application whose APIs are declared elsewhere keeps its group names there as well
+ if ( grouped || unknown || !surfaced || formatCallSites.IsEmpty )
+ {
+ return;
+ }
+
+ foreach ( var callSite in formatCallSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0026_UnusedGroupNameFormat, callSite ) );
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/VersionedAndNeutralAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/VersionedAndNeutralAnalyzer.cs
new file mode 100644
index 000000000..fc90a375c
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/VersionedAndNeutralAnalyzer.cs
@@ -0,0 +1,265 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports an API declared both versioned and version-neutral.
+///
+///
+/// Versioning metadata is inherited from a controller or an endpoint group as a convenience, and an
+/// action may state something more explicit in its place. The exception is neutrality, which applies to
+/// the whole API; an action cannot meaningfully claim a version of an API that has none. Controllers
+/// are collated by logical name, so a neutral declaration on one can silence versions declared on
+/// another that collates alongside it.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class VersionedAndNeutralAnalyzer : DiagnosticAnalyzer
+{
+ private const string IsApiVersionNeutral = nameof( IsApiVersionNeutral );
+ private const string ControllerNameConvention = "Asp.Versioning.Conventions.ControllerNameConvention";
+ private const string ControllerNameConventionOf = "Asp.Versioning.Conventions.IControllerNameConvention";
+
+ private static readonly HashSet VersioningCalls = new( StringComparer.Ordinal )
+ {
+ "HasApiVersion", "HasDeprecatedApiVersion",
+ };
+
+ /// Collation applies GroupName over NormalizeName, and only these two trim trailing
+ /// numbers between them. Any other convention collates by rules this cannot reproduce.
+ private static readonly HashSet TrimmingConventions = new( StringComparer.Ordinal )
+ {
+ "Asp.Versioning.Conventions.DefaultControllerNameConvention",
+ "Asp.Versioning.Conventions.GroupedControllerNameConvention",
+ "Default",
+ "Grouped",
+ };
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create( AV0019_VersionedAndNeutral );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+
+ // controllers cannot exist without MVC, so there is nothing to walk the declared types for
+ if ( Symbols.IsReferenced( context.Compilation, Symbols.ControllerBase ) )
+ {
+ context.RegisterSymbolAction( analysis.OnNamedType, SymbolKind.NamedType );
+ }
+
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ private static Location? GetLocation( ISymbol symbol, string attributeName )
+ {
+ foreach ( var attribute in symbol.GetAttributes() )
+ {
+ if ( attribute.AttributeClass?.ToDisplayString() == attributeName &&
+ attribute.ApplicationSyntaxReference is { } reference )
+ {
+ return Location.Create( reference.SyntaxTree, reference.Span );
+ }
+ }
+
+ return default;
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentDictionary apis = new( StringComparer.Ordinal );
+ private readonly ConcurrentBag incongruent = [];
+ private volatile bool unknown;
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method )
+ {
+ return;
+ }
+
+ if ( ReplacesNameConvention( context, invocation, method ) )
+ {
+ return;
+ }
+
+ if ( Symbols.ResolveDeclaringType( method )?.ToDisplayString() !=
+ Symbols.EndpointRouteBuilderExtensions ||
+ !Endpoints.IsMapped( method.Name ) )
+ {
+ return;
+ }
+
+ var self = new HashSet( StringComparer.Ordinal );
+ var inherited = new HashSet( StringComparer.Ordinal );
+
+ Routes.CollectChainedCalls( invocation, self );
+ Routes.ResolveChain( context, Routes.Receiver( invocation ), inherited, out var complete );
+
+ if ( !complete )
+ {
+ // a group that could not be followed may declare either of the two
+ unknown = true;
+ return;
+ }
+
+ // neutrality declared above the endpoint cannot be narrowed to a version below it, and
+ // neither can the two be declared together at the same level
+ var neutralAbove = inherited.Contains( IsApiVersionNeutral );
+ var versioned = self.Overlaps( VersioningCalls ) || inherited.Overlaps( VersioningCalls );
+ var both = self.Contains( IsApiVersionNeutral ) && self.Overlaps( VersioningCalls );
+
+ if ( ( neutralAbove && versioned ) || both )
+ {
+ incongruent.Add( Symbols.GetLocation( invocation ) );
+ }
+ }
+
+ /// The naming convention decides how controllers collate, and it can be replaced
+ /// through the service collection. A replacement that is not one of the built-in trimming
+ /// conventions collates by rules that cannot be reproduced here.
+ private bool ReplacesNameConvention(
+ SyntaxNodeAnalysisContext context,
+ InvocationExpressionSyntax invocation,
+ IMethodSymbol method )
+ {
+ var replaces = false;
+ var recognized = false;
+
+ foreach ( var typeArgument in method.TypeArguments )
+ {
+ var name = typeArgument.ToDisplayString();
+
+ replaces |= name == ControllerNameConventionOf;
+ recognized |= TrimmingConventions.Contains( name );
+ }
+
+ foreach ( var argument in invocation.ArgumentList.Arguments )
+ {
+ var type = context.SemanticModel.GetTypeInfo( argument.Expression, context.CancellationToken );
+
+ replaces |= type.Type?.ToDisplayString() == ControllerNameConventionOf ||
+ type.ConvertedType?.ToDisplayString() == ControllerNameConventionOf;
+
+ // the built-in conventions are reached through a property rather than a type
+ recognized |= context.SemanticModel.GetSymbolInfo( argument.Expression, context.CancellationToken )
+ .Symbol is IPropertySymbol { IsStatic: true } convention &&
+ convention.ContainingType?.ToDisplayString() == ControllerNameConvention &&
+ TrimmingConventions.Contains( convention.Name );
+ }
+
+ if ( replaces && !recognized )
+ {
+ unknown = true;
+ }
+
+ return replaces;
+ }
+
+ public void OnNamedType( SymbolAnalysisContext context )
+ {
+ var type = (INamedTypeSymbol) context.Symbol;
+
+ if ( !Symbols.IsApiController( type ) )
+ {
+ return;
+ }
+
+ if ( !ControllerName.TryResolve( type, out var name ) )
+ {
+ unknown = true;
+ return;
+ }
+
+ var api = apis.GetOrAdd( name, static _ => new() );
+ var neutral = Symbols.HasAttribute( type, Symbols.ApiVersionNeutralAttribute );
+
+ if ( neutral )
+ {
+ api.Neutral = true;
+ }
+
+ if ( GetLocation( type, Symbols.ApiVersionAttribute ) is { } declared )
+ {
+ api.Versioned.Add( declared );
+ }
+
+ foreach ( var member in type.GetMembers() )
+ {
+ if ( member is not IMethodSymbol action ||
+ action.MethodKind != MethodKind.Ordinary ||
+ action.DeclaredAccessibility != Accessibility.Public ||
+ action.IsStatic )
+ {
+ continue;
+ }
+
+ if ( GetLocation( action, Symbols.ApiVersionAttribute ) is not { } version )
+ {
+ continue;
+ }
+
+ // an action stating both is incongruent on its own, without regard to collation
+ if ( Symbols.HasAttribute( action, Symbols.ApiVersionNeutralAttribute ) )
+ {
+ incongruent.Add( version );
+ }
+ else
+ {
+ api.Versioned.Add( version );
+ }
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ if ( unknown )
+ {
+ return;
+ }
+
+ foreach ( var location in incongruent )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0019_VersionedAndNeutral, location ) );
+ }
+
+ foreach ( var api in apis.Values )
+ {
+ if ( !api.Neutral )
+ {
+ continue;
+ }
+
+ foreach ( var location in api.Versioned )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0019_VersionedAndNeutral, location ) );
+ }
+ }
+ }
+
+ private sealed class Api
+ {
+ public ConcurrentBag Versioned { get; } = [];
+
+ public bool Neutral { get; set; }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/VersionedOpenApiAnalyzer.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/VersionedOpenApiAnalyzer.cs
new file mode 100644
index 000000000..c2a92c2a8
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Rules/VersionedOpenApiAnalyzer.cs
@@ -0,0 +1,190 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable IDE0130
+
+namespace Asp.Versioning.Analyzers;
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using static Descriptor;
+using static Microsoft.CodeAnalysis.Diagnostics.GeneratedCodeAnalysisFlags;
+
+///
+/// Represents an analyzer that reports OpenAPI configured without regard to API versions.
+///
+///
+/// Versioned OpenAPI registers services of its own in place of the ones OpenAPI registers for itself,
+/// which describe a single document that knows nothing about API versions. The endpoint serving the
+/// documents resolves them from the services of the request it is answering, which is only where the
+/// versioned documents are to be found once the endpoint has been told to look there.
+///
+[DiagnosticAnalyzer( LanguageNames.CSharp )]
+public sealed class VersionedOpenApiAnalyzer : DiagnosticAnalyzer
+{
+ private const string AddApiExplorer = nameof( AddApiExplorer );
+ private const string AddODataApiExplorer = nameof( AddODataApiExplorer );
+ private const string AddGrpcApiExplorer = nameof( AddGrpcApiExplorer );
+ private const string AddOpenApi = nameof( AddOpenApi );
+ private const string MapOpenApi = nameof( MapOpenApi );
+ private const string WithDocumentPerVersion = nameof( WithDocumentPerVersion );
+ private const string ApiVersioningBuilderExtensions =
+ "Microsoft.Extensions.DependencyInjection.IApiVersioningBuilderExtensions";
+ private const string OpenApiServiceCollectionExtensions =
+ "Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions";
+ private const string OpenApiEndpointRouteBuilderExtensions =
+ "Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions";
+ private const string EndpointConventionBuilderExtensions =
+ "Microsoft.AspNetCore.Builder.IEndpointConventionBuilderExtensions";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(
+ AV0029_UnnecessaryOpenApiServices,
+ AV0030_MissingDocumentPerVersion );
+
+ public override void Initialize( AnalysisContext context )
+ {
+ context.ConfigureGeneratedCodeAnalysis( Analyze | ReportDiagnostics );
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction( OnCompilationStart );
+ }
+
+ private static void OnCompilationStart( CompilationStartAnalysisContext context )
+ {
+ // neither the services nor the endpoint exist to be configured without the library declaring them
+ if ( !Symbols.IsReferenced( context.Compilation, OpenApiServiceCollectionExtensions ) )
+ {
+ return;
+ }
+
+ var analysis = new Analysis();
+
+ context.RegisterSyntaxNodeAction( analysis.OnInvocation, SyntaxKind.InvocationExpression );
+ context.RegisterCompilationEndAction( analysis.OnCompilationEnd );
+ }
+
+ private static bool IsCall( SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation, string name, string declaringType ) =>
+ context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is IMethodSymbol method &&
+ method.Name == name &&
+ Symbols.ResolveDeclaringType( method )?.ToDisplayString() == declaringType;
+
+ /// The endpoint is told to serve a document per version by continuing the expression that
+ /// mapped it.
+ private static bool IsDocumentPerVersion( SyntaxNodeAnalysisContext context, InvocationExpressionSyntax mapped )
+ {
+ var expression = (ExpressionSyntax) mapped;
+
+ while ( expression.Parent is MemberAccessExpressionSyntax access &&
+ access.Expression == expression &&
+ access.Parent is InvocationExpressionSyntax invocation )
+ {
+ if ( IsCall( context, invocation, WithDocumentPerVersion, EndpointConventionBuilderExtensions ) )
+ {
+ return true;
+ }
+
+ expression = invocation;
+ }
+
+ return false;
+ }
+
+ /// Reading the expression the other way tells whether a convention belongs to an endpoint
+ /// this can see, because one applied anywhere else is applied to something unknown.
+ private static bool FollowsMapOpenApi( SyntaxNodeAnalysisContext context, InvocationExpressionSyntax decoration )
+ {
+ var expression = decoration.Expression;
+
+ while ( expression is MemberAccessExpressionSyntax access )
+ {
+ if ( access.Expression is not InvocationExpressionSyntax inner )
+ {
+ return false;
+ }
+
+ if ( IsCall( context, inner, MapOpenApi, OpenApiEndpointRouteBuilderExtensions ) )
+ {
+ return true;
+ }
+
+ expression = inner.Expression;
+ }
+
+ return false;
+ }
+
+ private sealed class Analysis
+ {
+ private readonly ConcurrentBag serviceCallSites = [];
+ private readonly ConcurrentBag mappedCallSites = [];
+ private volatile bool versioned;
+ private volatile bool unknown;
+
+ public void OnInvocation( SyntaxNodeAnalysisContext context )
+ {
+ var invocation = (InvocationExpressionSyntax) context.Node;
+
+ if ( context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is not IMethodSymbol method ||
+ Symbols.ResolveDeclaringType( method ) is not { } type )
+ {
+ return;
+ }
+
+ // OpenAPI declares an AddOpenApi of its own, which is the one that knows nothing of versions
+ var declaringType = type.ToDisplayString();
+
+ switch ( method.Name )
+ {
+ case AddApiExplorer or AddODataApiExplorer or AddGrpcApiExplorer or AddOpenApi
+ when declaringType == ApiVersioningBuilderExtensions:
+ versioned = true;
+ break;
+ case AddOpenApi when declaringType == OpenApiServiceCollectionExtensions:
+ serviceCallSites.Add(
+ invocation.Parent is ExpressionStatementSyntax statement
+ ? statement.GetLocation()
+ : invocation.GetLocation() );
+ break;
+ case MapOpenApi when declaringType == OpenApiEndpointRouteBuilderExtensions:
+ if ( !IsDocumentPerVersion( context, invocation ) )
+ {
+ mappedCallSites.Add( Symbols.GetLocation( invocation ) );
+ }
+
+ break;
+ case WithDocumentPerVersion when declaringType == EndpointConventionBuilderExtensions:
+ // applied to an endpoint reached some other way, which may well be the mapped one
+ if ( !FollowsMapOpenApi( context, invocation ) )
+ {
+ unknown = true;
+ }
+
+ break;
+ }
+ }
+
+ public void OnCompilationEnd( CompilationAnalysisContext context )
+ {
+ if ( !versioned )
+ {
+ return;
+ }
+
+ foreach ( var callSite in serviceCallSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0029_UnnecessaryOpenApiServices, callSite ) );
+ }
+
+ if ( unknown )
+ {
+ return;
+ }
+
+ foreach ( var callSite in mappedCallSites )
+ {
+ context.ReportDiagnostic( Diagnostic.Create( AV0030_MissingDocumentPerVersion, callSite ) );
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Symbols.cs b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Symbols.cs
new file mode 100644
index 000000000..ce493c41a
--- /dev/null
+++ b/src/Analyzers/src/Asp.Versioning.Api.Analyzers/Symbols.cs
@@ -0,0 +1,110 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+///
+/// The API surface the rules apply to is matched by name because it is not available to an analyzer, which the compiler
+/// requires to target netstandard2.0.
+///
+internal static class Symbols
+{
+ public const string ApiVersioningOptions = "Asp.Versioning.ApiVersioningOptions";
+ public const string MvcApiVersioningOptions = "Asp.Versioning.MvcApiVersioningOptions";
+ public const string ODataApiVersioningOptions = "Asp.Versioning.OData.ODataApiVersioningOptions";
+ public const string ApiExplorerOptions = "Asp.Versioning.ApiExplorer.ApiExplorerOptions";
+ public const string ODataApiExplorerOptions = "Asp.Versioning.ApiExplorer.ODataApiExplorerOptions";
+ public const string ServiceCollectionExtensions =
+ "Microsoft.Extensions.DependencyInjection.IServiceCollectionExtensions";
+ public const string EndpointRouteBuilderExtensions =
+ "Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions";
+ public const string WebApplication = "Microsoft.AspNetCore.Builder.WebApplication";
+ public const string ControllerBase = "Microsoft.AspNetCore.Mvc.ControllerBase";
+ public const string Controller = "Microsoft.AspNetCore.Mvc.Controller";
+ public const string ODataController = "Microsoft.AspNetCore.OData.Routing.Controllers.ODataController";
+ public const string RouteAttribute = "Microsoft.AspNetCore.Mvc.RouteAttribute";
+ public const string HttpMethodAttributePrefix = "Microsoft.AspNetCore.Mvc.Http";
+ public const string ApiVersionAttribute = "Asp.Versioning.ApiVersionAttribute";
+ public const string ApiVersionNeutralAttribute = "Asp.Versioning.ApiVersionNeutralAttribute";
+
+ // an extension member is declared in a synthetic, nested type that cannot be referred to by name, so the type
+ // that declares the member is its containing type.
+ public static INamedTypeSymbol? ResolveDeclaringType( IMethodSymbol method )
+ {
+ var type = method.ContainingType;
+
+ return type is { ContainingType: { } declaringType } && !type.CanBeReferencedByName
+ ? declaringType
+ : type;
+ }
+
+ public static string? GetDeclaringType( SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation ) =>
+ context.SemanticModel.GetSymbolInfo( invocation, context.CancellationToken ).Symbol
+ is IMethodSymbol method && ResolveDeclaringType( method ) is { } type
+ ? type.ToDisplayString()
+ : default;
+
+ public static bool Inherits( INamedTypeSymbol type, string baseTypeName )
+ {
+ for ( var baseType = type.BaseType; baseType is not null; baseType = baseType.BaseType )
+ {
+ if ( baseType.ToDisplayString() == baseTypeName )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Determines whether a type is, or derives from, a named type.
+ ///
+ /// A member declared by a base type is reached through the type that derives from it, so the
+ /// type a member appears to belong to is not always the type that declares it.
+ public static bool Declares( INamedTypeSymbol? type, string typeName )
+ {
+ for ( var declaringType = type; declaringType is not null; declaringType = declaringType.BaseType )
+ {
+ if ( declaringType.ToDisplayString() == typeName )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static bool HasAttribute( ISymbol symbol, string attributeName )
+ {
+ foreach ( var attribute in symbol.GetAttributes() )
+ {
+ if ( attribute.AttributeClass?.ToDisplayString() == attributeName )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // a controller derived from Controller serves a user interface, which is never versioned, and one derived from
+ // ODataController is routed by its registered components rather than by an attribute
+ public static bool IsApiController( INamedTypeSymbol type ) =>
+ type is { TypeKind: TypeKind.Class, IsAbstract: false } &&
+ Inherits( type, ControllerBase ) &&
+ !Inherits( type, Controller ) &&
+ !Inherits( type, ODataController );
+
+ ///
+ /// Determines whether a type is available to a compilation.
+ ///
+ /// A rule that depends on a specialized variant has nothing to match when the variant is
+ /// not referenced, so the work it would do can be skipped entirely.
+ public static bool IsReferenced( Compilation compilation, string typeName ) =>
+ compilation.GetTypeByMetadataName( typeName ) is not null;
+
+ public static Location GetLocation( InvocationExpressionSyntax invocation ) =>
+ invocation.Expression is MemberAccessExpressionSyntax access
+ ? access.Name.GetLocation()
+ : invocation.Expression.GetLocation();
+}
\ No newline at end of file
diff --git a/src/Analyzers/src/Directory.Build.props b/src/Analyzers/src/Directory.Build.props
new file mode 100644
index 000000000..c8dc869f0
--- /dev/null
+++ b/src/Analyzers/src/Directory.Build.props
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+ $(NoWarn);1591;CS3021
+ false
+ true
+ false
+ true
+ true
+
+
+ $(DefineConstants);ANALYZER
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Analyzers/src/Directory.Build.targets b/src/Analyzers/src/Directory.Build.targets
new file mode 100644
index 000000000..3b2244d88
--- /dev/null
+++ b/src/Analyzers/src/Directory.Build.targets
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+ $(TargetsForTfmSpecificContentInPackage);IncludeAnalyzerInPackage
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/AnalyzerVerifier.cs b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/AnalyzerVerifier.cs
new file mode 100644
index 000000000..8b214a343
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/AnalyzerVerifier.cs
@@ -0,0 +1,65 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+internal static class AnalyzerVerifier
+{
+ private static readonly ImmutableArray References = CreateReferences();
+
+ public static async Task> AnalyzeAsync( string source, params MetadataReference[] references )
+ {
+ var compilation = Compile( "Test", source, references );
+
+ compilation.GetDiagnostics()
+ .Where( diagnostic => diagnostic.Severity == DiagnosticSeverity.Error )
+ .Should()
+ .BeEmpty( "the source under test must compile" );
+
+ // every analyzer runs for every test so that no analyzer reports on another's syntax
+ var analyzers = ImmutableArray.Create(
+ new ApiVersionStringSyntaxMustBeValid(),
+ new ApiVersionRangeStringSyntaxMustBeValid(),
+ new ApiVersionFormatStringSyntaxMustBeValid(),
+ new ApiVersionArgumentsMustBeValid() );
+
+ return await compilation.WithAnalyzers( analyzers )
+ .GetAnalyzerDiagnosticsAsync( TestContext.Current.CancellationToken )
+ .ConfigureAwait( false );
+ }
+
+ public static MetadataReference EmitAssembly( string assemblyName, string source )
+ {
+ var stream = new MemoryStream();
+ var result = Compile( assemblyName, source, [] ).Emit( stream );
+
+ result.Success.Should().BeTrue( "the referenced assembly must compile" );
+ stream.Position = 0;
+
+ return MetadataReference.CreateFromStream( stream );
+ }
+
+ public static string Literal( string value ) => SymbolDisplay.FormatLiteral( value, quote: true );
+
+ private static CSharpCompilation Compile( string assemblyName, string source, MetadataReference[] references ) =>
+ CSharpCompilation.Create(
+ assemblyName,
+ [CSharpSyntaxTree.ParseText( source )],
+ [.. References, .. references],
+ new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary ) );
+
+ private static ImmutableArray CreateReferences()
+ {
+ // the trusted platform assemblies are the exact set the test host was loaded with, which
+ // includes the runtime, Asp.Versioning.Abstractions, and everything else in the output
+ var assemblies = (string) AppContext.GetData( "TRUSTED_PLATFORM_ASSEMBLIES" );
+
+ return
+ [
+ .. assemblies
+ .Split( Path.PathSeparator )
+ .Where( path => path.EndsWith( ".dll", StringComparison.OrdinalIgnoreCase ) )
+ .GroupBy( Path.GetFileName )
+ .Select( duplicates => MetadataReference.CreateFromFile( duplicates.First() ) )
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Asp.Versioning.Analyzers.Tests.csproj b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Asp.Versioning.Analyzers.Tests.csproj
new file mode 100644
index 000000000..6e3e8addc
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Asp.Versioning.Analyzers.Tests.csproj
@@ -0,0 +1,13 @@
+
+
+
+ $(DefaultTargetFramework)
+ Asp.Versioning.Analyzers
+
+
+
+
+
+
+
+
diff --git a/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionArgumentsMustBeValidTest.cs b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionArgumentsMustBeValidTest.cs
new file mode 100644
index 000000000..c27278e70
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionArgumentsMustBeValidTest.cs
@@ -0,0 +1,402 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class ApiVersionArgumentsMustBeValidTest
+{
+ private const string AV0003 = nameof( AV0003 );
+ private const string AV0004 = nameof( AV0004 );
+ private const string AV0005 = nameof( AV0005 );
+ private const string AV0006 = nameof( AV0006 );
+ private const string AV0007 = nameof( AV0007 );
+ private const string AV0008 = nameof( AV0008 );
+
+ [Theory]
+ [InlineData( "[ApiVersion( 1.0 )]" )]
+ [InlineData( "[ApiVersion( 0.0 )]" )]
+ [InlineData( "[ApiVersion( 1 )]" )]
+ [InlineData( "[ApiVersion( 2.0, \"beta\" )]" )]
+ [InlineData( "[ApiVersion( 2016, 1, 1 )]" )]
+ [InlineData( "[ApiVersion( 2016, 2, 29 )]" )]
+ [InlineData( "[ApiVersion( 2016, 12, 31, \"alpha.1\" )]" )]
+ [InlineData( "[ApiVersion( \"1.0\" )]" )]
+ [InlineData( "[AdvertiseApiVersions( 1.0, 2.0, 3.0 )]" )]
+ [InlineData( "[AdvertiseApiVersions( 2016, 2, 29 )]" )]
+ public async Task analyzer_should_not_report_valid_arguments( string attribute )
+ {
+ // arrange
+ var source = Attributed( attribute );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData( "[ApiVersion( -1.0 )]" )]
+ [InlineData( "[ApiVersion( -0.1 )]" )]
+ [InlineData( "[ApiVersion( -1 )]" )]
+ [InlineData( "[ApiVersion( -1.0, \"beta\" )]" )]
+ [InlineData( "[AdvertiseApiVersions( -1.0 )]" )]
+ [InlineData( "[AdvertiseApiVersions( 1.0, -2.0 )]" )]
+ [InlineData( "[AdvertiseApiVersions( 1.0, new[] { -2.0 } )]" )]
+ public async Task analyzer_should_report_negative_version( string attribute )
+ {
+ // arrange
+ var source = Attributed( attribute );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0004 );
+ }
+
+ [Theory]
+ [InlineData( "[ApiVersion( 0, 1, 1 )]", AV0005 )]
+ [InlineData( "[ApiVersion( -1, 1, 1 )]", AV0005 )]
+ [InlineData( "[ApiVersion( 10000, 1, 1 )]", AV0005 )]
+ [InlineData( "[ApiVersion( 2016, 0, 1 )]", AV0006 )]
+ [InlineData( "[ApiVersion( 2016, 13, 1 )]", AV0006 )]
+ [InlineData( "[ApiVersion( 2016, 1, 0 )]", AV0007 )]
+ [InlineData( "[ApiVersion( 2016, 1, 32 )]", AV0007 )]
+ public async Task analyzer_should_report_invalid_date_component( string attribute, string expected )
+ {
+ // arrange
+ var source = Attributed( attribute );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( expected );
+ }
+
+ [Theory]
+ [InlineData( "[ApiVersion( 2013, 2, 29 )]" )]
+ [InlineData( "[ApiVersion( 2016, 4, 31 )]" )]
+ [InlineData( "[ApiVersion( 2016, 2, 30 )]" )]
+ [InlineData( "[AdvertiseApiVersions( 2013, 2, 29 )]" )]
+ public async Task analyzer_should_report_date_that_does_not_exist( string attribute )
+ {
+ // arrange
+ // each component is individually in range, so only the composed date is reported
+ var source = Attributed( attribute );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0008 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_date_across_every_component()
+ {
+ // arrange
+ var source = Attributed( "[ApiVersion( 2013, 2, 29 )]" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var span = diagnostics.Should().ContainSingle().Subject.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "2013, 2, 29" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_date_when_a_component_is_invalid()
+ {
+ // arrange
+ // the composed date cannot be evaluated until every component is in range, so each
+ // component is reported on its own and the date itself is not
+ var source = Attributed( "[ApiVersion( 0, 13, 32 )]" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Select( diagnostic => diagnostic.Id ).Should().BeEquivalentTo( [AV0005, AV0006, AV0007] );
+ }
+
+ [Theory]
+ [InlineData( "[ApiVersion( 1.0, \"1bad\" )]" )]
+ [InlineData( "[ApiVersion( 1.0, \"a-b\" )]" )]
+ [InlineData( "[ApiVersion( 1.0, \"a b\" )]" )]
+ [InlineData( "[ApiVersion( 1.0, \"preview.\" )]" )]
+ [InlineData( "[ApiVersion( 2016, 1, 1, \"a-b\" )]" )]
+ [InlineData( "[AdvertiseApiVersions( 1.0, \"a-b\" )]" )]
+ public async Task analyzer_should_report_invalid_status( string attribute )
+ {
+ // arrange
+ var source = Attributed( attribute );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0003 );
+ }
+
+ // a status is judged by the method an API version judges one with, so the two cannot disagree. an empty
+ // status is accepted here and refused by the parser, which reads one that was written down
+ [Theory]
+ [InlineData( "beta" )]
+ [InlineData( "alpha.1" )]
+ [InlineData( "RC" )]
+ [InlineData( "" )]
+ [InlineData( "1bad" )]
+ [InlineData( "a-b" )]
+ [InlineData( "a b" )]
+ [InlineData( "preview." )]
+ [InlineData( "alpha..1" )]
+ public async Task analyzer_should_agree_with_api_version_on_a_status( string status )
+ {
+ // arrange
+ var source = Attributed( $"[ApiVersion( 1.0, \"{status}\" )]" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Any( diagnostic => diagnostic.Id == AV0003 )
+ .Should()
+ .Be( !ApiVersion.IsValidStatus( status ), "the analyzer must agree with ApiVersion.IsValidStatus" );
+ }
+
+ // the date components are judged against the range each accepts rather than by composing a date, so what
+ // they accept together is compared against the calendar the version is composed from
+ [Theory]
+ [InlineData( 2016, 1, 1 )]
+ [InlineData( 2016, 2, 29 )]
+ [InlineData( 2016, 12, 31 )]
+ [InlineData( 2013, 2, 29 )]
+ [InlineData( 2016, 4, 31 )]
+ [InlineData( 2016, 2, 30 )]
+ [InlineData( 0, 1, 1 )]
+ [InlineData( 10000, 1, 1 )]
+ [InlineData( 2016, 0, 1 )]
+ [InlineData( 2016, 13, 1 )]
+ [InlineData( 2016, 1, 0 )]
+ [InlineData( 2016, 1, 32 )]
+ public async Task analyzer_should_agree_with_the_calendar_on_a_date( int year, int month, int day )
+ {
+ // arrange
+ var source = Attributed( $"[ApiVersion( {year}, {month}, {day} )]" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Any().Should().Be( !IsRealDate( year, month, day ), "the analyzer must agree with the calendar" );
+ }
+
+ private static bool IsRealDate( int year, int month, int day )
+ {
+ try
+ {
+ _ = new DateOnly( year, month, day );
+ return true;
+ }
+ catch ( ArgumentOutOfRangeException )
+ {
+ return false;
+ }
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_map_to_api_version_arguments()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+
+ public class Controller
+ {
+ [MapToApiVersion( -1.0 )]
+ public void Get() { }
+
+ [MapToApiVersion( 2013, 2, 29 )]
+ public void Put() { }
+
+ [MapToApiVersion( 1.0, "a-b" )]
+ public void Post() { }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Select( diagnostic => diagnostic.Id ).Should().BeEquivalentTo( [AV0004, AV0008, AV0003] );
+ }
+
+ [Theory]
+ [InlineData( "builder.HasApiVersion( -1.0 )", AV0004 )]
+ [InlineData( "builder.HasApiVersion( 1, -1 )", AV0004 )]
+ [InlineData( "builder.HasApiVersion( 2013, 2, 29 )", AV0008 )]
+ [InlineData( "builder.HasDeprecatedApiVersion( 2016, 13, 1 )", AV0006 )]
+ [InlineData( "builder.AdvertisesApiVersion( 1.0, \"a b\" )", AV0003 )]
+ [InlineData( "builder.AdvertisesDeprecatedApiVersion( -2.0 )", AV0004 )]
+ [InlineData( "builder.MapToApiVersion( 1.0, \"a-b\" )", AV0003 )]
+ [InlineData( "builder.MapToApiVersion( 0, 1, 1 )", AV0005 )]
+ public async Task analyzer_should_report_convention_builder_arguments( string statement, string expected )
+ {
+ // arrange
+ var source = Configured( statement );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( expected );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_convention_builder_arguments_in_static_form()
+ {
+ // arrange
+ // an extension member is declared in a synthetic nested type, so the receiver is only a
+ // parameter when the method is called in its unreduced form
+ var source = Configured( "ApiVersionConventionBuilderExtensions.HasApiVersion( builder, -1.0 )" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0004 );
+ }
+
+ [Theory]
+ [InlineData( "builder.HasApiVersion( 1.0 )" )]
+ [InlineData( "builder.HasApiVersion( 1, 0 )" )]
+ [InlineData( "builder.HasApiVersion( 2016, 2, 29 )" )]
+ [InlineData( "builder.MapToApiVersion( 1.0, \"beta\" )" )]
+ public async Task analyzer_should_not_report_valid_convention_builder_arguments( string statement )
+ {
+ // arrange
+ var source = Configured( statement );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_arguments_known_only_at_run_time()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Asp.Versioning.Conventions;
+
+ public class Sample
+ {
+ public void Configure( IMapToApiVersionConventionBuilder builder, double version, string status )
+ {
+ builder.HasApiVersion( version, status );
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_unrelated_api()
+ {
+ // arrange
+ // the parameter names match, but the declaring type is not part of the versioning surface
+ var source = """
+ public static class Unrelated
+ {
+ public static void Configure( double version, string status ) { }
+
+ public static void Configure( int year, int month, int day ) { }
+
+ public static void Run()
+ {
+ Configure( -1.0, "a-b" );
+ Configure( 2013, 2, 29 );
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_string_version()
+ {
+ // arrange
+ // a string version is the concern of AV0001, even though the parameter is also named version
+ var source = Attributed( "[ApiVersion( \"neutral\" )]" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( "AV0001" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_object_creation()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+
+ public class Sample
+ {
+ public void Create()
+ {
+ var attribute = new ApiVersionAttribute( -1.0 );
+ ApiVersionAttribute other = new( 2013, 2, 29 );
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Select( diagnostic => diagnostic.Id ).Should().BeEquivalentTo( [AV0004, AV0008] );
+ }
+
+ private static string Attributed( string attribute ) =>
+ $$"""
+ using Asp.Versioning;
+
+ {{attribute}}
+ public class Controller
+ {
+ }
+ """;
+
+ private static string Configured( string statement ) =>
+ $$"""
+ using Asp.Versioning;
+ using Asp.Versioning.Conventions;
+
+ public class Sample
+ {
+ public void Configure( IMapToApiVersionConventionBuilder builder )
+ {
+ {{statement}};
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionFormatStringSyntaxMustBeValidTest.cs b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionFormatStringSyntaxMustBeValidTest.cs
new file mode 100644
index 000000000..aed81f1b9
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionFormatStringSyntaxMustBeValidTest.cs
@@ -0,0 +1,363 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class ApiVersionFormatStringSyntaxMustBeValidTest
+{
+ private const string AV0009 = nameof( AV0009 );
+ private const string AV0010 = nameof( AV0010 );
+
+ private static readonly ApiVersion Sample = ApiVersionParser.Default.Parse( "2017-05-01.1.5-RC" );
+
+ [Theory]
+ [InlineData( "" )]
+ [InlineData( "F" )]
+ [InlineData( "FF" )]
+ [InlineData( "G" )]
+ [InlineData( "GG" )]
+ [InlineData( "y" )]
+ [InlineData( "yyyy" )]
+ [InlineData( "yyyyy" )]
+ [InlineData( "MM" )]
+ [InlineData( "MMMM" )]
+ [InlineData( "dd" )]
+ [InlineData( "dddd" )]
+ [InlineData( "v" )]
+ [InlineData( "V" )]
+ [InlineData( "VV" )]
+ [InlineData( "VVV" )]
+ [InlineData( "VVVV" )]
+ [InlineData( "S" )]
+ [InlineData( "p" )]
+ [InlineData( "p3" )]
+ [InlineData( "p99" )]
+ [InlineData( "P" )]
+ [InlineData( "PPPP" )]
+ [InlineData( "V.v" )]
+ [InlineData( "'v'V" )]
+ [InlineData( "%V" )]
+ public async Task analyzer_should_not_report_valid_format( string format )
+ {
+ // arrange
+ var source = Formatted( format );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ Throws( format ).Should().BeFalse( "the analyzer must agree with ApiVersionFormatProvider" );
+ }
+
+ [Theory]
+ [InlineData( "'unterminated" )]
+ [InlineData( "\"unterminated" )]
+ [InlineData( "MM-dd-yyyy'" )]
+ [InlineData( "p100" )]
+ [InlineData( "P100" )]
+ [InlineData( "p256" )]
+ [InlineData( "p2147483648" )]
+ [InlineData( "p99999999999999999999" )]
+ public async Task analyzer_should_report_malformed_format( string format )
+ {
+ // arrange
+ var source = Formatted( format );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0009 );
+ Throws( format ).Should().BeTrue( "AV0009 is only for a format that fails at run time" );
+ }
+
+ [Theory]
+ [InlineData( "FFF" )]
+ [InlineData( "GGG" )]
+ [InlineData( "SS" )]
+ [InlineData( "vv" )]
+ [InlineData( "pp" )]
+ [InlineData( "MMMMM" )]
+ [InlineData( "ddddd" )]
+ [InlineData( "VVVVV" )]
+ [InlineData( "PPPPP" )]
+ public async Task analyzer_should_report_repeated_specifier( string format )
+ {
+ // arrange
+ var source = Formatted( format );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0010 );
+ Throws( format ).Should().BeFalse( "AV0010 is for a format that silently misbehaves" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_repeated_specifier_as_a_warning()
+ {
+ // arrange
+ // an over-repeated specifier still formats, so it cannot fail the build the way AV0009 does
+ var source = Formatted( "vv" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Warning );
+ diagnostic.GetMessage().Should().Contain( "'v'" ).And.Contain( "1" ).And.Contain( "2" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_malformed_format_as_an_error()
+ {
+ // arrange
+ var source = Formatted( "p100" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Error );
+ diagnostic.GetMessage().Should().Contain( "100" ).And.Contain( "99" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_every_problem_in_a_format()
+ {
+ // arrange
+ var source = Formatted( "VVVVV-vv" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Select( diagnostic => diagnostic.Id ).Should().BeEquivalentTo( [AV0010, AV0010] );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_format_passed_to_to_string_with_provider()
+ {
+ // arrange
+ var source = """
+ using System;
+ using Asp.Versioning;
+
+ public class Formatter
+ {
+ public string Format( ApiVersion version, IFormatProvider provider ) =>
+ version.ToString( "vv", provider );
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0010 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_format_passed_to_try_format()
+ {
+ // arrange
+ var source = """
+ using System;
+ using Asp.Versioning;
+
+ public class Formatter
+ {
+ public bool Format( ApiVersion version, Span destination ) =>
+ version.TryFormat( destination, out _, "p100", null );
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0009 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_format_passed_to_format_provider()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+
+ public class Formatter
+ {
+ public string Format( ApiVersion version ) =>
+ ApiVersionFormatProvider.CurrentCulture.Format( "vv", version, null );
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0010 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_format_known_only_at_run_time()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+
+ public class Formatter
+ {
+ public string Format( ApiVersion version, string format ) => version.ToString( format );
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_an_api_version_as_a_format()
+ {
+ // arrange
+ // "1.0" is a valid API version but reads as a format of literal characters, and the two
+ // syntaxes must not be confused for one another
+ var source = """
+ using Asp.Versioning;
+
+ [ApiVersion( "1.0" )]
+ public class Controller
+ {
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_format_assigned_to_property()
+ {
+ // arrange
+ var source = Options( """
+ public class Startup
+ {
+ public void Configure( Options options ) => options.GroupNameFormat = "'vVVV";
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0009 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_format_assigned_in_object_initializer()
+ {
+ // arrange
+ var source = Options( """
+ public class Startup
+ {
+ public Options Configure() => new Options { GroupNameFormat = "'vVVV" };
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0009 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_format_assigned_to_field()
+ {
+ // arrange
+ var source = Options( """
+ public class Startup
+ {
+ public void Configure( Options options ) => options.SubstitutionFormat = "vv";
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0010 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_assignment_to_unannotated_property()
+ {
+ // arrange
+ var source = Options( """
+ public class Startup
+ {
+ public void Configure( Options options ) => options.Name = "'vVVV";
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ private static bool Throws( string format )
+ {
+ try
+ {
+ Sample.ToString( format );
+ return false;
+ }
+ catch ( FormatException )
+ {
+ return true;
+ }
+ }
+
+ /// The options an API explorer is configured with are declared here rather than referenced, because
+ /// the annotation is what the rule keys off of and not the assembly the annotated member came from.
+ private static string Options( string code ) =>
+ """
+ using System.Diagnostics.CodeAnalysis;
+
+ public class Options
+ {
+ [StringSyntax( "ApiVersionFormat" )]
+ public string GroupNameFormat { get; set; }
+
+ [StringSyntax( "ApiVersionFormat" )]
+ public string SubstitutionFormat;
+
+ public string Name { get; set; }
+ }
+
+ """ + code;
+
+ private static string Formatted( string format ) =>
+ $$"""
+ using Asp.Versioning;
+
+ public class Formatter
+ {
+ public string Format( ApiVersion version ) => version.ToString( {{AnalyzerVerifier.Literal( format )}} );
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionRangeStringSyntaxMustBeValidTest.cs b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionRangeStringSyntaxMustBeValidTest.cs
new file mode 100644
index 000000000..b22ee2b2a
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionRangeStringSyntaxMustBeValidTest.cs
@@ -0,0 +1,327 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class ApiVersionRangeStringSyntaxMustBeValidTest
+{
+ private const string AV0002 = nameof( AV0002 );
+
+ private const string Declarations = """
+ using System;
+ using System.Diagnostics.CodeAnalysis;
+
+ [AttributeUsage( AttributeTargets.Class )]
+ public sealed class RangedAttribute : Attribute
+ {
+ public RangedAttribute( [StringSyntax( "ApiVersionRange" )] string rule ) { }
+
+ [StringSyntax( "ApiVersionRange" )]
+ public string Sunset { get; set; }
+ }
+
+ public static class Api
+ {
+ public static void Restrict( [StringSyntax( "ApiVersionRange" )] string rule ) { }
+ }
+ """;
+
+ [Theory]
+ [InlineData( "1" )]
+ [InlineData( "1.0" )]
+ [InlineData( "[1]" )]
+ [InlineData( "[1.0]" )]
+ [InlineData( "1.0-beta" )]
+ [InlineData( "2013-08-06" )]
+ [InlineData( "[1.0,)" )]
+ [InlineData( "(1.0,)" )]
+ [InlineData( "(,1.0]" )]
+ [InlineData( "(,1.0)" )]
+ [InlineData( "[1.0,2.0]" )]
+ [InlineData( "(1.0,2.0)" )]
+ [InlineData( "[1.0,2.0)" )]
+ [InlineData( "(1.0,2.0]" )]
+ [InlineData( "[1.0-beta,)" )]
+ [InlineData( "[2013-08-06,2013-09-01)" )]
+ [InlineData( "[1.0 , 2.0]" )]
+ public async Task analyzer_should_not_report_valid_api_version_range( string rule )
+ {
+ // arrange
+ var source = Ranged( rule );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ CanParse( rule ).Should().BeTrue( "the analyzer must agree with ApiVersionRange" );
+ }
+
+ [Theory]
+ [InlineData( "" )]
+ [InlineData( "()" )]
+ [InlineData( "[]" )]
+ [InlineData( "," )]
+ [InlineData( "(,)" )]
+ [InlineData( "[,]" )]
+ [InlineData( "[,)" )]
+ [InlineData( "(1.0)" )]
+ [InlineData( "[1.0)" )]
+ [InlineData( "(1.0]" )]
+ [InlineData( "(1.0," )]
+ [InlineData( "1.0,2.0" )]
+ [InlineData( "[1.0,2.0" )]
+ [InlineData( "1.0,2.0]" )]
+ [InlineData( "[1.0,)]" )]
+ [InlineData( "[bogus,)" )]
+ [InlineData( "(,bogus]" )]
+ [InlineData( "[1.0,,2.0]" )]
+ [InlineData( "[[1.0,2.0]]" )]
+ [InlineData( "[1.0,2.0]extra" )]
+ [InlineData( "[ 1.0,2.0]" )]
+ [InlineData( "[1.0,2.0 ]" )]
+ [InlineData( " 1.0" )]
+ [InlineData( "1.0 " )]
+ public async Task analyzer_should_report_invalid_api_version_range( string rule )
+ {
+ // arrange
+ var source = Ranged( rule );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0002 );
+ CanParse( rule ).Should().BeFalse( "the analyzer must agree with ApiVersionRange" );
+ }
+
+ [Theory]
+ [InlineData( "]1.0,2.0[" )]
+ [InlineData( "]1.0,2.0]" )]
+ [InlineData( "]1.0,2.0)" )]
+ [InlineData( ")1.0,2.0[" )]
+ [InlineData( ")1.0,2.0]" )]
+ [InlineData( ")1.0,2.0)" )]
+ [InlineData( "[1.0,2.0[" )]
+ [InlineData( "[1.0,2.0(" )]
+ [InlineData( "(1.0,2.0[" )]
+ [InlineData( "(1.0,2.0(" )]
+ public async Task analyzer_should_report_mismatched_bounds( string rule )
+ {
+ // arrange
+ // only '[' and '(' are a lower bound and only ']' and ')' are an upper bound
+ var source = Ranged( rule );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0002 );
+ CanParse( rule ).Should().BeFalse( "the analyzer must agree with ApiVersionRange" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_api_version_range_at_argument_location()
+ {
+ // arrange
+ var source = Ranged( "(1.0)" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var span = diagnostics.Should().ContainSingle().Subject.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "\"(1.0)\"" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_range_in_expanded_params()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+
+ public class Model
+ {
+ [VisibleInApiVersion( "[1.0,)", "[2.0,3.0]", "(4.0)" )]
+ public string Name { get; set; }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0002 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_range_in_params_array()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+
+ public class Model
+ {
+ [VisibleInApiVersion( "[1.0,)", new[] { "(4.0)" } )]
+ public string Name { get; set; }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0002 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_range_in_attribute_property()
+ {
+ // arrange
+ var source = Declared( """
+ [Ranged( "[1.0,)", Sunset = "(2.0)" )]
+ public class Controller
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0002 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_range_in_method_call()
+ {
+ // arrange
+ var source = Declared( """
+ public class Controller
+ {
+ public void Get() => Api.Restrict( "(1.0)" );
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0002 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_api_version_range_known_only_at_run_time()
+ {
+ // arrange
+ var source = Declared( """
+ public class Controller
+ {
+ public void Get( string rule ) => Api.Restrict( rule );
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_range_annotated_by_internal_attribute()
+ {
+ // arrange
+ // .NET Standard builds compile an internal copy of StringSyntaxAttribute rather than using the
+ // one from the BCL, so the annotation cannot be resolved as a type symbol from the compilation
+ const string Backport = """
+ using System;
+ using System.Diagnostics.CodeAnalysis;
+
+ namespace System.Diagnostics.CodeAnalysis
+ {
+ [AttributeUsage( AttributeTargets.Parameter )]
+ internal sealed class StringSyntaxAttribute : Attribute
+ {
+ public StringSyntaxAttribute( string syntax ) { }
+ }
+ }
+
+ [AttributeUsage( AttributeTargets.Class )]
+ public sealed class BackportedRangedAttribute : Attribute
+ {
+ public BackportedRangedAttribute( [StringSyntax( "ApiVersionRange" )] string rule ) { }
+ }
+ """;
+
+ var library = AnalyzerVerifier.EmitAssembly( "BackportedRange", Backport );
+ var source = """
+ [BackportedRanged( "(1.0)" )]
+ public class Controller
+ {
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source, library );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0002 );
+ }
+
+ [Fact]
+ public async Task analyzers_should_not_report_each_others_syntax()
+ {
+ // arrange
+ // "[1.0,)" is a valid range but not a valid API version, and "1.0-beta" is a valid API
+ // version that is also a valid range, so neither analyzer may act on the other's annotation
+ var source = """
+ using Asp.Versioning;
+
+ [ApiVersion( "1.0-beta" )]
+ public class Model
+ {
+ [VisibleInApiVersion( "[1.0,)" )]
+ public string Name { get; set; }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ private static bool CanParse( string rule )
+ {
+ try
+ {
+ ApiVersionRange.Parse( rule );
+ return true;
+ }
+ catch ( FormatException )
+ {
+ return false;
+ }
+ catch ( ArgumentException )
+ {
+ return false;
+ }
+ }
+
+ private static string Ranged( string rule ) =>
+ $$"""
+ using Asp.Versioning;
+
+ public class Model
+ {
+ [VisibleInApiVersion( {{AnalyzerVerifier.Literal( rule )}} )]
+ public string Name { get; set; }
+ }
+ """;
+
+ private static string Declared( string code ) => Declarations + Environment.NewLine + code;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionStringSyntaxMustBeValidTest.cs b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionStringSyntaxMustBeValidTest.cs
new file mode 100644
index 000000000..3fcb7b6b3
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Analyzers.Tests/Rules/ApiVersionStringSyntaxMustBeValidTest.cs
@@ -0,0 +1,418 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class ApiVersionStringSyntaxMustBeValidTest
+{
+ private const string AV0001 = nameof( AV0001 );
+
+ private const string Declarations = """
+ using System;
+ using System.Diagnostics.CodeAnalysis;
+
+ [AttributeUsage( AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true )]
+ public sealed class VersionedAttribute : Attribute
+ {
+ public VersionedAttribute( [StringSyntax( "ApiVersion" )] string version ) { }
+
+ public VersionedAttribute( double version ) { }
+
+ [StringSyntax( "ApiVersion" )]
+ public string Sunset { get; set; }
+ }
+
+ [AttributeUsage( AttributeTargets.Class )]
+ public sealed class VersionSetAttribute : Attribute
+ {
+ public VersionSetAttribute(
+ [StringSyntax( "ApiVersion" )] string version,
+ [StringSyntax( "ApiVersion" )] params string[] otherVersions ) { }
+ }
+
+ public static class Api
+ {
+ public static void Use( [StringSyntax( "ApiVersion" )] string version ) { }
+ }
+ """;
+
+ [Theory]
+ [InlineData( "1" )]
+ [InlineData( "0" )]
+ [InlineData( "1.0" )]
+ [InlineData( "0.0" )]
+ [InlineData( "01.0" )]
+ [InlineData( "2147483647" )]
+ [InlineData( "1.0-beta" )]
+ [InlineData( "1.0-alpha.1" )]
+ [InlineData( "2013-08-06" )]
+ [InlineData( "2013-08-06-Alpha" )]
+ [InlineData( "2013-08-06.1" )]
+ [InlineData( "2013-08-06.1.1" )]
+ [InlineData( "2013-08-06.1-Alpha" )]
+ [InlineData( "2013-08-06.1.1-Alpha" )]
+ public async Task analyzer_should_not_report_valid_api_version( string version )
+ {
+ // arrange
+ var source = Versioned( version );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ ApiVersionParser.Default
+ .TryParse( version, out _ )
+ .Should()
+ .BeTrue( "the analyzer must agree with ApiVersionParser" );
+ }
+
+ [Theory]
+ [InlineData( "" )]
+ [InlineData( "neutral" )]
+ [InlineData( "Alpha1" )]
+ [InlineData( "v1" )]
+ [InlineData( "1_0" )]
+ [InlineData( "1.0.0" )]
+ [InlineData( "1." )]
+ [InlineData( ".1" )]
+ [InlineData( "1-" )]
+ [InlineData( "1.0-" )]
+ [InlineData( "1.0-alpha." )]
+ [InlineData( "--" )]
+ [InlineData( "1.-1" )]
+ [InlineData( "1.1-Alpha-1" )]
+ [InlineData( "2147483648" )]
+ [InlineData( "-1" )]
+ [InlineData( "-1.0" )]
+ [InlineData( "+1.0" )]
+ [InlineData( " 1.0" )]
+ [InlineData( "1.0 " )]
+ [InlineData( "2013-02-29" )]
+ [InlineData( "2025-13-45" )]
+ [InlineData( "2013-08-06X" )]
+ [InlineData( "2013-08-06." )]
+ [InlineData( "2013-08-06-" )]
+ public async Task analyzer_should_report_invalid_api_version( string version )
+ {
+ // arrange
+ var source = Versioned( version );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0001 );
+ ApiVersionParser.Default
+ .TryParse( version, out _ )
+ .Should()
+ .BeFalse( "the analyzer must agree with ApiVersionParser" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_api_version_at_argument_location()
+ {
+ // arrange
+ var source = Versioned( "neutral" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var span = diagnostics.Should().ContainSingle().Subject.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "\"neutral\"" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_in_attribute_property()
+ {
+ // arrange
+ var source = Declared( """
+ [Versioned( "1.0", Sunset = "1.x" )]
+ public class Controller
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0001 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_in_expanded_params()
+ {
+ // arrange
+ var source = Declared( """
+ [VersionSet( "1.0", "2.0", "bogus" )]
+ public class Controller
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0001 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_in_params_array()
+ {
+ // arrange
+ var source = Declared( """
+ [VersionSet( "1.0", new[] { "2.0", "bogus" } )]
+ public class Controller
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0001 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_in_method_call()
+ {
+ // arrange
+ var source = Declared( """
+ public class Controller
+ {
+ public void Get() => Api.Use( "1.x" );
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0001 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_in_named_argument()
+ {
+ // arrange
+ var source = Declared( """
+ public class Controller
+ {
+ public void Get() => Api.Use( version: "1.x" );
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0001 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_in_object_creation()
+ {
+ // arrange
+ var source = Declared( """
+ public class Controller
+ {
+ public void Get()
+ {
+ var attribute = new VersionedAttribute( "1.x" );
+ }
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0001 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_in_implicit_object_creation()
+ {
+ // arrange
+ var source = Declared( """
+ public class Controller
+ {
+ public void Get()
+ {
+ VersionedAttribute attribute = new( "1.x" );
+ }
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0001 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_from_constant()
+ {
+ // arrange
+ var source = Declared( """
+ public class Controller
+ {
+ private const string Version = "1.x";
+
+ [Versioned( Version )]
+ public void Get() { }
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0001 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_api_version_known_only_at_run_time()
+ {
+ // arrange
+ var source = Declared( """
+ public class Controller
+ {
+ public void Get( string version ) => Api.Use( version );
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_unannotated_parameter()
+ {
+ // arrange
+ var source = Declared( """
+ [Versioned( 1.0 )]
+ public class Controller
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_declared_in_metadata()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+
+ [ApiVersion( "1.0" )]
+ [ApiVersion( "neutral" )]
+ [AdvertiseApiVersions( "1.0", "bogus" )]
+ public class Controller
+ {
+ [MapToApiVersion( "2.x" )]
+ public void Get() { }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().HaveCount( 3 ).And.OnlyContain( diagnostic => diagnostic.Id == AV0001 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_invalid_api_version_annotated_by_internal_attribute()
+ {
+ // arrange
+ // .NET Standard builds compile an internal copy of StringSyntaxAttribute rather than using the
+ // one from the BCL, so the annotation cannot be resolved as a type symbol from the compilation
+ const string Backport = """
+ using System;
+ using System.Diagnostics.CodeAnalysis;
+
+ namespace System.Diagnostics.CodeAnalysis
+ {
+ [AttributeUsage( AttributeTargets.Parameter )]
+ internal sealed class StringSyntaxAttribute : Attribute
+ {
+ public StringSyntaxAttribute( string syntax ) { }
+ }
+ }
+
+ [AttributeUsage( AttributeTargets.Class )]
+ public sealed class BackportedVersionedAttribute : Attribute
+ {
+ public BackportedVersionedAttribute( [StringSyntax( "ApiVersion" )] string version ) { }
+ }
+ """;
+
+ var library = AnalyzerVerifier.EmitAssembly( "Backported", Backport );
+ var source = """
+ [BackportedVersioned( "1.x" )]
+ public class Controller
+ {
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source, library );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0001 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_unrelated_string_syntax()
+ {
+ // arrange
+ var source = """
+ using System;
+ using System.Diagnostics.CodeAnalysis;
+
+ [AttributeUsage( AttributeTargets.Class )]
+ public sealed class RoutedAttribute : Attribute
+ {
+ public RoutedAttribute( [StringSyntax( "Route" )] string template ) { }
+ }
+
+ [Routed( "not/an/api/version" )]
+ public class Controller
+ {
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ private static string Versioned( string version ) =>
+ $$"""
+ using Asp.Versioning;
+
+ [ApiVersion( {{AnalyzerVerifier.Literal( version )}} )]
+ public class Controller
+ {
+ }
+ """;
+
+ private static string Declared( string code ) => Declarations + Environment.NewLine + code;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/AnalyzerVerifier.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/AnalyzerVerifier.cs
new file mode 100644
index 000000000..b69641a70
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/AnalyzerVerifier.cs
@@ -0,0 +1,69 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+internal static class AnalyzerVerifier
+{
+ private static readonly ImmutableArray References = CreateReferences();
+
+ public static Task> AnalyzeAsync( params string[] sources ) =>
+ AnalyzeAsync( OutputKind.DynamicallyLinkedLibrary, sources );
+
+ // a rule can depend on whether the compilation produces an application, which only an entry point makes it
+ public static async Task> AnalyzeAsync(
+ OutputKind outputKind,
+ params string[] sources )
+ {
+ var compilation = CSharpCompilation.Create(
+ "Test",
+ sources.Select( source => CSharpSyntaxTree.ParseText( source ) ),
+ References,
+ new CSharpCompilationOptions( outputKind ) );
+
+ compilation.GetDiagnostics()
+ .Where( diagnostic => diagnostic.Severity == DiagnosticSeverity.Error )
+ .Should()
+ .BeEmpty( "the source under test must compile" );
+
+ // every analyzer runs for every test so that no analyzer reports on another's concern
+ var analyzers = ImmutableArray.Create(
+ new DefaultApiVersionAnalyzer(),
+ new MissingAddMvcAnalyzer(),
+ new MissingApiBehaviorAnalyzer(),
+ new SpecificApiVersionReaderAnalyzer(),
+ new AssumeDefaultApiVersionAnalyzer(),
+ new DefaultValueAnalyzer(),
+ new AllEndpointsVersionNeutralAnalyzer(),
+ new VersionedAndNeutralAnalyzer(),
+ new ApiExplorerAnalyzer(),
+ new MissingAddODataAnalyzer(),
+ new IgnoredRouteComponentsAnalyzer(),
+ new InheritedApiExplorerOptionAnalyzer(),
+ new MissingDocumentInfoAnalyzer(),
+ new UnusedGroupNameFormatAnalyzer(),
+ new DescribeApiVersionsAnalyzer(),
+ new PolicyEffectiveDateAnalyzer(),
+ new VersionedOpenApiAnalyzer(),
+ new MissingApiExplorerAnalyzer() );
+
+ return await compilation.WithAnalyzers( analyzers )
+ .GetAnalyzerDiagnosticsAsync( TestContext.Current.CancellationToken )
+ .ConfigureAwait( false );
+ }
+
+ private static ImmutableArray CreateReferences()
+ {
+ // the trusted platform assemblies are the exact set the test host was loaded with, which
+ // includes the runtime, the versioning libraries, and everything else in the output
+ var assemblies = (string) AppContext.GetData( "TRUSTED_PLATFORM_ASSEMBLIES" );
+
+ return
+ [
+ .. assemblies
+ .Split( Path.PathSeparator )
+ .Where( path => path.EndsWith( ".dll", StringComparison.OrdinalIgnoreCase ) )
+ .GroupBy( Path.GetFileName )
+ .Select( duplicates => MetadataReference.CreateFromFile( duplicates.First() ) )
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Asp.Versioning.Api.Analyzers.Tests.csproj b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Asp.Versioning.Api.Analyzers.Tests.csproj
new file mode 100644
index 000000000..666877abd
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Asp.Versioning.Api.Analyzers.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ $(DefaultTargetFramework)
+ Asp.Versioning.Analyzers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/NamespaceVersionTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/NamespaceVersionTest.cs
new file mode 100644
index 000000000..e89cf2efe
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/NamespaceVersionTest.cs
@@ -0,0 +1,39 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers;
+
+
+public class NamespaceVersionTest
+{
+ [Theory]
+ [InlineData( "v1" )]
+ [InlineData( "V1" )]
+ [InlineData( "v1_1" )]
+ [InlineData( "v2_0_Beta" )]
+ [InlineData( "v20180401" )]
+ [InlineData( "v2018_04_01_1_1_Beta" )]
+ [InlineData( "_1" )]
+ [InlineData( "_1_1" )]
+ [InlineData( "_20180401" )]
+ [InlineData( "_2018_04_01" )]
+ [InlineData( "_2018_04_01_Beta" )]
+ [InlineData( "_2018_04_01_1_0_Beta" )]
+ [InlineData( "Api.v1.Controllers" )]
+ [InlineData( "Company.Api._2018_04_01" )]
+ public void is_versioned_should_return_true_for_a_versioned_namespace( string @namespace ) =>
+ NamespaceVersion.IsVersioned( @namespace ).Should().BeTrue();
+
+ [Theory]
+ [InlineData( "" )]
+ [InlineData( "Api" )]
+ [InlineData( "Api.Controllers" )]
+ [InlineData( "Version1" )]
+ [InlineData( "vNext" )]
+ [InlineData( "v" )]
+ [InlineData( "v1_1_Bad-Status" )]
+ [InlineData( "v20181301" )]
+ [InlineData( "v2018_13_01" )]
+ [InlineData( "Api.Models.Orders" )]
+ public void is_versioned_should_return_false_for_an_unversioned_namespace( string @namespace ) =>
+ NamespaceVersion.IsVersioned( @namespace ).Should().BeFalse();
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/AllEndpointsVersionNeutralAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/AllEndpointsVersionNeutralAnalyzerTest.cs
new file mode 100644
index 000000000..382170482
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/AllEndpointsVersionNeutralAnalyzerTest.cs
@@ -0,0 +1,292 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class AllEndpointsVersionNeutralAnalyzerTest
+{
+ private const string AV0018 = nameof( AV0018 );
+
+ [Fact]
+ public async Task analyzer_should_report_when_every_controller_is_version_neutral()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/[controller]" )]
+ public class PeopleController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0018 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_a_version_is_declared_anywhere()
+ {
+ // arrange
+ // one explicit version gives the API explorer something to describe the neutral endpoint against
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/[controller]" )]
+ public class HealthController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_an_endpoint_declares_nothing()
+ {
+ // arrange
+ // an endpoint without any metadata is a different problem, reported by another rule
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/[controller]" )]
+ public class HealthController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_an_action_is_version_neutral()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ [ApiVersionNeutral]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0018 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_every_minimal_api_is_version_neutral()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/api/orders", () => "" ).IsApiVersionNeutral();
+ app.MapGet( "/api/people", () => "" ).IsApiVersionNeutral();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0018 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_a_minimal_api_declares_a_version()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/api/health", () => "" ).IsApiVersionNeutral();
+ app.MapGet( "/api/orders", () => "" ).HasApiVersion( 1.0 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_a_group_is_version_neutral()
+ {
+ // arrange
+ var source = Application( """
+ var api = app.MapGroup( "/api" ).IsApiVersionNeutral();
+
+ api.MapGet( "/orders", () => "" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0018 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_any_endpoints()
+ {
+ // arrange
+ var source = Application( string.Empty );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_api_versioning()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Mvc;
+
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_a_route_cannot_be_followed()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) => services.AddApiVersioning();
+
+ public static void MapOrders( IEndpointRouteBuilder builder ) =>
+ builder.MapGet( "/orders", () => "" ).IsApiVersionNeutral();
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_api_versioning_call_site_as_an_error()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/api/orders", () => "" ).IsApiVersionNeutral();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "AddApiVersioning" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Error );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( string source ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( source ) ).Where( diagnostic => diagnostic.Id == AV0018 )];
+
+ private static string Controllers( string controllers ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) => services.AddApiVersioning();
+ }
+
+ {{controllers}}
+ """;
+
+ private static string Application( string endpoints ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) => services.AddApiVersioning();
+
+ public static void Configure( WebApplication app )
+ {
+ {{endpoints}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/ApiExplorerAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/ApiExplorerAnalyzerTest.cs
new file mode 100644
index 000000000..ec3c1cbd8
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/ApiExplorerAnalyzerTest.cs
@@ -0,0 +1,191 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class ApiExplorerAnalyzerTest
+{
+ private const string AV0020 = nameof( AV0020 );
+ private const string AV0021 = nameof( AV0021 );
+
+ [Fact]
+ public async Task analyzer_should_report_a_redundant_endpoints_api_explorer()
+ {
+ // arrange
+ // the versioned explorer adds the endpoints explorer itself
+ var source = Configured( """
+ services.AddEndpointsApiExplorer();
+ services.AddApiVersioning().AddApiExplorer();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0020 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_an_unversioned_endpoints_api_explorer()
+ {
+ // arrange
+ // versions are in use, but the explorer describing the endpoints knows nothing about them
+ var source = Configured( """
+ services.AddEndpointsApiExplorer();
+ services.AddApiVersioning();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0021 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_the_versioned_api_explorer_alone()
+ {
+ // arrange
+ var source = Configured( "services.AddApiVersioning().AddApiExplorer();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_redundant_endpoints_api_explorer_with_odata()
+ {
+ // arrange
+ // the OData explorer reaches the versioned explorer, which adds the endpoints explorer itself
+ var source = Configured( """
+ services.AddEndpointsApiExplorer();
+ services.AddApiVersioning().AddODataApiExplorer();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0020 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_redundant_endpoints_api_explorer_with_openapi()
+ {
+ // arrange
+ // AddOpenApi reaches the versioned explorer the same way the OData explorer does
+ var source = Configured( """
+ services.AddEndpointsApiExplorer();
+ services.AddApiVersioning().AddOpenApi();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0020 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_api_versioning()
+ {
+ // arrange
+ // an application that does not version has no reason to use the versioned explorer
+ var source = Configured( "services.AddEndpointsApiExplorer();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_each_endpoints_api_explorer()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddEndpointsApiExplorer();
+ services.AddEndpointsApiExplorer();
+ services.AddApiVersioning();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().HaveCount( 2 ).And.OnlyContain( diagnostic => diagnostic.Id == AV0021 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_files()
+ {
+ // arrange
+ var explorer = Configured( "services.AddEndpointsApiExplorer();", "Explorer" );
+ var versioning = Configured( "services.AddApiVersioning().AddApiExplorer();", "Versioning" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( explorer, versioning );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0020 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_the_redundant_call_as_unnecessary_code()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddEndpointsApiExplorer();
+ services.AddApiVersioning().AddApiExplorer();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "services.AddEndpointsApiExplorer();" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Info );
+ diagnostic.Descriptor.CustomTags.Should().Contain( WellKnownDiagnosticTags.Unnecessary );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_the_unversioned_call_as_a_warning()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddEndpointsApiExplorer();
+ services.AddApiVersioning();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Severity.Should().Be( DiagnosticSeverity.Warning );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( params string[] sources ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( sources ) )
+ .Where( diagnostic => diagnostic.Id is AV0020 or AV0021 )];
+
+ private static string Configured( string body, string name = "Startup" ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class {{name}}
+ {
+ public static void ConfigureServices( IServiceCollection services )
+ {
+ {{body}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/AssumeDefaultApiVersionAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/AssumeDefaultApiVersionAnalyzerTest.cs
new file mode 100644
index 000000000..6ea87ce2a
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/AssumeDefaultApiVersionAnalyzerTest.cs
@@ -0,0 +1,595 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class AssumeDefaultApiVersionAnalyzerTest
+{
+ private const string AV0016 = nameof( AV0016 );
+
+ private const string VersionedController = """
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """;
+
+ [Fact]
+ public async Task analyzer_should_report_when_every_endpoint_declares_a_version()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0016 );
+ }
+
+ [Theory]
+ [InlineData( "new MediaTypeApiVersionReader()" )]
+ [InlineData( """new MediaTypeApiVersionReader( "v" )""" )]
+ [InlineData( """ApiVersionReader.Combine( new QueryStringApiVersionReader(), new MediaTypeApiVersionReader() )""" )]
+ [InlineData( """ApiVersionReader.Combine( new MediaTypeApiVersionReader(), new HeaderApiVersionReader( "api-version" ) )""" )]
+ [InlineData( """new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build()""" )]
+ public async Task analyzer_should_not_report_when_the_version_is_read_from_the_media_type( string reader )
+ {
+ // arrange
+ // a client asking for "application/json" has named no version and never will, so assuming a
+ // default is what keeps it working
+ var source = Controllers( VersionedController, Reading( reader ) );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData( """new HeaderApiVersionReader( "api-version" )""" )]
+ [InlineData( "new QueryStringApiVersionReader()" )]
+ [InlineData( """ApiVersionReader.Combine( new QueryStringApiVersionReader(), new UrlSegmentApiVersionReader() )""" )]
+ public async Task analyzer_should_report_when_the_version_is_read_from_elsewhere( string reader )
+ {
+ // arrange
+ // every other reader requires a client to name the version it wants
+ var source = Controllers( VersionedController, Reading( reader ) );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0016 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_reader_that_cannot_be_decided()
+ {
+ // arrange
+ // a reader that cannot be read as written may well be reading the media type
+ var source = """
+ using System.Collections.Generic;
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ private static IEnumerable Readers => new IApiVersionReader[0];
+
+ public static void ConfigureServices( IServiceCollection services ) =>
+ services.AddApiVersioning(
+ options =>
+ {
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ApiVersionReader = ApiVersionReader.Combine( Readers );
+ } );
+ }
+
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_an_endpoint_declares_nothing()
+ {
+ // arrange
+ // the unversioned endpoint is reachable without a version, which is what the default is for
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class LegacyController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_an_endpoint_is_version_neutral()
+ {
+ // arrange
+ // declaring neutrality is still declaring something, so the default cannot apply
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/[controller]" )]
+ public class HealthController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0016 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_only_an_action_declares_a_version()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ [ApiVersion( 1.0 )]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0016 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_every_route_is_constrained()
+ {
+ // arrange
+ // nothing declares a version, but nothing can be reached without naming one either
+ var source = Controllers( """
+ [ApiController]
+ [Route( "api/v{version:apiVersion}/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0016 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_dual_route_registration()
+ {
+ // arrange
+ // the pair is how a default version is applied to a URL segment, so it is deliberate
+ var source = Controllers( """
+ [ApiController]
+ [Route( "api/[controller]" )]
+ [Route( "api/v{version:apiVersion}/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_dual_route_registration_when_versioned()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/[controller]" )]
+ [Route( "api/v{version:apiVersion}/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_the_setting_is_absent()
+ {
+ // arrange
+ var source = Controllers(
+ """
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """,
+ configure: "services.AddApiVersioning();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_the_setting_is_false()
+ {
+ // arrange
+ var source = Controllers(
+ """
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """,
+ configure: "services.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = false );" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_assignment()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length )
+ .Should()
+ .Be( "options.AssumeDefaultVersionWhenUnspecified = true" );
+ diagnostic.Descriptor.CustomTags.Should().Contain( WellKnownDiagnosticTags.Unnecessary );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_a_convention_is_unrecognized()
+ {
+ // arrange
+ // a convention that is not understood may version anything, so nothing can be concluded
+ var source = """
+ using Asp.Versioning;
+ using Asp.Versioning.Conventions;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.AspNetCore.Mvc.ApplicationModels;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public class CustomConvention : IControllerConvention
+ {
+ public bool Apply( IControllerConventionBuilder builder, ControllerModel controller ) => true;
+ }
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) =>
+ services.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true )
+ .AddMvc( options => options.Conventions.Add( new CustomConvention() ) );
+ }
+
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_a_namespace_declares_the_version()
+ {
+ // arrange
+ // the convention is understood, so the namespace versions the controller
+ var source = VersionedByNamespace( "Api.v1.Controllers" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0016 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_a_namespace_declares_nothing()
+ {
+ // arrange
+ var source = VersionedByNamespace( "Api.Controllers" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ private static string VersionedByNamespace( string @namespace ) =>
+ $$"""
+ using Asp.Versioning;
+ using Asp.Versioning.Conventions;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) =>
+ services.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true )
+ .AddMvc( options => options.Conventions.Add( new VersionByNamespaceConvention() ) );
+ }
+
+ namespace {{@namespace}}
+ {
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ }
+ """;
+
+ [Fact]
+ public async Task analyzer_should_report_for_versioned_minimal_apis()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/api/orders", () => "" ).HasApiVersion( 1.0 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0016 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_for_an_unversioned_minimal_api()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/api/orders", () => "" ).HasApiVersion( 1.0 );
+ app.MapGet( "/api/legacy", () => "" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_a_group_declares_the_version()
+ {
+ // arrange
+ var source = Application( """
+ var api = app.MapGroup( "/api" ).HasApiVersion( 1.0 );
+
+ api.MapGet( "/orders", () => "" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0016 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_for_a_constrained_minimal_api()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/api/v{version:apiVersion}/orders", () => "" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0016 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_a_route_cannot_be_followed()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) =>
+ services.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true );
+
+ public static void MapOrders( IEndpointRouteBuilder builder ) =>
+ builder.MapGet( "/orders", () => "" ).HasApiVersion( 1.0 );
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_any_endpoints()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) =>
+ services.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true );
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ // both rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( string source ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( source ) ).Where( diagnostic => diagnostic.Id == AV0016 )];
+
+ private static string Reading( string reader ) =>
+ $$"""
+ services.AddApiVersioning(
+ options =>
+ {
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ApiVersionReader = {{reader}};
+ } );
+ """;
+
+ private static string Controllers(
+ string controllers,
+ string configure =
+ "services.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true );" ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) => {{configure}}
+ }
+
+ {{controllers}}
+ """;
+
+ private static string Application( string endpoints ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) =>
+ services.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true );
+
+ public static void Configure( WebApplication app )
+ {
+ {{endpoints}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/DefaultApiVersionAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/DefaultApiVersionAnalyzerTest.cs
new file mode 100644
index 000000000..925214781
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/DefaultApiVersionAnalyzerTest.cs
@@ -0,0 +1,373 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class DefaultApiVersionAnalyzerTest
+{
+ private const string AV0011 = nameof( AV0011 );
+ private const string AV0012 = nameof( AV0012 );
+
+ [Theory]
+ [InlineData( "ApiVersion.Default" )]
+ [InlineData( "new ApiVersion( 1, 0 )" )]
+ [InlineData( "new ApiVersion( 1 )" )]
+ [InlineData( "new( 1, 0 )" )]
+ [InlineData( "new ApiVersion( 1.0 )" )]
+ [InlineData( "new ApiVersion( majorVersion: 1, minorVersion: 0 )" )]
+ [InlineData( "new ApiVersion( minorVersion: 0, majorVersion: 1 )" )]
+ public async Task analyzer_should_report_unnecessary_default_api_version( string version )
+ {
+ // arrange
+ var source = Configured( "ApiVersioningOptions", version );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0011 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_neutral_default_api_version()
+ {
+ // arrange
+ var source = Configured( "ApiVersioningOptions", "ApiVersion.Neutral" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0012 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_the_options_that_decide_the_default()
+ {
+ // arrange
+ var source = Configured( "ApiVersioningOptions", "ApiVersion.Default" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0011 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_the_api_explorer_default()
+ {
+ // arrange
+ // the API explorer is given whatever default the versioning options were given, so a version
+ // that matches is reported against what it came from rather than against the version here
+ var source = Configured( "ApiExplorerOptions", "ApiVersion.Default" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().NotContain( diagnostic => diagnostic.Id == AV0011 );
+ }
+
+ [Theory]
+ [InlineData( "ApiVersion.Neutral", AV0012 )]
+ public async Task analyzer_should_report_for_a_descendent_of_options( string version, string expected )
+ {
+ // arrange
+ var source = $$"""
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+
+ public class CustomApiExplorerOptions : ApiExplorerOptions
+ {
+ }
+
+ public class Startup
+ {
+ public void Configure( CustomApiExplorerOptions options ) =>
+ options.DefaultApiVersion = {{version}};
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( expected );
+ }
+
+ [Theory]
+ [InlineData( "ApiVersion.Default", AV0011 )]
+ [InlineData( "ApiVersion.Neutral", AV0012 )]
+ public async Task analyzer_should_report_in_an_object_initializer( string version, string expected )
+ {
+ // arrange
+ var source = $$"""
+ using Asp.Versioning;
+
+ public class Startup
+ {
+ public ApiVersioningOptions Configure() =>
+ new() { DefaultApiVersion = {{version}} };
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( expected );
+ }
+
+ [Theory]
+ [InlineData( "ApiVersion.Default", AV0011 )]
+ [InlineData( "ApiVersion.Neutral", AV0012 )]
+ public async Task analyzer_should_report_when_configured_by_options_setup( string version, string expected )
+ {
+ // arrange
+ // the rule matches the assignment, so where the options are configured does not matter
+ var source = $$"""
+ using Asp.Versioning;
+ using Microsoft.Extensions.Options;
+
+ public class ConfigureApiVersioning : IConfigureOptions
+ {
+ public void Configure( ApiVersioningOptions options ) =>
+ options.DefaultApiVersion = {{version}};
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( expected );
+ }
+
+ [Theory]
+ [InlineData( "ApiVersion.Neutral", AV0012 )]
+ public async Task analyzer_should_report_when_configured_after_setup( string version, string expected )
+ {
+ // arrange
+ var source = $$"""
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.Extensions.Options;
+
+ public class PostConfigureApiExplorer : IPostConfigureOptions
+ {
+ public void PostConfigure( string name, ApiExplorerOptions options ) =>
+ options.DefaultApiVersion = {{version}};
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( expected );
+ }
+
+ [Theory]
+ [InlineData( "new ApiVersion( 2, 0 )" )]
+ [InlineData( "new ApiVersion( 1, 1 )" )]
+ [InlineData( "new ApiVersion( 0, 9 )" )]
+ [InlineData( "new ApiVersion( 2.0 )" )]
+ [InlineData( "new ApiVersion( 1, 0, \"beta\" )" )]
+ [InlineData( "new ApiVersion( 1.0, \"beta\" )" )]
+ [InlineData( "new ApiVersion( new DateOnly( 2016, 1, 1 ) )" )]
+ public async Task analyzer_should_not_report_a_version_other_than_the_default( string version )
+ {
+ // arrange
+ var source = Configured( "ApiVersioningOptions", version );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_version_known_only_at_run_time()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+
+ public class Startup
+ {
+ public void Configure( ApiVersioningOptions options, ApiVersion version ) =>
+ options.DefaultApiVersion = version;
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_another_property()
+ {
+ // arrange
+ // the rules are about the default version, not any other version an option may hold
+ var source = """
+ using Asp.Versioning;
+
+ public class Startup
+ {
+ public void Configure( ApiVersioningOptions options ) =>
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_an_unrelated_default_api_version()
+ {
+ // arrange
+ // the property name matches, but the type it is declared on is not a versioning option
+ var source = """
+ using Asp.Versioning;
+
+ public class Unrelated
+ {
+ public ApiVersion DefaultApiVersion { get; set; }
+ }
+
+ public class Startup
+ {
+ public void Configure( Unrelated options ) =>
+ options.DefaultApiVersion = ApiVersion.Default;
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_unnecessary_default_api_version_as_style()
+ {
+ // arrange
+ var source = Configured( "ApiVersioningOptions", "ApiVersion.Default" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Info );
+ diagnostic.Descriptor.Category.Should().Be( "Style" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_neutral_default_api_version_as_usage()
+ {
+ // arrange
+ var source = Configured( "ApiVersioningOptions", "ApiVersion.Neutral" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Error );
+ diagnostic.Descriptor.Category.Should().Be( "Usage" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_unnecessary_default_api_version_as_unnecessary_code()
+ {
+ // arrange
+ // the tag is what fades the code out in an IDE rather than marking it as a problem
+ var source = Configured( "ApiVersioningOptions", "ApiVersion.Default" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should()
+ .ContainSingle()
+ .Which.Descriptor.CustomTags
+ .Should()
+ .Contain( WellKnownDiagnosticTags.Unnecessary );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_unnecessary_default_api_version_across_the_assignment()
+ {
+ // arrange
+ // the entire assignment can be removed, so that is what is faded out
+ var source = Configured( "ApiVersioningOptions", "ApiVersion.Default" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var span = diagnostics.Should().ContainSingle().Subject.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length )
+ .Should()
+ .Be( "options.DefaultApiVersion = ApiVersion.Default" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_neutral_default_api_version_as_unnecessary_code()
+ {
+ // arrange
+ // a neutral version is wrong rather than redundant, so it must not be faded out
+ var source = Configured( "ApiVersioningOptions", "ApiVersion.Neutral" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should()
+ .ContainSingle()
+ .Which.Descriptor.CustomTags
+ .Should()
+ .NotContain( WellKnownDiagnosticTags.Unnecessary );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_assigned_version()
+ {
+ // arrange
+ var source = Configured( "ApiVersioningOptions", "ApiVersion.Neutral" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var span = diagnostics.Should().ContainSingle().Subject.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "ApiVersion.Neutral" );
+ }
+
+ private static string Configured( string options, string version ) =>
+ $$"""
+ using System;
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+
+ public class Startup
+ {
+ public void Configure( {{options}} options ) =>
+ options.DefaultApiVersion = {{version}};
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/DefaultValueAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/DefaultValueAnalyzerTest.cs
new file mode 100644
index 000000000..d3236c12e
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/DefaultValueAnalyzerTest.cs
@@ -0,0 +1,189 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class DefaultValueAnalyzerTest
+{
+ private const string AV0017 = nameof( AV0017 );
+
+ [Theory]
+ [InlineData( "options.RouteConstraintName = \"apiVersion\"" )]
+ [InlineData( "options.ReportApiVersions = false" )]
+ [InlineData( "options.AssumeDefaultVersionWhenUnspecified = false" )]
+ [InlineData( "options.UnsupportedApiVersionStatusCode = 400" )]
+ public async Task analyzer_should_report_a_default_on_api_versioning_options( string assignment )
+ {
+ // arrange
+ var source = Configured( "ApiVersioningOptions", assignment );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0017 );
+ }
+
+ [Theory]
+ [InlineData( "options.RouteConstraintName = \"version\"" )]
+ [InlineData( "options.ReportApiVersions = true" )]
+ [InlineData( "options.AssumeDefaultVersionWhenUnspecified = true" )]
+ [InlineData( "options.UnsupportedApiVersionStatusCode = 404" )]
+ public async Task analyzer_should_not_report_a_value_other_than_the_default( string assignment )
+ {
+ // arrange
+ var source = Configured( "ApiVersioningOptions", assignment );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData( "options.GroupNameFormat = \"\"" )]
+ [InlineData( "options.GroupNameFormat = string.Empty" )]
+ [InlineData( "options.SubstitutionFormat = \"VVV\"" )]
+ [InlineData( "options.SubstituteApiVersionInUrl = false" )]
+ [InlineData( "options.AddApiVersionParametersWhenVersionNeutral = false" )]
+ [InlineData( "options.FormatGroupName = null" )]
+ public async Task analyzer_should_report_a_default_on_api_explorer_options( string assignment )
+ {
+ // arrange
+ var source = Configured( "ApiExplorerOptions", assignment );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0017 );
+ }
+
+ [Theory]
+ [InlineData( "options.UseQualifiedNames = false" )]
+ [InlineData( "options.MetadataOptions = ODataMetadataOptions.None" )]
+ [InlineData( "options.SubstituteApiVersionInUrl = false" )]
+ public async Task analyzer_should_report_a_default_on_a_descendent( string assignment )
+ {
+ // arrange
+ var source = Configured( "ODataApiExplorerOptions", assignment );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0017 );
+ }
+
+ [Theory]
+ [InlineData( "options.AssumeDefaultVersionWhenUnspecified = false" )]
+ [InlineData( "options.RouteConstraintName = string.Empty" )]
+ [InlineData( "options.RouteConstraintName = \"apiVersion\"" )]
+ [InlineData( "options.DefaultApiVersion = ApiVersion.Default" )]
+ public async Task analyzer_should_not_report_a_value_shared_with_api_versioning( string assignment )
+ {
+ // arrange
+ // what the API explorer defaults to is decided by the versioning options rather than by the
+ // property, so a shared value is reported on its own
+ var source = Configured( "ApiExplorerOptions", assignment );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_the_default_api_version()
+ {
+ // arrange
+ // the default version can be spelled several ways and is reported on its own
+ var source = Configured( "ApiVersioningOptions", "options.DefaultApiVersion = ApiVersion.Default" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_value_known_only_at_run_time()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+
+ public static class Startup
+ {
+ public static void Configure( ApiVersioningOptions options, bool report ) =>
+ options.ReportApiVersions = report;
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_an_unrelated_option()
+ {
+ // arrange
+ var source = """
+ public class Unrelated
+ {
+ public bool ReportApiVersions { get; set; }
+ }
+
+ public static class Startup
+ {
+ public static void Configure( Unrelated options ) => options.ReportApiVersions = false;
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_the_assignment_as_unnecessary_code()
+ {
+ // arrange
+ var source = Configured( "ApiVersioningOptions", "options.ReportApiVersions = false" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "options.ReportApiVersions = false" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Info );
+ diagnostic.Descriptor.CustomTags.Should().Contain( WellKnownDiagnosticTags.Unnecessary );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( string source ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( source ) ).Where( diagnostic => diagnostic.Id == AV0017 )];
+
+ private static string Configured( string options, string assignment ) =>
+ $$"""
+ using System;
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+ using Asp.Versioning.OData;
+
+ public static class Startup
+ {
+ public static void Configure( {{options}} options ) => {{assignment}};
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/DescribeApiVersionsAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/DescribeApiVersionsAnalyzerTest.cs
new file mode 100644
index 000000000..6e78f6e17
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/DescribeApiVersionsAnalyzerTest.cs
@@ -0,0 +1,309 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class DescribeApiVersionsAnalyzerTest
+{
+ private const string AV0027 = nameof( AV0027 );
+
+ [Theory]
+ [InlineData( "AddApiExplorer" )]
+ [InlineData( "AddODataApiExplorer" )]
+ [InlineData( "AddGrpcApiExplorer" )]
+ [InlineData( "AddOpenApi" )]
+ public async Task analyzer_should_report_for_each_api_explorer( string explorer )
+ {
+ // arrange
+ var source = Configured(
+ $"builder.Services.AddApiVersioning().{explorer}();",
+ """app.MapGet( "/order", () => "" );""",
+ "app.Services.GetRequiredService()" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0027 );
+ }
+
+ [Theory]
+ [InlineData( "app.Services.GetService()" )]
+ [InlineData( "app.Services.GetRequiredService()" )]
+ [InlineData( "app.Services.GetService( typeof( IApiVersionDescriptionProvider ) )" )]
+ [InlineData( "app.Services.GetRequiredService( typeof( IApiVersionDescriptionProvider ) )" )]
+ public async Task analyzer_should_report_each_way_the_provider_is_resolved( string resolution )
+ {
+ // arrange
+ var source = Configured(
+ "builder.Services.AddApiVersioning().AddApiExplorer();",
+ """app.MapGet( "/order", () => "" );""",
+ resolution );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0027 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_a_minimal_api()
+ {
+ // arrange
+ // the services knew about every API there was by the time they were built
+ var source = Configured(
+ "builder.Services.AddApiVersioning().AddApiExplorer();",
+ "",
+ "app.Services.GetRequiredService()" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_an_api_explorer()
+ {
+ // arrange
+ var source = Configured(
+ "builder.Services.AddApiVersioning();",
+ """app.MapGet( "/order", () => "" );""",
+ "app.Services.GetRequiredService()" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_another_service()
+ {
+ // arrange
+ var source = Configured(
+ "builder.Services.AddApiVersioning().AddApiExplorer();",
+ """app.MapGet( "/order", () => "" );""",
+ "app.Services.GetRequiredService()" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_the_endpoints_that_describe_themselves()
+ {
+ // arrange
+ // describing from the application waits until every API has been mapped
+ var source = """
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Program
+ {
+ public static void Main()
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ builder.Services.AddApiVersioning().AddApiExplorer();
+
+ var app = builder.Build();
+
+ app.MapGet( "/order", () => "" );
+
+ var descriptions = app.DescribeApiVersions();
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_resolution_inside_a_callback()
+ {
+ // arrange
+ // the UI is configured by a callback that closes over the application, which is where the
+ // descriptions are reached from
+ var source = """
+ using System;
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Program
+ {
+ public static void Main()
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ builder.Services.AddApiVersioning().AddApiExplorer();
+
+ var app = builder.Build();
+
+ app.MapGet( "/order", () => "" );
+
+ Action configure = () =>
+ {
+ var provider = app.Services.GetRequiredService();
+
+ foreach ( var description in provider.ApiVersionDescriptions )
+ {
+ }
+ };
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0027 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_descriptions_taken_inside_a_callback()
+ {
+ // arrange
+ // the same closure describing the versions from the application instead
+ var source = """
+ using System;
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Program
+ {
+ public static void Main()
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ builder.Services.AddApiVersioning().AddApiExplorer();
+
+ var app = builder.Build();
+
+ app.MapGet( "/order", () => "" );
+
+ Action configure = () =>
+ {
+ foreach ( var description in app.DescribeApiVersions() )
+ {
+ }
+ };
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_each_resolution()
+ {
+ // arrange
+ const string Twice = "app.Services.GetRequiredService();" +
+ "var second = app.Services.GetRequiredService()";
+ var source = Configured(
+ "builder.Services.AddApiVersioning().AddApiExplorer();",
+ """app.MapGet( "/order", () => "" );""",
+ Twice );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().HaveCount( 2 ).And.OnlyContain( diagnostic => diagnostic.Id == AV0027 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_files()
+ {
+ // arrange
+ var api = """
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+
+ public static class Orders
+ {
+ public static void MapOrders( this IEndpointRouteBuilder endpoints ) =>
+ endpoints.MapGet( "/order", () => "" );
+ }
+ """;
+ var startup = Configured(
+ "builder.Services.AddApiVersioning().AddApiExplorer();",
+ "",
+ "app.Services.GetRequiredService()" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( startup, api );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0027 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_resolution_call_site()
+ {
+ // arrange
+ var source = Configured(
+ "builder.Services.AddApiVersioning().AddApiExplorer();",
+ """app.MapGet( "/order", () => "" );""",
+ "app.Services.GetRequiredService()" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "GetRequiredService" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Warning );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( params string[] sources ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( sources ) ).Where( diagnostic => diagnostic.Id == AV0027 )];
+
+ private static string Configured( string services, string endpoints, string resolution ) =>
+ $$"""
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Program
+ {
+ public static void Main()
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ {{services}}
+
+ var app = builder.Build();
+
+ {{endpoints}}
+
+ var provider = {{resolution}};
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/IgnoredRouteComponentsAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/IgnoredRouteComponentsAnalyzerTest.cs
new file mode 100644
index 000000000..cc0061ca7
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/IgnoredRouteComponentsAnalyzerTest.cs
@@ -0,0 +1,233 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class IgnoredRouteComponentsAnalyzerTest
+{
+ private const string AV0023 = nameof( AV0023 );
+
+ [Fact]
+ public async Task analyzer_should_report_route_components_configured_for_odata()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddControllers().AddOData( options => options.AddRouteComponents( "api", new EdmModel() ) );
+ services.AddApiVersioning().AddOData();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0023 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_route_components_configured_by_options()
+ {
+ // arrange
+ // the options can be reached without going through OData itself
+ var source = Configured( """
+ services.Configure( options => options.AddRouteComponents( new EdmModel() ) );
+ services.AddApiVersioning().AddOData();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0023 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_route_components_call_site()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddControllers().AddOData( options => options.AddRouteComponents( "api", new EdmModel() ) );
+ services.AddApiVersioning().AddOData();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var span = diagnostics.Should().ContainSingle().Subject.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "AddRouteComponents" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_route_components_are_versioned()
+ {
+ // arrange
+ var source = Configured(
+ """services.AddApiVersioning().AddOData( options => options.AddRouteComponents( "api" ) );""" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_odata_is_versioned_with_a_setup_action()
+ {
+ // arrange
+ // stating the same prefix in both places collides once the versioned options are resolved
+ var source = Configured( """
+ services.AddControllers().AddOData( options => options.AddRouteComponents( "api", new EdmModel() ) );
+ services.AddApiVersioning().AddOData( options => options.AddRouteComponents( "api" ) );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0023 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_only_the_route_components_configured_for_odata()
+ {
+ // arrange
+ // the versioned options declare AddRouteComponents of their own, which is the correct one
+ var source = Configured( """
+ services.AddControllers().AddOData( options => options.AddRouteComponents( "api", new EdmModel() ) );
+ services.AddApiVersioning().AddOData( options => options.AddRouteComponents( "other" ) );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var location = diagnostics.Should().ContainSingle().Subject.Location;
+ var line = location.GetLineSpan().StartLinePosition.Line;
+
+ source.Split( '\n' )[line].Should().Contain( "AddControllers" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_versioned_odata()
+ {
+ // arrange
+ // nothing replaces the options, so the route components are applied as they are written
+ var source = Configured( """
+ services.AddControllers().AddOData( options => options.AddRouteComponents( "api", new EdmModel() ) );
+ services.AddApiVersioning();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_the_odata_api_explorer()
+ {
+ // arrange
+ // the explorer does not replace the options the way the core services do
+ var source = Configured( """
+ services.AddControllers().AddOData( options => options.AddRouteComponents( "api", new EdmModel() ) );
+ services.AddApiVersioning().AddODataApiExplorer();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_each_route_component()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddControllers().AddOData( options =>
+ {
+ options.AddRouteComponents( "api", new EdmModel() );
+ options.AddRouteComponents( "other", new EdmModel() );
+ } );
+ services.AddApiVersioning().AddOData();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().HaveCount( 2 ).And.OnlyContain( diagnostic => diagnostic.Id == AV0023 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_files()
+ {
+ // arrange
+ var odata = Configured(
+ """services.AddControllers().AddOData( options => options.AddRouteComponents( "api", new EdmModel() ) );""",
+ "Data" );
+ var versioning = Configured( "services.AddApiVersioning().AddOData();", "Versioning" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( odata, versioning );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0023 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_user_defined_add_route_components()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public class Components
+ {
+ public void AddRouteComponents( string prefix )
+ {
+ }
+ }
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services )
+ {
+ new Components().AddRouteComponents( "api" );
+ services.AddApiVersioning().AddOData();
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( params string[] sources ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( sources ) )
+ .Where( diagnostic => diagnostic.Id == AV0023 )];
+
+ private static string Configured( string body, string name = "Startup" ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.OData;
+ using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.OData.Edm;
+
+ public static class {{name}}
+ {
+ public static void ConfigureServices( IServiceCollection services )
+ {
+ {{body}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/InheritedApiExplorerOptionAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/InheritedApiExplorerOptionAnalyzerTest.cs
new file mode 100644
index 000000000..1cf3c19e7
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/InheritedApiExplorerOptionAnalyzerTest.cs
@@ -0,0 +1,318 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class InheritedApiExplorerOptionAnalyzerTest
+{
+ private const string AV0024 = nameof( AV0024 );
+
+ [Theory]
+ [InlineData( "new ApiVersion( 2, 0 )" )]
+ [InlineData( "new ApiVersion( 2.0 )" )]
+ [InlineData( "new ApiVersion( 1, 1, \"beta\" )" )]
+ public async Task analyzer_should_report_a_value_matching_api_versioning( string version )
+ {
+ // arrange
+ var source = Configured(
+ $"versioning.DefaultApiVersion = {version}",
+ $"explorer.DefaultApiVersion = {version}" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0024 );
+ }
+
+ [Theory]
+ [InlineData( "new ApiVersion( 2, 0 )", "new ApiVersion( 2.0 )" )]
+ [InlineData( "new ApiVersion( 1 )", "new ApiVersion( 1, 0 )" )]
+ [InlineData( "ApiVersion.Default", "new ApiVersion( 1.0 )" )]
+ public async Task analyzer_should_report_the_same_version_written_two_ways(
+ string configured,
+ string restated )
+ {
+ // arrange
+ var source = Configured(
+ $"versioning.DefaultApiVersion = {configured}",
+ $"explorer.DefaultApiVersion = {restated}" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0024 );
+ }
+
+ [Theory]
+ [InlineData( "ApiVersion.Default" )]
+ [InlineData( "new ApiVersion( 1, 0 )" )]
+ [InlineData( "new ApiVersion( 1 )" )]
+ public async Task analyzer_should_report_the_default_when_api_versioning_states_none( string version )
+ {
+ // arrange
+ // the versioning options decide the default, and they default to 1.0 themselves
+ var source = Configured( "versioning.ReportApiVersions = true", $"explorer.DefaultApiVersion = {version}" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0024 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_value_differing_from_api_versioning()
+ {
+ // arrange
+ // the API explorer is meant to describe a different default than the one requests resolve to
+ var source = Configured(
+ "versioning.DefaultApiVersion = new ApiVersion( 2, 0 )",
+ "explorer.DefaultApiVersion = new ApiVersion( 1, 0 )" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_value_other_than_the_default()
+ {
+ // arrange
+ var source = Configured(
+ "versioning.ReportApiVersions = true",
+ "explorer.DefaultApiVersion = new ApiVersion( 2, 0 )" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData( "AssumeDefaultVersionWhenUnspecified", "true", "AssumeDefaultVersionWhenUnspecified", "true" )]
+ [InlineData( "RouteConstraintName", "\"version\"", "RouteConstraintName", "\"version\"" )]
+ [InlineData( "ApiVersionReader", "new QueryStringApiVersionReader()", "ApiVersionParameterSource", "new QueryStringApiVersionReader()" )]
+ [InlineData( "ApiVersionReader", "new HeaderApiVersionReader( \"api-version\" )", "ApiVersionParameterSource", "new HeaderApiVersionReader( \"api-version\" )" )]
+ public async Task analyzer_should_report_each_shared_property(
+ string source,
+ string configured,
+ string target,
+ string restated )
+ {
+ // arrange
+ var code = Configured( $"versioning.{source} = {configured}", $"explorer.{target} = {restated}" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( code );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0024 );
+ }
+
+ [Theory]
+ [InlineData( "AssumeDefaultVersionWhenUnspecified", "false" )]
+ [InlineData( "RouteConstraintName", "\"apiVersion\"" )]
+ [InlineData( "ApiVersionParameterSource", "ApiVersionReader.Default" )]
+ public async Task analyzer_should_report_a_shared_default_when_api_versioning_states_none(
+ string property,
+ string value )
+ {
+ // arrange
+ var source = Configured( "versioning.ReportApiVersions = true", $"explorer.{property} = {value}" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0024 );
+ }
+
+ [Theory]
+ [InlineData( "AssumeDefaultVersionWhenUnspecified", "true" )]
+ [InlineData( "RouteConstraintName", "\"version\"" )]
+ [InlineData( "ApiVersionParameterSource", "new HeaderApiVersionReader( \"api-version\" )" )]
+ public async Task analyzer_should_not_report_a_shared_value_other_than_the_default(
+ string property,
+ string value )
+ {
+ // arrange
+ var source = Configured( "versioning.ReportApiVersions = true", $"explorer.{property} = {value}" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_value_the_options_do_not_share()
+ {
+ // arrange
+ // the group name format belongs to the API explorer alone
+ var source = Configured( "versioning.ReportApiVersions = true", "explorer.GroupNameFormat = \"VVV\"" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_descendent_of_the_api_explorer_options()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+ using Asp.Versioning.OData;
+
+ public static class Startup
+ {
+ public static void Configure( ApiVersioningOptions versioning, ODataApiExplorerOptions explorer )
+ {
+ versioning.DefaultApiVersion = new ApiVersion( 2, 0 );
+ explorer.DefaultApiVersion = new ApiVersion( 2, 0 );
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0024 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_files()
+ {
+ // arrange
+ var versioning = """
+ using Asp.Versioning;
+ using Microsoft.Extensions.Options;
+
+ public class ConfigureApiVersioning : IConfigureOptions
+ {
+ public void Configure( ApiVersioningOptions versioning ) =>
+ versioning.DefaultApiVersion = new ApiVersion( 2, 0 );
+ }
+ """;
+ var explorer = """
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.Extensions.Options;
+
+ public class ConfigureApiExplorer : IConfigureOptions
+ {
+ public void Configure( ApiExplorerOptions explorer ) =>
+ explorer.DefaultApiVersion = new ApiVersion( 2, 0 );
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( versioning, explorer );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0024 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_api_versioning_is_configured_two_ways()
+ {
+ // arrange
+ // nothing can be said about which value the API explorer is given
+ var source = """
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+
+ public static class Startup
+ {
+ public static void Configure(
+ ApiVersioningOptions first,
+ ApiVersioningOptions second,
+ ApiExplorerOptions explorer )
+ {
+ first.DefaultApiVersion = new ApiVersion( 2, 0 );
+ second.DefaultApiVersion = new ApiVersion( 3, 0 );
+ explorer.DefaultApiVersion = new ApiVersion( 2, 0 );
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_value_known_only_at_run_time()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+
+ public static class Startup
+ {
+ public static void Configure( ApiVersioningOptions versioning, ApiExplorerOptions explorer, ApiVersion version )
+ {
+ versioning.DefaultApiVersion = version;
+ explorer.DefaultApiVersion = version;
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_the_assignment_as_unnecessary_code()
+ {
+ // arrange
+ var source = Configured(
+ "versioning.DefaultApiVersion = new ApiVersion( 2, 0 )",
+ "explorer.DefaultApiVersion = new ApiVersion( 2, 0 )" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "explorer.DefaultApiVersion = new ApiVersion( 2, 0 )" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Info );
+ diagnostic.Descriptor.CustomTags.Should().Contain( WellKnownDiagnosticTags.Unnecessary );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( params string[] sources ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( sources ) ).Where( diagnostic => diagnostic.Id == AV0024 )];
+
+ private static string Configured( string versioning, string explorer ) =>
+ $$"""
+ using Asp.Versioning;
+ using Asp.Versioning.ApiExplorer;
+
+ public static class Startup
+ {
+ public static void Configure( ApiVersioningOptions versioning, ApiExplorerOptions explorer )
+ {
+ {{versioning}};
+ {{explorer}};
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingAddMvcAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingAddMvcAnalyzerTest.cs
new file mode 100644
index 000000000..0de821ca8
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingAddMvcAnalyzerTest.cs
@@ -0,0 +1,180 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class MissingAddMvcAnalyzerTest
+{
+ private const string AV0013 = nameof( AV0013 );
+
+ [Theory]
+ [InlineData( "services.AddControllers();" )]
+ [InlineData( "services.AddControllers( options => { } );" )]
+ [InlineData( "services.AddMvcCore();" )]
+ [InlineData( "services.AddMvcCore( options => { } );" )]
+ public async Task analyzer_should_report_controllers_without_versioned_mvc( string controllers )
+ {
+ // arrange
+ var source = Startup( controllers + "\n services.AddApiVersioning();" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0013 );
+ }
+
+ [Theory]
+ [InlineData( "services.AddApiVersioning().AddMvc();" )]
+ [InlineData( "services.AddApiVersioning().AddMvc( options => { } );" )]
+ [InlineData( "services.AddApiVersioning( options => { } ).AddMvc();" )]
+ public async Task analyzer_should_not_report_when_mvc_is_versioned( string versioning )
+ {
+ // arrange
+ var source = Startup( "services.AddControllers();\n " + versioning );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_mvc_is_versioned_separately()
+ {
+ // arrange
+ // the builder is often held rather than chained
+ var source = Startup( """
+ services.AddControllers();
+
+ var builder = services.AddApiVersioning();
+
+ builder.AddMvc();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_controllers()
+ {
+ // arrange
+ // a minimal API is versioned without ever adding MVC
+ var source = Startup( "services.AddApiVersioning();" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_api_versioning()
+ {
+ // arrange
+ // there is no call site to report against, and nothing was versioned to begin with
+ var source = Startup( "services.AddControllers();" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_mvc_adds_its_own_unrelated_mvc()
+ {
+ // arrange
+ // MVC declares an AddMvc of its own, which does not version anything
+ var source = Startup( """
+ services.AddControllers();
+ services.AddMvc();
+ services.AddApiVersioning();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0013 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_mvc_is_versioned_in_another_file()
+ {
+ // arrange
+ // the calls are compilation wide, so they need not appear together
+ var controllers = Startup( "services.AddControllers();", "Controllers" );
+ var versioning = Startup( "services.AddApiVersioning().AddMvc();", "Versioning" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( controllers, versioning );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_controllers_are_added_in_another_file()
+ {
+ // arrange
+ var controllers = Startup( "services.AddControllers();", "Controllers" );
+ var versioning = Startup( "services.AddApiVersioning();", "Versioning" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( controllers, versioning );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0013 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_api_versioning_call_site()
+ {
+ // arrange
+ var source = Startup( "services.AddControllers();\n services.AddApiVersioning();" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var span = diagnostics.Should().ContainSingle().Subject.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "AddApiVersioning" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_every_api_versioning_call_site()
+ {
+ // arrange
+ var controllers = Startup( "services.AddControllers();", "Controllers" );
+ var first = Startup( "services.AddApiVersioning();", "First" );
+ var second = Startup( "services.AddApiVersioning();", "Second" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( controllers, first, second );
+
+ // assert
+ diagnostics.Should().HaveCount( 2 ).And.OnlyContain( diagnostic => diagnostic.Id == AV0013 );
+ }
+
+ private static string Startup( string body, string name = "Startup" ) =>
+ $$"""
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public class {{name}}
+ {
+ public void ConfigureServices( IServiceCollection services )
+ {
+ {{body}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingAddODataAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingAddODataAnalyzerTest.cs
new file mode 100644
index 000000000..3f8231eb6
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingAddODataAnalyzerTest.cs
@@ -0,0 +1,214 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class MissingAddODataAnalyzerTest
+{
+ private const string AV0022 = nameof( AV0022 );
+
+ [Fact]
+ public async Task analyzer_should_report_unversioned_odata()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddControllers().AddOData();
+ services.AddApiVersioning();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0022 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_unversioned_odata_from_mvc_core()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddMvcCore().AddOData();
+ services.AddApiVersioning();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0022 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_api_versioning_call_site()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddControllers().AddOData();
+ services.AddApiVersioning();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var span = diagnostics.Should().ContainSingle().Subject.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "AddApiVersioning" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_versioned_odata()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddControllers().AddOData();
+ services.AddApiVersioning().AddOData();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_the_odata_api_explorer_alone()
+ {
+ // arrange
+ // the explorer registers the versioned services it needs without the rest of them
+ var source = Configured( """
+ services.AddControllers().AddOData();
+ services.AddApiVersioning().AddODataApiExplorer();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_odata()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddControllers();
+ services.AddApiVersioning();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_api_versioning()
+ {
+ // arrange
+ var source = Configured( "services.AddControllers().AddOData();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_files()
+ {
+ // arrange
+ var odata = Configured( "services.AddControllers().AddOData();", "Data" );
+ var versioning = Configured( "services.AddApiVersioning();", "Versioning" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( odata, versioning );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0022 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_versioned_odata_is_in_another_file()
+ {
+ // arrange
+ var odata = Configured( "services.AddControllers().AddOData();", "Data" );
+ var versioning = Configured( "services.AddApiVersioning().AddOData();", "Versioning" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( odata, versioning );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_each_api_versioning_call_site()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddControllers().AddOData();
+ services.AddApiVersioning();
+ services.AddApiVersioning();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().HaveCount( 2 ).And.OnlyContain( diagnostic => diagnostic.Id == AV0022 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_user_defined_add_odata()
+ {
+ // arrange
+ var source = """
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class ODataExtensions
+ {
+ public static IServiceCollection AddOData( this IServiceCollection services ) => services;
+ }
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services )
+ {
+ services.AddOData();
+ services.AddApiVersioning();
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( params string[] sources ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( sources ) )
+ .Where( diagnostic => diagnostic.Id == AV0022 )];
+
+ private static string Configured( string body, string name = "Startup" ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.OData;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class {{name}}
+ {
+ public static void ConfigureServices( IServiceCollection services )
+ {
+ {{body}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingApiBehaviorAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingApiBehaviorAnalyzerTest.cs
new file mode 100644
index 000000000..7a95c366f
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingApiBehaviorAnalyzerTest.cs
@@ -0,0 +1,280 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class MissingApiBehaviorAnalyzerTest
+{
+ private const string AV0014 = nameof( AV0014 );
+
+ [Fact]
+ public async Task analyzer_should_report_controller_without_api_behavior()
+ {
+ // arrange
+ var source = Controllers( """
+ public class OrdersController : ControllerBase
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0014 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_controller_with_api_behavior()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ public class OrdersController : ControllerBase
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData( "[assembly: ApiController]" )]
+ [InlineData( "[assembly: Microsoft.AspNetCore.Mvc.ApiController]" )]
+ public async Task analyzer_should_not_report_when_api_behavior_is_applied_to_the_assembly( string attribute )
+ {
+ // arrange
+ // the second form is what a build generates from an AssemblyAttribute item
+ var source = $$"""
+ using Microsoft.AspNetCore.Mvc;
+
+ {{attribute}}
+
+ public class OrdersController : ControllerBase
+ {
+ }
+
+ public class PeopleController : ControllerBase
+ {
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_the_assembly_applies_api_behavior_in_another_file()
+ {
+ // arrange
+ var attribute = """
+ [assembly: Microsoft.AspNetCore.Mvc.ApiController]
+ """;
+ var controller = Controllers( """
+ public class OrdersController : ControllerBase
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( attribute, controller );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_user_interface_controller()
+ {
+ // arrange
+ // Controller extends ControllerBase, but serves views rather than an API
+ var source = Controllers( """
+ public class HomeController : Controller
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_through_a_user_interface_base_class()
+ {
+ // arrange
+ var source = Controllers( """
+ public abstract class UserInterfaceController : Controller
+ {
+ }
+
+ public class HomeController : UserInterfaceController
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_through_a_user_defined_base_class()
+ {
+ // arrange
+ var source = Controllers( """
+ public abstract class ApiControllerBase : ControllerBase
+ {
+ }
+
+ public class OrdersController : ApiControllerBase
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0014 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_a_base_class_applies_api_behavior()
+ {
+ // arrange
+ // the attribute is inherited, so the base class applies it for every controller under it
+ var source = Controllers( """
+ [ApiController]
+ public abstract class ApiControllerBase : ControllerBase
+ {
+ }
+
+ public class OrdersController : ApiControllerBase
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_an_abstract_controller()
+ {
+ // arrange
+ var source = Controllers( """
+ public abstract class ApiControllerBase : ControllerBase
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_nested_controller()
+ {
+ // arrange
+ var source = Controllers( """
+ public static class Outer
+ {
+ public class OrdersController : ControllerBase
+ {
+ }
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_class_that_is_not_a_controller()
+ {
+ // arrange
+ var source = Controllers( """
+ public class Orders
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_each_controller_without_api_behavior()
+ {
+ // arrange
+ var source = Controllers( """
+ public class OrdersController : ControllerBase
+ {
+ }
+
+ [ApiController]
+ public class PeopleController : ControllerBase
+ {
+ }
+
+ public class BooksController : ControllerBase
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().HaveCount( 2 ).And.OnlyContain( diagnostic => diagnostic.Id == AV0014 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_controller_name()
+ {
+ // arrange
+ var source = Controllers( """
+ public class OrdersController : ControllerBase
+ {
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var span = diagnostics.Should().ContainSingle().Subject.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "OrdersController" );
+ }
+
+ private static string Controllers( string body ) =>
+ $$"""
+ using Microsoft.AspNetCore.Mvc;
+
+ {{body}}
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingApiExplorerAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingApiExplorerAnalyzerTest.cs
new file mode 100644
index 000000000..eca29337e
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingApiExplorerAnalyzerTest.cs
@@ -0,0 +1,266 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class MissingApiExplorerAnalyzerTest
+{
+ private const string AV0031 = nameof( AV0031 );
+
+ [Theory]
+ [InlineData( "services.AddApiVersioning().AddOpenApi();" )]
+ [InlineData( "services.AddApiVersioning().AddMvc().AddOpenApi();" )]
+ public async Task analyzer_should_report_the_missing_api_explorer( string chain )
+ {
+ // arrange
+ var source = Configured( chain );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+
+ diagnostic.Id.Should().Be( AV0031 );
+ diagnostic.GetMessage().Should().Contain( "AddApiExplorer()" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_the_missing_odata_api_explorer()
+ {
+ // arrange
+ var source = Configured( "services.AddApiVersioning().AddOData().AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+
+ diagnostic.Id.Should().Be( AV0031 );
+ diagnostic.GetMessage().Should().Contain( "AddODataApiExplorer()" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_the_missing_grpc_api_explorer()
+ {
+ // arrange
+ var source = Configured( "services.AddApiVersioning().AddGrpc().AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+
+ diagnostic.Id.Should().Be( AV0031 );
+ diagnostic.GetMessage().Should().Contain( "AddGrpcApiExplorer()" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_each_missing_api_explorer()
+ {
+ // arrange
+ // an application can be built more than one way, and each way is described on its own
+ var source = Configured( "services.AddApiVersioning().AddOData().AddGrpc().AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Select( diagnostic => diagnostic.GetMessage() )
+ .Should()
+ .HaveCount( 2 )
+ .And.Contain( message => message.Contains( "AddODataApiExplorer()" ) )
+ .And.Contain( message => message.Contains( "AddGrpcApiExplorer()" ) );
+ }
+
+ [Theory]
+ [InlineData( "services.AddApiVersioning().AddApiExplorer().AddOpenApi();" )]
+ [InlineData( "services.AddApiVersioning().AddMvc().AddApiExplorer().AddOpenApi();" )]
+ [InlineData( "services.AddApiVersioning().AddOData().AddODataApiExplorer().AddOpenApi();" )]
+ [InlineData( "services.AddApiVersioning().AddGrpc().AddGrpcApiExplorer().AddOpenApi();" )]
+ public async Task analyzer_should_not_report_a_configured_api_explorer( string chain )
+ {
+ // arrange
+ var source = Configured( chain );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_the_base_explorer_alongside_a_specialized_one()
+ {
+ // arrange
+ // OData is described by an explorer that builds on the one the rest of them use
+ var source = Configured( "services.AddApiVersioning().AddOData().AddODataApiExplorer().AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData( "services.AddApiVersioning().AddGrpcApiExplorer().AddOpenApi();" )]
+ [InlineData( "services.AddApiVersioning().AddODataApiExplorer().AddOpenApi();" )]
+ public async Task analyzer_should_not_report_a_specialized_explorer_used_on_its_own( string chain )
+ {
+ // arrange
+ // a specialized explorer can be configured without the APIs it specializes in
+ var source = Configured( chain );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_grpc_without_its_explorer_however_it_is_described()
+ {
+ // arrange
+ // the base explorer says nothing about gRPC, which is described by an explorer of its own
+ var source = Configured( "services.AddApiVersioning().AddGrpc().AddApiExplorer().AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "AddGrpcApiExplorer()" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_openapi()
+ {
+ // arrange
+ // nothing is generating a document for the explorer to describe
+ var source = Configured( "services.AddApiVersioning().AddOData();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_the_services_of_the_same_name()
+ {
+ // arrange
+ // gRPC and MVC declare methods of their own that say nothing about API versioning
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void Configure( IServiceCollection services )
+ {
+ services.AddGrpc();
+ services.AddMvc();
+ services.AddApiVersioning().AddApiExplorer().AddOpenApi();
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_configuration_split_across_variables()
+ {
+ // arrange
+ // which builder the calls were made against is not tracked
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void Configure( IServiceCollection services )
+ {
+ var builder = services.AddApiVersioning();
+
+ builder.AddOData();
+ builder.AddOpenApi();
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "AddODataApiExplorer()" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_configuration_split_across_methods()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void Configure( IServiceCollection services ) =>
+ Describe( services.AddApiVersioning().AddOData() ).AddOpenApi();
+
+ private static IApiVersioningBuilder Describe( IApiVersioningBuilder builder ) =>
+ builder.AddODataApiExplorer();
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_openapi_call_site()
+ {
+ // arrange
+ var source = Configured( "services.AddApiVersioning().AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "AddOpenApi" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Warning );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( params string[] sources ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( sources ) ).Where( diagnostic => diagnostic.Id == AV0031 )];
+
+ private static string Configured( string chain ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void Configure( IServiceCollection services )
+ {
+ {{chain}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingDocumentInfoAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingDocumentInfoAnalyzerTest.cs
new file mode 100644
index 000000000..a8dc7e654
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/MissingDocumentInfoAnalyzerTest.cs
@@ -0,0 +1,182 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class MissingDocumentInfoAnalyzerTest
+{
+ private const string AV0025 = nameof( AV0025 );
+ private const string Description = """[assembly: System.Reflection.AssemblyDescription( "An example API." )]""";
+
+ [Fact]
+ public async Task analyzer_should_report_a_document_without_a_description()
+ {
+ // arrange
+ var source = Configured( "services.AddApiVersioning().AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0025 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_description_written_by_hand()
+ {
+ // arrange
+ var source = Configured( "services.AddApiVersioning().AddOpenApi();", Description );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_description_generated_from_the_project()
+ {
+ // arrange
+ // the project generates the attribute into a file of its own, which is part of the compilation
+ var generated = $"""
+ [assembly: System.Reflection.AssemblyCompany( "Contoso" )]
+ [assembly: System.Reflection.AssemblyTitle( "Example" )]
+ {Description}
+ """;
+ var source = Configured( "services.AddApiVersioning().AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source, generated );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_description_that_is_empty()
+ {
+ // arrange
+ // an empty description is left out of the document the same way a missing one is
+ var source = Configured(
+ "services.AddApiVersioning().AddOpenApi();",
+ """[assembly: System.Reflection.AssemblyDescription( "" )]""" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0025 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_when_only_a_title_is_present()
+ {
+ // arrange
+ // the project supplies a title whether it was asked for or not, which says nothing about the
+ // description
+ var source = Configured(
+ "services.AddApiVersioning().AddOpenApi();",
+ """[assembly: System.Reflection.AssemblyTitle( "Example" )]""" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0025 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_openapi()
+ {
+ // arrange
+ var source = Configured( "services.AddApiVersioning().AddApiExplorer();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_library()
+ {
+ // arrange
+ // the document is described from the assembly the application was started from
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class ServiceDefaults
+ {
+ public static void ConfigureServices( IServiceCollection services ) =>
+ services.AddApiVersioning().AddOpenApi();
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().NotContain( diagnostic => diagnostic.Id == AV0025 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_each_call_site()
+ {
+ // arrange
+ var source = Configured( """
+ services.AddApiVersioning().AddOpenApi();
+ services.AddApiVersioning().AddOpenApi();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().HaveCount( 2 ).And.OnlyContain( diagnostic => diagnostic.Id == AV0025 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_openapi_call_site()
+ {
+ // arrange
+ var source = Configured( "services.AddApiVersioning().AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "AddOpenApi" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Info );
+ diagnostic.Descriptor.Category.Should().Be( "Documentation" );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( params string[] sources ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( OutputKind.ConsoleApplication, sources ) )
+ .Where( diagnostic => diagnostic.Id == AV0025 )];
+
+ private static string Configured( string body, string attributes = "" ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.Extensions.DependencyInjection;
+
+ {{attributes}}
+
+ public static class Program
+ {
+ public static void Main()
+ {
+ }
+
+ public static void ConfigureServices( IServiceCollection services )
+ {
+ {{body}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/PolicyEffectiveDateAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/PolicyEffectiveDateAnalyzerTest.cs
new file mode 100644
index 000000000..b7893eb8b
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/PolicyEffectiveDateAnalyzerTest.cs
@@ -0,0 +1,292 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class PolicyEffectiveDateAnalyzerTest
+{
+ private const string AV0028 = nameof( AV0028 );
+
+ [Fact]
+ public async Task analyzer_should_report_a_sunset_before_its_deprecation()
+ {
+ // arrange
+ var source = Configured( """
+ options.Policies.Deprecate( 0.9 ).Effective( 2024, 6, 1 );
+ options.Policies.Sunset( 0.9 ).Effective( 2024, 1, 1 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0028 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_sunset_after_its_deprecation()
+ {
+ // arrange
+ // deprecation announces that an API is going away and sunset is when it does
+ var source = Configured( """
+ options.Policies.Deprecate( 0.9 ).Effective( 2024, 1, 1 );
+ options.Policies.Sunset( 0.9 ).Effective( 2024, 6, 1 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_the_same_day()
+ {
+ // arrange
+ var source = Configured( """
+ options.Policies.Deprecate( 0.9 ).Effective( 2024, 1, 1 );
+ options.Policies.Sunset( 0.9 ).Effective( 2024, 1, 1 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData( "0.9", "0.9" )]
+ [InlineData( "\"Orders\"", "\"Orders\"" )]
+ [InlineData( "\"Orders\", 1, 0", "\"Orders\", 1, 0" )]
+ [InlineData( "new ApiVersion( 2, 0 )", "new ApiVersion( 2, 0 )" )]
+ [InlineData( "1.0", "new ApiVersion( 1, 0 )" )]
+ [InlineData( "\"Orders\", 1.0", "\"Orders\", new ApiVersion( 1 )" )]
+ public async Task analyzer_should_report_policies_keyed_the_same_way( string deprecated, string sunset )
+ {
+ // arrange
+ var source = Configured( $"""
+ options.Policies.Deprecate( {deprecated} ).Effective( 2024, 6, 1 );
+ options.Policies.Sunset( {sunset} ).Effective( 2024, 1, 1 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0028 );
+ }
+
+ [Theory]
+ [InlineData( "\"Orders\"", "0.9" )]
+ [InlineData( "0.9", "\"Orders\"" )]
+ [InlineData( "\"Orders\", 0.9", "\"Orders\"" )]
+ [InlineData( "\"Orders\", 0.9", "0.9" )]
+ public async Task analyzer_should_report_policies_an_api_reaches_together( string deprecated, string sunset )
+ {
+ // arrange
+ // a policy that leaves a part unstated is reached by every API that agrees with the rest
+ var source = Configured( $"""
+ options.Policies.Deprecate( {deprecated} ).Effective( 2024, 6, 1 );
+ options.Policies.Sunset( {sunset} ).Effective( 2024, 1, 1 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0028 );
+ }
+
+ [Theory]
+ [InlineData( "\"Orders\"", "\"People\"" )]
+ [InlineData( "0.9", "1.0" )]
+ [InlineData( "\"Orders\", 0.9", "\"Orders\", 1.0" )]
+ [InlineData( "\"Orders\", 0.9", "\"People\", 0.9" )]
+ [InlineData( "\"Orders\", 0.9", "1.0" )]
+ public async Task analyzer_should_not_report_policies_no_api_reaches_together(
+ string deprecated,
+ string sunset )
+ {
+ // arrange
+ var source = Configured( $"""
+ options.Policies.Deprecate( {deprecated} ).Effective( 2024, 6, 1 );
+ options.Policies.Sunset( {sunset} ).Effective( 2024, 1, 1 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData( "DateTimeOffset.Now" )]
+ [InlineData( "DateTimeOffset.Now.AddDays( 60 )" )]
+ [InlineData( "date" )]
+ public async Task analyzer_should_not_report_a_date_from_somewhere_else( string date )
+ {
+ // arrange
+ // what the date will be is not decided here
+ var source = $$"""
+ using System;
+ using Asp.Versioning;
+
+ public static class Startup
+ {
+ public static void Configure( ApiVersioningOptions options, DateTimeOffset date )
+ {
+ options.Policies.Deprecate( 0.9 ).Effective( 2024, 6, 1 );
+ options.Policies.Sunset( 0.9 ).Effective( {{date}} );
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_date_built_from_its_parts()
+ {
+ // arrange
+ var source = Configured( """
+ options.Policies.Deprecate( 0.9 ).Effective( new DateTimeOffset( new DateTime( 2024, 6, 1 ) ) );
+ options.Policies.Sunset( 0.9 ).Effective( new DateTimeOffset( new DateTime( 2024, 1, 1 ) ) );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0028 );
+ }
+
+ [Theory]
+ [InlineData( "\"\"" )]
+ [InlineData( "default( string ), default( ApiVersion )" )]
+ public async Task analyzer_should_not_report_a_policy_no_api_reaches( string key )
+ {
+ // arrange
+ // stating neither a name nor a version reaches nothing at all rather than everything
+ var source = Configured( $"""
+ options.Policies.Deprecate( {key} ).Effective( 2024, 6, 1 );
+ options.Policies.Sunset( 0.9 ).Effective( 2024, 1, 1 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_policy_without_a_date()
+ {
+ // arrange
+ // a policy that never takes effect has nothing to compare
+ var source = Configured( """
+ options.Policies.Deprecate( 0.9 ).Effective( 2024, 6, 1 );
+ options.Policies.Sunset( 0.9 ).Link( "policy.html" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_deprecation_alone()
+ {
+ // arrange
+ var source = Configured( "options.Policies.Deprecate( 0.9 ).Effective( 2024, 6, 1 );" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_sunset_once_for_any_number_of_deprecations()
+ {
+ // arrange
+ var source = Configured( """
+ options.Policies.Deprecate( "Orders" ).Effective( 2024, 6, 1 );
+ options.Policies.Deprecate( 0.9 ).Effective( 2024, 7, 1 );
+ options.Policies.Sunset( "Orders", 0.9 ).Effective( 2024, 1, 1 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0028 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_files()
+ {
+ // arrange
+ var deprecation = Configured(
+ "options.Policies.Deprecate( 0.9 ).Effective( 2024, 6, 1 );",
+ "Deprecation" );
+ var sunset = Configured(
+ "options.Policies.Sunset( 0.9 ).Effective( 2024, 1, 1 );",
+ "Sunset" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( deprecation, sunset );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0028 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_sunset_date()
+ {
+ // arrange
+ var source = Configured( """
+ options.Policies.Deprecate( 0.9 ).Effective( 2024, 6, 1 ).Link( "policy.html" ).Title( "t" );
+ options.Policies.Sunset( 0.9 ).Effective( 2024, 1, 1 ).Link( "policy.html" ).Title( "t" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+ var line = diagnostic.Location.GetLineSpan().StartLinePosition.Line;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "Effective" );
+ source.Split( '\n' )[line].Should().Contain( "Sunset" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Warning );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( params string[] sources ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( sources ) ).Where( diagnostic => diagnostic.Id == AV0028 )];
+
+ private static string Configured( string policies, string name = "Startup" ) =>
+ $$"""
+ using System;
+ using Asp.Versioning;
+
+ public static class {{name}}
+ {
+ public static void Configure( ApiVersioningOptions options )
+ {
+ {{policies}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/SpecificApiVersionReaderAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/SpecificApiVersionReaderAnalyzerTest.cs
new file mode 100644
index 000000000..69465d597
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/SpecificApiVersionReaderAnalyzerTest.cs
@@ -0,0 +1,470 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class SpecificApiVersionReaderAnalyzerTest
+{
+ private const string AV0015 = nameof( AV0015 );
+
+ [Fact]
+ public async Task analyzer_should_report_url_segment_for_minimal_apis()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/api/v{version:apiVersion}/people", () => "" );
+ app.MapGet( "/api/v{version:apiVersion}/orders", () => "" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+
+ diagnostic.Id.Should().Be( AV0015 );
+ diagnostic.GetMessage().Should().Contain( "UrlSegmentApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_query_string_for_minimal_apis()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/api/people", () => "" );
+ app.MapGet( "/api/orders", () => "" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+
+ diagnostic.Id.Should().Be( AV0015 );
+ diagnostic.GetMessage().Should().Contain( "QueryStringApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_mixture_of_styles()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/api/v{version:apiVersion}/people", () => "" );
+ app.MapGet( "/api/orders", () => "" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_resolve_a_group_prefix_through_a_chain()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGroup( "/api/v{version:apiVersion}" ).MapGet( "/people", () => "" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "UrlSegmentApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_resolve_a_group_prefix_through_a_local()
+ {
+ // arrange
+ // the shape used by the minimal API examples, where the group flows through a fluent chain
+ var source = Application( """
+ var api = app.MapGroup( "/api/v{version:apiVersion}/people" )
+ .HasApiVersion( 1.0 );
+
+ api.MapGet( "/{id:int}", () => "" );
+ api.MapGet( "/", () => "" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "UrlSegmentApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_a_route_cannot_be_followed()
+ {
+ // arrange
+ // the prefix comes from the caller, so the template seen here may be missing the constraint
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void Configure( IServiceCollection services ) => services.AddApiVersioning();
+
+ public static void MapPeople( IEndpointRouteBuilder builder ) =>
+ builder.MapGet( "/people", () => "" );
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_a_template_is_not_constant()
+ {
+ // arrange
+ const string Endpoints = """
+ var route = GetRoute();
+
+ app.MapGet( route, () => "" );
+ """;
+ const string Members = """
+ private static string GetRoute() => "/api/people";
+ """;
+
+ var source = Application( Endpoints, Members );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_the_reader_is_configured()
+ {
+ // arrange
+ var source = Application(
+ """
+ app.MapGet( "/api/people", () => "" );
+ """,
+ configure: "services.AddApiVersioning( options => options.ApiVersionReader = new QueryStringApiVersionReader() );" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_any_endpoints()
+ {
+ // arrange
+ var source = Application( string.Empty );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_without_api_versioning()
+ {
+ // arrange
+ var source = """
+ using Microsoft.AspNetCore.Builder;
+
+ public static class Startup
+ {
+ public static void Configure( WebApplication app ) =>
+ app.MapGet( "/api/people", () => "" );
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_ignore_a_version_neutral_endpoint()
+ {
+ // arrange
+ // the neutral endpoint has no constraint, but must not count as a mixture
+ var source = Application( """
+ app.MapGet( "/api/v{version:apiVersion}/people", () => "" );
+ app.MapGet( "/health", () => "" ).IsApiVersionNeutral();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "UrlSegmentApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_honor_a_configured_constraint_name()
+ {
+ // arrange
+ var source = Application(
+ """
+ app.MapGet( "/api/v{version:ver}/people", () => "" );
+ """,
+ configure: "services.AddApiVersioning( options => options.RouteConstraintName = \"ver\" );" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "UrlSegmentApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_recognize_the_default_constraint_name_when_reconfigured()
+ {
+ // arrange
+ var source = Application(
+ """
+ app.MapGet( "/api/v{version:apiVersion}/people", () => "" );
+ """,
+ configure: "services.AddApiVersioning( options => options.RouteConstraintName = \"ver\" );" );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "QueryStringApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_url_segment_for_controllers()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [Route( "api/v{version:apiVersion}/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "UrlSegmentApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_query_string_for_controllers()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "QueryStringApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_ignore_a_user_interface_controller()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [Route( "api/v{version:apiVersion}/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [Route( "home" )]
+ public class HomeController : Controller
+ {
+ [HttpGet]
+ public IActionResult Index() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "UrlSegmentApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_ignore_a_version_neutral_controller()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [Route( "api/v{version:apiVersion}/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [ApiVersionNeutral]
+ [ApiController]
+ [Route( "health" )]
+ public class HealthController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.GetMessage().Should().Contain( "UrlSegmentApiVersionReader" );
+ }
+
+ [Theory]
+ [InlineData( "options.AddRouteComponents( \"api/v{version:apiVersion}\" )", "UrlSegmentApiVersionReader" )]
+ [InlineData( "options.AddRouteComponents( \"api\" )", "QueryStringApiVersionReader" )]
+ [InlineData( "options.AddRouteComponents()", "QueryStringApiVersionReader" )]
+ public async Task analyzer_should_report_for_odata_route_components( string components, string reader )
+ {
+ // arrange
+ var source = ODataApi( components );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should()
+ .ContainSingle( diagnostic => diagnostic.Id == AV0015 )
+ .Which.GetMessage()
+ .Should()
+ .Contain( reader );
+ }
+
+ [Fact]
+ public async Task analyzer_should_ignore_an_odata_controller()
+ {
+ // arrange
+ // an OData controller is routed by its registered components rather than by an attribute, so
+ // counting its template-less actions would look like a mixture of styles
+ const string Controller = """
+ [ApiController]
+ public class OrdersController : ODataController
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """;
+
+ var source = ODataApi( "options.AddRouteComponents( \"api/v{version:apiVersion}\" )", Controller );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should()
+ .ContainSingle( diagnostic => diagnostic.Id == AV0015 )
+ .Which.GetMessage()
+ .Should()
+ .Contain( "UrlSegmentApiVersionReader" );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_api_versioning_call_site()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/api/people", () => "" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzerVerifier.AnalyzeAsync( source );
+
+ // assert
+ var span = diagnostics.Should().ContainSingle().Subject.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "AddApiVersioning" );
+ }
+
+ private static string Application(
+ string endpoints,
+ string members = "",
+ string configure = "services.AddApiVersioning();" ) =>
+ $$"""
+ using System;
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services )
+ {
+ {{configure}}
+ }
+
+ public static void Configure( WebApplication app )
+ {
+ {{endpoints}}
+ }
+
+ {{members}}
+ }
+ """;
+
+ private static string ODataApi( string components, string controllers = "" ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.AspNetCore.OData.Routing.Controllers;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) =>
+ services.AddApiVersioning().AddOData( options => {{components}} );
+ }
+
+ {{controllers}}
+ """;
+
+ private static string Controllers( string controllers ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) => services.AddApiVersioning();
+ }
+
+ {{controllers}}
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/UnusedGroupNameFormatAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/UnusedGroupNameFormatAnalyzerTest.cs
new file mode 100644
index 000000000..368d29b45
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/UnusedGroupNameFormatAnalyzerTest.cs
@@ -0,0 +1,345 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class UnusedGroupNameFormatAnalyzerTest
+{
+ private const string AV0026 = nameof( AV0026 );
+ private const string Format = "options.FormatGroupName = ( group, version ) => $\"{group}-{version}\"";
+
+ [Fact]
+ public async Task analyzer_should_report_a_format_no_api_uses()
+ {
+ // arrange
+ var source = Controller( Format );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0026 );
+ }
+
+ [Theory]
+ [InlineData( "[ApiExplorerSettings( GroupName = \"orders\" )]" )]
+ [InlineData( "[ApiExplorerSettings( IgnoreApi = false, GroupName = \"orders\" )]" )]
+ public async Task analyzer_should_not_report_a_group_name_on_a_controller( string attribute )
+ {
+ // arrange
+ var source = Controller( Format, attribute );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData( "[ApiExplorerSettings( IgnoreApi = true )]" )]
+ [InlineData( "[ApiExplorerSettings( GroupName = \"\" )]" )]
+ [InlineData( "[ApiExplorerSettings( GroupName = null )]" )]
+ public async Task analyzer_should_report_settings_without_a_group_name( string attribute )
+ {
+ // arrange
+ var source = Controller( Format, attribute );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0026 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_group_name_on_a_minimal_api()
+ {
+ // arrange
+ var source = MinimalApi( """app.MapGet( "/order", () => "" ).WithGroupName( "orders" );""" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_group_name_on_a_route_group()
+ {
+ // arrange
+ // a group of endpoints carries the name to every endpoint within it
+ var source = MinimalApi( """
+ app.MapGet( "/order", () => "" );
+ app.MapGroup( "/orders" ).WithGroupName( "orders" );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_group_name_attribute_on_a_handler()
+ {
+ // arrange
+ var source = """
+ using System;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+
+ public static class Startup
+ {
+ public static void Configure( WebApplication app, ApiExplorerOptions options )
+ {
+ app.MapGet( "/order", Handler );
+ options.FormatGroupName = ( group, version ) => $"{group}-{version}";
+ }
+
+ [EndpointGroupName( "orders" )]
+ private static string Handler() => "";
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_minimal_api_without_a_group_name()
+ {
+ // arrange
+ var source = MinimalApi( """app.MapGet( "/order", () => "" );""" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0026 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_single_group_name_among_many_apis()
+ {
+ // arrange
+ // one name anywhere is enough to put the callback to use
+ var source = """
+ using System;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Mvc;
+
+ public class OrdersController : ControllerBase
+ {
+ }
+
+ [ApiExplorerSettings( GroupName = "people" )]
+ public class PeopleController : ControllerBase
+ {
+ }
+
+ public static class Startup
+ {
+ public static void Configure( WebApplication app, ApiExplorerOptions options )
+ {
+ app.MapGet( "/order", () => "" );
+ options.FormatGroupName = ( group, version ) => $"{group}-{version}";
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_cleared_callback()
+ {
+ // arrange
+ // nothing is reached when there is no callback to reach
+ var source = Controller( "options.FormatGroupName = null" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_group_name_known_only_at_run_time()
+ {
+ // arrange
+ var source = """
+ using System;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.AspNetCore.Builder;
+
+ public static class Startup
+ {
+ public static void Configure( WebApplication app, ApiExplorerOptions options, string name )
+ {
+ app.MapGet( "/order", () => "" ).WithGroupName( name );
+ options.FormatGroupName = ( group, version ) => $"{group}-{version}";
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_group_names_come_from_elsewhere()
+ {
+ // arrange
+ // a provider of its own says nothing about whether any names are set
+ var source = """
+ using System;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Mvc.ApiExplorer;
+
+ [AttributeUsage( AttributeTargets.Class )]
+ public sealed class TenantAttribute : Attribute, IApiDescriptionGroupNameProvider
+ {
+ public string GroupName => "tenant";
+ }
+
+ public static class Startup
+ {
+ public static void Configure( WebApplication app, ApiExplorerOptions options )
+ {
+ app.MapGet( "/order", () => "" );
+ options.FormatGroupName = ( group, version ) => $"{group}-{version}";
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_no_apis_are_declared()
+ {
+ // arrange
+ // an application whose APIs are declared elsewhere keeps its group names there as well
+ var source = """
+ using System;
+ using Asp.Versioning.ApiExplorer;
+
+ public static class Startup
+ {
+ public static void Configure( ApiExplorerOptions options ) =>
+ options.FormatGroupName = ( group, version ) => $"{group}-{version}";
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_files()
+ {
+ // arrange
+ var controller = """
+ using Microsoft.AspNetCore.Mvc;
+
+ public class OrdersController : ControllerBase
+ {
+ }
+ """;
+ var startup = """
+ using System;
+ using Asp.Versioning.ApiExplorer;
+
+ public static class Startup
+ {
+ public static void Configure( ApiExplorerOptions options ) =>
+ options.FormatGroupName = ( group, version ) => $"{group}-{version}";
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( controller, startup );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0026 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_the_assignment_as_unnecessary_code()
+ {
+ // arrange
+ var source = Controller( Format );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( Format );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Info );
+ diagnostic.Descriptor.CustomTags.Should().Contain( WellKnownDiagnosticTags.Unnecessary );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( params string[] sources ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( sources ) ).Where( diagnostic => diagnostic.Id == AV0026 )];
+
+ private static string Controller( string assignment, string attribute = "" ) =>
+ $$"""
+ using System;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.AspNetCore.Mvc;
+
+ {{attribute}}
+ public class OrdersController : ControllerBase
+ {
+ }
+
+ public static class Startup
+ {
+ public static void Configure( ApiExplorerOptions options ) => {{assignment}};
+ }
+ """;
+
+ private static string MinimalApi( string body ) =>
+ $$"""
+ using System;
+ using Asp.Versioning.ApiExplorer;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+
+ public static class Startup
+ {
+ public static void Configure( WebApplication app, ApiExplorerOptions options )
+ {
+ {{body}}
+ options.FormatGroupName = ( group, version ) => $"{group}-{version}";
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/VersionedAndNeutralAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/VersionedAndNeutralAnalyzerTest.cs
new file mode 100644
index 000000000..c111a23c2
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/VersionedAndNeutralAnalyzerTest.cs
@@ -0,0 +1,450 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class VersionedAndNeutralAnalyzerTest
+{
+ private const string AV0019 = nameof( AV0019 );
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_version_neutral_action()
+ {
+ // arrange
+ // an action states something more explicit than the controller, which is the intended use
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [ApiVersion( 2.0 )]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+
+ [HttpDelete( "{id}" )]
+ [ApiVersionNeutral]
+ public IActionResult Delete( int id ) => NoContent();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_versioned_action_of_a_neutral_controller()
+ {
+ // arrange
+ // the controller has no versions at all, so an action cannot claim one
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ [ApiVersion( 1.0 )]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0019 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_controller_declaring_both()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [ApiVersionNeutral]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0019 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_an_action_declaring_both()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ [ApiVersion( 1.0 )]
+ [ApiVersionNeutral]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0019 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_a_collated_controller()
+ {
+ // arrange
+ // both collate to Orders, so the neutral declaration silences the version on the other
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/orders" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [ApiController]
+ [ApiVersion( 2.0 )]
+ [Route( "api/v{version:apiVersion}/orders" )]
+ public class Orders2Controller : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0019 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_versions_across_collated_controllers()
+ {
+ // arrange
+ // versioning the same API from more than one class is ordinary
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/v{version:apiVersion}/orders" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [ApiController]
+ [ApiVersion( 2.0 )]
+ [Route( "api/v{version:apiVersion}/orders" )]
+ public class Orders2Controller : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_controllers_that_do_not_collate()
+ {
+ // arrange
+ // a neutral API alongside a separate versioned API is entirely reasonable
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/health" )]
+ public class HealthController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [ApiController]
+ [ApiVersion( 1.0 )]
+ [Route( "api/v{version:apiVersion}/orders" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_collate_by_an_explicit_controller_name()
+ {
+ // arrange
+ // the declared name overrides the type name, so these collate despite not looking alike
+ var source = Controllers( """
+ [ApiController]
+ [ControllerName( "Orders" )]
+ [ApiVersionNeutral]
+ [Route( "api/orders" )]
+ public class LegacyController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [ApiController]
+ [ApiVersion( 2.0 )]
+ [Route( "api/v{version:apiVersion}/orders" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0019 );
+ }
+
+ [Theory]
+ [InlineData( "services.AddSingleton();" )]
+ [InlineData( "services.AddSingleton( ControllerNameConvention.Original );" )]
+ [InlineData( "services.AddSingleton();" )]
+ public async Task analyzer_should_not_collate_under_an_unrecognized_name_convention( string registration )
+ {
+ // arrange
+ // collation follows the naming convention, and a replacement decides it by other rules
+ var source = Collated( registration );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData( "services.AddSingleton();" )]
+ [InlineData( "services.AddSingleton( ControllerNameConvention.Grouped );" )]
+ public async Task analyzer_should_collate_under_a_trimming_name_convention( string registration )
+ {
+ // arrange
+ // these are the conventions that trim trailing numbers, which is what is reproduced here
+ var source = Collated( registration );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0019 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_versioned_endpoint_of_a_neutral_group()
+ {
+ // arrange
+ var source = Application( """
+ var api = app.NewVersionedApi().IsApiVersionNeutral();
+
+ api.MapGet( "/orders", () => "" ).HasApiVersion( 1.0 );
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0019 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_neutral_endpoint_of_a_versioned_group()
+ {
+ // arrange
+ // the endpoint states something more explicit than the group, which is the intended use
+ var source = Application( """
+ var api = app.NewVersionedApi().HasApiVersion( 1.0 );
+
+ api.MapGet( "/orders", () => "" );
+ api.MapDelete( "/orders/{id}", ( int id ) => "" ).IsApiVersionNeutral();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_an_endpoint_declaring_both()
+ {
+ // arrange
+ var source = Application( """
+ app.MapGet( "/orders", () => "" ).HasApiVersion( 1.0 ).IsApiVersionNeutral();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0019 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_when_a_group_cannot_be_followed()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Asp.Versioning.Builder;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+
+ public static class Startup
+ {
+ public static void MapOrders( IEndpointRouteBuilder builder ) =>
+ builder.MapGet( "/orders", () => "" ).HasApiVersion( 1.0 );
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_declared_version_as_an_error()
+ {
+ // arrange
+ var source = Controllers( """
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/[controller]" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ [ApiVersion( 1.0 )]
+ public IActionResult Get() => Ok();
+ }
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "ApiVersion( 1.0 )" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Error );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( string source ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( source ) ).Where( diagnostic => diagnostic.Id == AV0019 )];
+
+ private static string Collated( string registration ) =>
+ $$"""
+ using Asp.Versioning;
+ using Asp.Versioning.Conventions;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.AspNetCore.Mvc.ApplicationModels;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public class CustomNameConvention : IControllerNameConvention
+ {
+ public string NormalizeName( string controllerName ) => controllerName;
+
+ public string GroupName( string controllerName ) => controllerName;
+ }
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services )
+ {
+ {{registration}}
+ services.AddApiVersioning();
+ }
+ }
+
+ [ApiController]
+ [ApiVersionNeutral]
+ [Route( "api/orders" )]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+
+ [ApiController]
+ [ApiVersion( 2.0 )]
+ [Route( "api/v{version:apiVersion}/orders" )]
+ public class Orders2Controller : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get() => Ok();
+ }
+ """;
+
+ private static string Controllers( string controllers ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) => services.AddApiVersioning();
+ }
+
+ {{controllers}}
+ """;
+
+ private static string Application( string endpoints ) =>
+ $$"""
+ using Asp.Versioning;
+ using Asp.Versioning.Builder;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Routing;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void ConfigureServices( IServiceCollection services ) => services.AddApiVersioning();
+
+ public static void Configure( WebApplication app )
+ {
+ {{endpoints}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/VersionedOpenApiAnalyzerTest.cs b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/VersionedOpenApiAnalyzerTest.cs
new file mode 100644
index 000000000..0ab20b083
--- /dev/null
+++ b/src/Analyzers/test/Asp.Versioning.Api.Analyzers.Tests/Rules/VersionedOpenApiAnalyzerTest.cs
@@ -0,0 +1,274 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.Analyzers.Rules;
+
+public class VersionedOpenApiAnalyzerTest
+{
+ private const string AV0029 = nameof( AV0029 );
+ private const string AV0030 = nameof( AV0030 );
+
+ [Theory]
+ [InlineData( "AddApiExplorer" )]
+ [InlineData( "AddODataApiExplorer" )]
+ [InlineData( "AddGrpcApiExplorer" )]
+ [InlineData( "AddOpenApi" )]
+ public async Task analyzer_should_report_openapi_services_for_each_explorer( string explorer )
+ {
+ // arrange
+ var source = Configured( $"services.AddApiVersioning().{explorer}();", "services.AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0029 );
+ }
+
+ [Theory]
+ [InlineData( "services.AddOpenApi();" )]
+ [InlineData( """services.AddOpenApi( "v1" );""" )]
+ [InlineData( "services.AddOpenApi( options => { } );" )]
+ public async Task analyzer_should_report_any_form_of_openapi_services( string added )
+ {
+ // arrange
+ var source = Configured( "services.AddApiVersioning().AddOpenApi();", added );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0029 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_openapi_services_without_versioning()
+ {
+ // arrange
+ // an application that does not version its APIs is described by a single document
+ var source = Configured( "services.AddApiVersioning();", "services.AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_the_services_as_unnecessary_code()
+ {
+ // arrange
+ var source = Configured( "services.AddApiVersioning().AddOpenApi();", "services.AddOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "services.AddOpenApi();" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Warning );
+ diagnostic.Descriptor.CustomTags.Should().Contain( WellKnownDiagnosticTags.Unnecessary );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_a_document_that_is_not_per_version()
+ {
+ // arrange
+ var source = Mapped( "app.MapOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0030 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_document_per_version()
+ {
+ // arrange
+ var source = Mapped( "app.MapOpenApi().WithDocumentPerVersion();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_document_per_version_among_other_conventions()
+ {
+ // arrange
+ var source = Mapped( """app.MapOpenApi().WithGroupName( "docs" ).WithDocumentPerVersion();""" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_convention_applied_some_other_way()
+ {
+ // arrange
+ // the convention may well belong to the mapped endpoint, which cannot be told from here
+ var source = Mapped( """
+ var openApi = app.MapOpenApi();
+ openApi.WithDocumentPerVersion();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_not_report_a_mapped_document_without_versioning()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void Configure( WebApplication app, IServiceCollection services )
+ {
+ services.AddApiVersioning();
+ app.MapOpenApi();
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_both_rules_together()
+ {
+ // arrange
+ var source = """
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void Configure( WebApplication app, IServiceCollection services )
+ {
+ services.AddApiVersioning().AddOpenApi();
+ services.AddOpenApi();
+ app.MapOpenApi();
+ }
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Select( diagnostic => diagnostic.Id ).Should().BeEquivalentTo( [AV0029, AV0030] );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_each_call_site()
+ {
+ // arrange
+ var source = Mapped( """
+ app.MapOpenApi();
+ app.MapOpenApi();
+ """ );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ diagnostics.Should().HaveCount( 2 ).And.OnlyContain( diagnostic => diagnostic.Id == AV0030 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_across_files()
+ {
+ // arrange
+ var services = Configured( "services.AddApiVersioning().AddOpenApi();", "", "Services" );
+ var endpoints = """
+ using Microsoft.AspNetCore.Builder;
+
+ public static class Endpoints
+ {
+ public static void Configure( WebApplication app ) => app.MapOpenApi();
+ }
+ """;
+
+ // act
+ var diagnostics = await AnalyzeAsync( services, endpoints );
+
+ // assert
+ diagnostics.Should().ContainSingle().Which.Id.Should().Be( AV0030 );
+ }
+
+ [Fact]
+ public async Task analyzer_should_report_at_the_mapped_call_site()
+ {
+ // arrange
+ var source = Mapped( "app.MapOpenApi();" );
+
+ // act
+ var diagnostics = await AnalyzeAsync( source );
+
+ // assert
+ var diagnostic = diagnostics.Should().ContainSingle().Subject;
+ var span = diagnostic.Location.SourceSpan;
+
+ source.Substring( span.Start, span.Length ).Should().Be( "MapOpenApi" );
+ diagnostic.Severity.Should().Be( DiagnosticSeverity.Warning );
+ }
+
+ // other rules can legitimately apply to the same configuration, so each test is scoped to its own
+ private static async Task> AnalyzeAsync( params string[] sources ) =>
+ [.. ( await AnalyzerVerifier.AnalyzeAsync( sources ) )
+ .Where( diagnostic => diagnostic.Id == AV0029 || diagnostic.Id == AV0030 )];
+
+ private static string Configured( string versioning, string openApi, string name = "Startup" ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class {{name}}
+ {
+ public static void Configure( IServiceCollection services )
+ {
+ {{versioning}}
+ {{openApi}}
+ }
+ }
+ """;
+
+ private static string Mapped( string endpoints ) =>
+ $$"""
+ using Asp.Versioning;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.Extensions.DependencyInjection;
+
+ public static class Startup
+ {
+ public static void Configure( WebApplication app, IServiceCollection services )
+ {
+ services.AddApiVersioning().AddOpenApi();
+ {{endpoints}}
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj
index 9e5f7ed2c..6a7c5c349 100644
--- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj
+++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj
@@ -1,8 +1,8 @@
- 10.0.0
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0net45;net472Asp.VersioningASP.NET Web API Versioning API Explorer for OData v4.0
diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/README.md b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/README.md
index 25be49b8f..96a122fee 100644
--- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/README.md
+++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/README.md
@@ -12,7 +12,4 @@ useful in a number of scenarios such as test automation or OpenAPI document gene
- Asp.Versioning.ApiExplorer.ODataApiExplorer
- Asp.Versioning.ApiExplorer.ODataApiExplorerOptions
-- Asp.Versioning.Conventions.ODataQueryOptionsConventionBuilder
-
-## Release Notes
-
+- Asp.Versioning.Conventions.ODataQueryOptionsConventionBuilder
\ No newline at end of file
diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/Asp.Versioning.WebApi.OData.csproj b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/Asp.Versioning.WebApi.OData.csproj
index e820e9f11..57aeab019 100644
--- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/Asp.Versioning.WebApi.OData.csproj
+++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/Asp.Versioning.WebApi.OData.csproj
@@ -1,8 +1,8 @@
- 10.0.0
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0net45;net472Asp.VersioningAPI Versioning for ASP.NET Web API with OData v4.0
diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/README.md b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/README.md
index dc4439ac6..c8f936a04 100644
--- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/README.md
+++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/README.md
@@ -14,7 +14,4 @@ metadata attributes and conventions that you use to describe which API versions
- Asp.Versioning.OData.VersionedODataModelBuilder
- Asp.Versioning.Routing.VersionedAttributeRoutingConvention
- Asp.Versioning.Routing.VersionedMetadataRoutingConvention
-- Asp.Versioning.Routing.VersionedODataRoutingConventions
-
-## Release Notes
-
+- Asp.Versioning.Routing.VersionedODataRoutingConventions
\ No newline at end of file
diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Asp.Versioning.WebApi.ApiExplorer.csproj b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Asp.Versioning.WebApi.ApiExplorer.csproj
index d30a6e08b..be3e27a6a 100644
--- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Asp.Versioning.WebApi.ApiExplorer.csproj
+++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Asp.Versioning.WebApi.ApiExplorer.csproj
@@ -1,8 +1,8 @@
- 10.0.0
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0net45;net472ASP.NET Web API Versioning API ExplorerThe API Explorer extensions for ASP.NET Web API Versioning.
@@ -10,6 +10,10 @@
Asp;AspNet;WebAPI;Versioning;ApiExplorer
+
+
+
+
diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/README.md b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/README.md
index 4dab3e95e..88cefdf6f 100644
--- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/README.md
+++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/README.md
@@ -14,7 +14,4 @@ number of scenarios such as test automation or OpenAPI document generation.
- Asp.Versioning.ApiDescriptionGroup
- Asp.Versioning.ApiDescriptionGroupCollection
- Asp.Versioning.VersionedApiDescription
-- Asp.Versioning.VersionedApiExplorer
-
-## Release Notes
-
+- Asp.Versioning.VersionedApiExplorer
\ No newline at end of file
diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj
index 0ce3f7f92..d9b9dba72 100644
--- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj
+++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj
@@ -1,8 +1,8 @@
- 10.0.0
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0net45;net472ASP.NET Web API VersioningA service API versioning library for Microsoft ASP.NET Web API.
diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/README.md b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/README.md
index 2a24c16cc..85cf1bb96 100644
--- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/README.md
+++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/README.md
@@ -15,6 +15,4 @@ and conventions that you use to describe which API versions are implemented by y
- Asp.Versioning.ISunsetPolicyBuilder
- Asp.Versioning.IPolicyManager
- Asp.Versioning.QueryStringApiVersionReader
-- Asp.Versioning.ReportApiVersionsAttribute
-
-## Release Notes
+- Asp.Versioning.ReportApiVersionsAttribute
\ No newline at end of file
diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj
index aea9ccc74..a081ceedb 100644
--- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj
+++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj
@@ -1,8 +1,8 @@
- 10.0.1
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0$(DefaultTargetFramework)Asp.VersioningASP.NET Core API Versioning API Explorer for OData v4.0
diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs
index 5d5823721..88234dc4b 100644
--- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs
+++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs
@@ -8,7 +8,6 @@ namespace Microsoft.Extensions.DependencyInjection;
using Asp.Versioning.ApiExplorer;
using Asp.Versioning.Conventions;
using Asp.Versioning.OData;
-using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -16,7 +15,7 @@ namespace Microsoft.Extensions.DependencyInjection;
using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor;
///
-/// Provides extension methods for the interface.
+/// Provides ASP.NET Core OData specific extension methods for .
///
[CLSCompliant( false )]
public static class IApiVersioningBuilderExtensions
diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/README.md b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/README.md
index 22c90eee0..c38751325 100644
--- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/README.md
+++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/README.md
@@ -12,7 +12,4 @@ are useful in a number of scenarios such as test automation or OpenAPI document
- Asp.Versioning.ODataApiDescriptionProvider
- Asp.Versioning.ODataApiExplorerOptions
-- Asp.Versioning.Conventions.ODataQueryOptionsConventionBuilder
-
-## Release Notes
-
+- Asp.Versioning.Conventions.ODataQueryOptionsConventionBuilder
\ No newline at end of file
diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj b/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj
index 9625952cc..ca3b128fa 100644
--- a/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj
+++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj
@@ -1,8 +1,8 @@
- 10.0.2
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0$(DefaultTargetFramework)Asp.VersioningASP.NET Core API Versioning with OData v4.0
diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/README.md b/src/AspNetCore/OData/src/Asp.Versioning.OData/README.md
index 51b10f58d..bfb25eace 100644
--- a/src/AspNetCore/OData/src/Asp.Versioning.OData/README.md
+++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/README.md
@@ -13,7 +13,4 @@ metadata attributes and conventions that you use to describe which API versions
- Asp.Versioning.OData.ODataApiVersioningOptions
- Asp.Versioning.OData.VersionedODataModelBuilder
- Asp.Versioning.Routing.VersionedAttributeRoutingConvention
-- Asp.Versioning.Routing.VersionedMetadataRoutingConvention
-
-## Release Notes
-
+- Asp.Versioning.Routing.VersionedMetadataRoutingConvention
\ No newline at end of file
diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt b/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt
index 66435879a..5f282702b 100644
--- a/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt
+++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt
@@ -1,2 +1 @@
-Optimize internal use of Reflection
-Fix broken routes [Issue #1138](https://github.com/dotnet/aspnet-api-versioning/issues/1138)
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Aot.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Aot.cs
new file mode 100644
index 000000000..8d2e1caee
--- /dev/null
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Aot.cs
@@ -0,0 +1,8 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning;
+
+internal static class Aot
+{
+ internal const string TrimmingMessage = "The API Explorer does not currently support trimming or native AOT. https://aka.ms/aspnet/trimming";
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/GrpcApiVersionRouteParameter.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/GrpcApiVersionRouteParameter.cs
index ec902b4d4..61787d2e4 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/GrpcApiVersionRouteParameter.cs
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/GrpcApiVersionRouteParameter.cs
@@ -22,12 +22,12 @@ public class GrpcApiVersionRouteParameter
///
///
/// gRPC supports route parameters in route templates, but a parameter must match an entire segment. It
- /// cannot match part of a segment in the same manner as a ASP.NET route constraint and an API version does not
+ /// cannot match part of a segment in the same manner as an ASP.NET route constraint and an API version does not
/// include literal characters such as "v". As a result, the character is not included in the route
/// template.
///
///
- /// This setting adds the expected literal in the route template went is built for the API Explorer. As an example,
+ /// This setting adds the expected literal in the route template when it is built for the API Explorer. As an example,
/// the gRPC route template "api/{api-version}/example" will be generated as
/// "api/v{api-version}/example" and produce the expected behavior in the API Explorer.
///
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/GrpcJsonTranscodingDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/GrpcJsonTranscodingDescriptionProvider.cs
index 878fa0998..8a9ab89a0 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/GrpcJsonTranscodingDescriptionProvider.cs
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/GrpcJsonTranscodingDescriptionProvider.cs
@@ -24,10 +24,11 @@ namespace Asp.Versioning.ApiExplorer;
internal sealed class GrpcJsonTranscodingDescriptionProvider(
EndpointDataSource source,
FileDescriptorPool pool,
- ApiVersionMetadataCache cache,
+ IAnnotation? annotation,
IOptions options ) : IApiDescriptionProvider
{
- private readonly ApiVersionRouteConstraint apiVersionRouteConstraint = new();
+ private static readonly ApiVersionRouteConstraint ApiVersionRouteConstraint = new();
+ private readonly IAnnotation annotations = annotation ?? new DefaultMemberAnnotation();
// REF: https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs
public int Order => -900;
@@ -127,7 +128,7 @@ private void RemoveExcludedParameters( ApiDescription apiDescription, MessageDes
for ( var j = 0; j < fields.Count; j++ )
{
- if ( !cache.IsVisibleTo( fields[j], apiVersion ) )
+ if ( !annotations.IsVisible( fields[j], apiVersion ) )
{
parameters.RemoveAt( i );
break;
@@ -150,7 +151,7 @@ private void ApplyApiVersionToMessages( ApiDescription apiDescription, ApiVersio
if ( parameter.Source == BindingSource.Body &&
parameter.ModelMetadata is GrpcModelMetadata metadata )
{
- parameter.ModelMetadata = metadata.ForApiVersion( cache, apiVersion );
+ parameter.ModelMetadata = metadata.ForApiVersion( annotations, apiVersion );
}
}
@@ -162,7 +163,7 @@ private void ApplyApiVersionToMessages( ApiDescription apiDescription, ApiVersio
if ( responseType.ModelMetadata is GrpcModelMetadata metadata )
{
- responseType.ModelMetadata = metadata.ForApiVersion( cache, apiVersion );
+ responseType.ModelMetadata = metadata.ForApiVersion( annotations, apiVersion );
}
}
}
@@ -170,7 +171,7 @@ private void ApplyApiVersionToMessages( ApiDescription apiDescription, ApiVersio
// the ApiVersion type is modeled as a first-class data type, but gRPC doesn't have a representation for it. when
// we identify a parameter that represents an API version, explicitly set the expected data type and constraints
// the versioned API Explorer expects.
- private (GrpcModelMetadata ModelMetadata, ApiParameterRouteInfo? RouteInfo) NewMetadataAndRouteInfo(
+ private static (GrpcModelMetadata ModelMetadata, ApiParameterRouteInfo? RouteInfo) NewMetadataAndRouteInfo(
string name,
ModelMetadataIdentity identity,
GrpcApiExplorerOptions options,
@@ -185,7 +186,7 @@ private void ApplyApiVersionToMessages( ApiDescription apiDescription, ApiVersio
if ( routeParameter )
{
- routeInfo = new() { Constraints = [apiVersionRouteConstraint] };
+ routeInfo = new() { Constraints = [ApiVersionRouteConstraint] };
}
}
@@ -262,7 +263,7 @@ private ApiDescription NewApiDescription(
}
[RequiresDynamicCode( "Might not be available at runtime" )]
- private void AddRouteParameters(
+ private static void AddRouteParameters(
ApiDescription apiDescription,
Dictionary parameters,
GrpcApiExplorerOptions options )
@@ -328,7 +329,7 @@ private static void AddBodyParameter( ApiDescription apiDescription, BodyDescrip
}
[RequiresDynamicCode( "Might not be available at runtime" )]
- private void AddQueryParameters(
+ private static void AddQueryParameters(
ApiDescription apiDescription,
Dictionary parameters,
GrpcApiExplorerOptions options )
@@ -367,4 +368,14 @@ private void AddQueryParameters(
} );
}
}
+
+ // used when the gRPC API versioning package isn't present. nothing is annotated, so nothing is filtered
+ private sealed class DefaultMemberAnnotation : IAnnotation
+ {
+ public bool TryGet( FieldDescriptor source, [MaybeNullWhen( false )] out ApiVersionRange annotation )
+ {
+ annotation = default;
+ return false;
+ }
+ }
}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Asp.Versioning.Grpc.ApiExplorer.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Asp.Versioning.Grpc.ApiExplorer.csproj
index 0c88c5f1b..ee088f426 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Asp.Versioning.Grpc.ApiExplorer.csproj
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Asp.Versioning.Grpc.ApiExplorer.csproj
@@ -1,8 +1,8 @@
- 10.0.0
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0$(DefaultTargetFramework)Asp.VersioningASP.NET Core API Versioning API Explorer for gRPC
@@ -12,16 +12,11 @@
-
-
-
-
-
-
+
-
+
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/DependencyInjection/IServiceCollectionExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs
similarity index 62%
rename from src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/DependencyInjection/IServiceCollectionExtensions.cs
rename to src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs
index 00ac87247..f52b09460 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/DependencyInjection/IServiceCollectionExtensions.cs
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs
@@ -7,54 +7,53 @@ namespace Microsoft.Extensions.DependencyInjection;
using Asp.Versioning;
using Asp.Versioning.ApiExplorer;
using Asp.Versioning.Grpc;
-using Grpc.AspNetCore.Server;
+using Google.Protobuf.Reflection;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection.Extensions;
-using Microsoft.Extensions.Options;
using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor;
///
-/// Provides extension methods for the interface.
+/// Provides ASP.NET Core API Explorer specific extension methods for .
///
-[CLSCompliant( false )]
-public static class IServiceCollectionExtensions
+public static class IApiVersioningBuilderExtensions
{
- private const string TrimmingMessage = "MVC does not currently support trimming or native AOT. https://aka.ms/aspnet/trimming";
+ private const string TrimmingMessage = "The API Explorer does not currently support trimming or native AOT. https://aka.ms/aspnet/trimming";
- /// The extended service collection.
- /// The original .
- extension( IServiceCollection services )
+ /// The extended API versioning builder.
+ /// The original .
+ extension( IApiVersioningBuilder builder )
{
///
/// Adds the API Explorer extensions for gRPC.
///
/// An action used to configure the provided options.
[RequiresUnreferencedCode( TrimmingMessage )]
- public IServiceCollection AddGrpcApiExplorer( Action setupAction )
+ public IApiVersioningBuilder AddGrpcApiExplorer( Action setupAction )
{
- ArgumentNullException.ThrowIfNull( services );
+ ArgumentNullException.ThrowIfNull( builder );
ArgumentNullException.ThrowIfNull( setupAction );
- return services.Configure( setupAction ).AddGrpcApiExplorer();
+ builder.Services.Configure( setupAction );
+ return builder.AddGrpcApiExplorer();
}
///
/// Adds the API Explorer extensions for gRPC.
///
[RequiresUnreferencedCode( TrimmingMessage )]
- public IServiceCollection AddGrpcApiExplorer()
+ public IApiVersioningBuilder AddGrpcApiExplorer()
{
- ArgumentNullException.ThrowIfNull( services );
+ ArgumentNullException.ThrowIfNull( builder );
+
+ var services = builder.Services;
services.AddGrpc().AddJsonTranscoding();
services.TryAddEnumerable( Transient() );
- services.TryAddEnumerable( Transient, ApiVersioningGrpcOptions>() );
services.AddSingleton();
services.TryAddSingleton( NewGroupCollectionProvider );
- services.AddSingleton( NewMetadataCache );
- return services;
+ return builder;
}
}
@@ -70,9 +69,6 @@ private static IApiDescriptionGroupCollectionProvider NewGroupCollectionProvider
apiDescriptionProvider );
}
- private static ApiVersionMetadataCache NewMetadataCache( IServiceProvider serviceProvider ) =>
- new( serviceProvider.GetService() ?? ApiVersionParser.Default );
-
#pragma warning restore CA1859
#pragma warning disable IDE0079
#pragma warning disable CA1812
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Grpc/ApiVersionMetadataCache.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Grpc/ApiVersionMetadataCache.cs
deleted file mode 100644
index 729213197..000000000
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Grpc/ApiVersionMetadataCache.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (c) .NET Foundation and contributors. All rights reserved.
-
-namespace Asp.Versioning.Grpc;
-
-using Google.Protobuf.Reflection;
-using System.Collections.Concurrent;
-
-internal sealed class ApiVersionMetadataCache( IApiVersionParser parser )
-{
- private readonly ConcurrentDictionary cache = new();
-
- public ApiVersionRange Get( FieldDescriptor field ) => cache.GetOrAdd( field, Add );
-
- public bool IsVisibleTo( FieldDescriptor field, ApiVersion apiVersion ) => Get( field ).Contains( apiVersion );
-
- private ApiVersionRange Add( FieldDescriptor field )
- {
- if ( field.GetOptions()?.GetExtension( AnnotationsExtensions.Version ) is not { Count: > 0 } versions )
- {
- return ApiVersionRange.Any;
- }
-
- return ApiVersionRange.Parse( parser, versions );
- }
-}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Grpc/FieldInterceptor.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Grpc/FieldInterceptor.cs
deleted file mode 100644
index b7c3944de..000000000
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Grpc/FieldInterceptor.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (c) .NET Foundation and contributors. All rights reserved.
-
-// created by the gRPC interceptor pipeline
-#pragma warning disable CA1812
-
-namespace Asp.Versioning.Grpc;
-
-using global::Grpc.Core;
-using global::Grpc.Core.Interceptors;
-using Google.Protobuf;
-using Google.Protobuf.Reflection;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.DependencyInjection;
-
-internal sealed class FieldInterceptor : Interceptor
-{
- public override async Task UnaryServerHandler(
- TRequest request,
- ServerCallContext context,
- UnaryServerMethod continuation )
- {
- var response = await continuation( request, context ).ConfigureAwait( false );
-
- if ( response is not IMessage message )
- {
- return response;
- }
-
- var http = context.GetHttpContext();
- var feature = http.ApiVersioningFeature;
-
- if ( feature.RequestedApiVersion is not { } apiVersion )
- {
- return response;
- }
-
- var cache = http.RequestServices.GetRequiredService();
-
- FilterFields( cache, message, apiVersion );
-
- return response;
- }
-
- private static void FilterFields( ApiVersionMetadataCache cache, IMessage message, ApiVersion apiVersion )
- {
- var descriptor = message.Descriptor;
-
- foreach ( var field in descriptor.Fields.InDeclarationOrder() )
- {
- if ( !cache.IsVisibleTo( field, apiVersion ) )
- {
- field.Accessor.Clear( message );
- continue;
- }
-
- if ( field.FieldType == FieldType.Message && field.Accessor.GetValue( message ) is IMessage nested )
- {
- FilterFields( cache, nested, apiVersion );
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Grpc/GrpcModelMetadata.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Grpc/GrpcModelMetadata.cs
index fc8452b48..e3f2e7878 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Grpc/GrpcModelMetadata.cs
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Grpc/GrpcModelMetadata.cs
@@ -2,6 +2,7 @@
namespace Asp.Versioning.Grpc;
+using Asp.Versioning.ApiExplorer;
using Google.Protobuf.Reflection;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
@@ -9,10 +10,11 @@ namespace Asp.Versioning.Grpc;
internal sealed class GrpcModelMetadata : ModelMetadata
{
private readonly MessageDescriptor? messageDescriptor;
- private readonly ApiVersionMetadataCache? cache;
+ private readonly IAnnotation? annotation;
private readonly ApiVersion? apiVersion;
private readonly bool repeated;
private string? dataTypeName;
+ private IReadOnlyDictionary
[CLSCompliant( false )]
-public sealed class ApiVersionModelMetadata : ModelMetadata
+public sealed class ApiVersionModelMetadata : DelegatingModelMetadata
{
- private readonly ModelMetadata inner;
private readonly string description;
///
@@ -21,30 +20,9 @@ public sealed class ApiVersionModelMetadata : ModelMetadata
/// used to create the new instance.
/// The description associated with the model metadata.
public ApiVersionModelMetadata( IModelMetadataProvider modelMetadataProvider, string description )
- : base( ModelMetadataIdentity.ForType( typeof( string ) ) )
- {
- ArgumentNullException.ThrowIfNull( modelMetadataProvider );
- inner = modelMetadataProvider.GetMetadataForType( typeof( string ) );
- this.description = description;
- }
-
- ///
- public override IReadOnlyDictionary AdditionalValues => inner.AdditionalValues;
-
- ///
- public override ModelPropertyCollection Properties => inner.Properties;
-
- ///
- public override string? BinderModelName => inner.BinderModelName;
-
- ///
- public override Type? BinderType => inner.BinderType;
-
- ///
- public override BindingSource? BindingSource => inner.BindingSource;
-
- ///
- public override bool ConvertEmptyStringToNull => inner.ConvertEmptyStringToNull;
+ : base(
+ NewInner( modelMetadataProvider ),
+ ModelMetadataIdentity.ForType( typeof( string ) ) ) => this.description = description;
///
public override string DataTypeName => nameof( ApiVersion );
@@ -52,87 +30,12 @@ public ApiVersionModelMetadata( IModelMetadataProvider modelMetadataProvider, st
///
public override string Description => description;
- ///
- public override string? DisplayFormatString => inner.DisplayFormatString;
-
///
public override string? DisplayName => SR.ApiVersionDisplayName;
- ///
- public override string? EditFormatString => inner.EditFormatString;
-
- ///
- public override ModelMetadata? ElementMetadata => inner.ElementMetadata;
-
- ///
- public override IEnumerable>? EnumGroupedDisplayNamesAndValues => inner.EnumGroupedDisplayNamesAndValues;
-
- ///
- public override IReadOnlyDictionary? EnumNamesAndValues => inner.EnumNamesAndValues;
-
- ///
- public override bool HasNonDefaultEditFormat => inner.HasNonDefaultEditFormat;
-
- ///
- public override bool HtmlEncode => inner.HtmlEncode;
-
- ///
- public override bool HideSurroundingHtml => inner.HideSurroundingHtml;
-
- ///
- public override bool IsBindingAllowed => inner.IsBindingAllowed;
-
- ///
- public override bool IsBindingRequired => inner.IsBindingRequired;
-
- ///
- public override bool IsEnum => inner.IsEnum;
-
- ///
- public override bool IsFlagsEnum => inner.IsFlagsEnum;
-
- ///
- public override bool IsReadOnly => inner.IsReadOnly;
-
- ///
- public override bool IsRequired => inner.IsRequired;
-
- ///
- public override ModelBindingMessageProvider ModelBindingMessageProvider => inner.ModelBindingMessageProvider;
-
- ///
- public override int Order => inner.Order;
-
- ///
- public override string? Placeholder => inner.Placeholder;
-
- ///
- public override string? NullDisplayText => inner.NullDisplayText;
-
- ///
- public override IPropertyFilterProvider? PropertyFilterProvider => inner.PropertyFilterProvider;
-
- ///
- public override bool ShowForDisplay => inner.ShowForDisplay;
-
- ///
- public override bool ShowForEdit => inner.ShowForEdit;
-
- ///
- public override string? SimpleDisplayProperty => inner.SimpleDisplayProperty;
-
- ///
- public override string? TemplateHint => inner.TemplateHint;
-
- ///
- public override bool ValidateChildren => inner.ValidateChildren;
-
- ///
- public override IReadOnlyList ValidatorMetadata => inner.ValidatorMetadata;
-
- ///
- public override Func? PropertyGetter => inner.PropertyGetter;
-
- ///
- public override Action? PropertySetter => inner.PropertySetter;
+ private static ModelMetadata NewInner( IModelMetadataProvider modelMetadataProvider )
+ {
+ ArgumentNullException.ThrowIfNull( modelMetadataProvider );
+ return modelMetadataProvider.GetMetadataForType( typeof( string ) );
+ }
}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj
index 7da1560a7..f61fc7a89 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj
@@ -1,8 +1,8 @@
- 10.0.1
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0$(DefaultTargetFramework)Asp.Versioning.ApiExplorerASP.NET Core API Versioning API Explorer
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DelegatingModelMetadata.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DelegatingModelMetadata.cs
new file mode 100644
index 000000000..26ca0c1f2
--- /dev/null
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DelegatingModelMetadata.cs
@@ -0,0 +1,145 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.ApiExplorer;
+
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
+
+///
+/// Represents model metadata that delegates to other model metadata.
+///
+///
+/// declares three dozen abstract members, which makes decorating it verbose. This
+/// class forwards every member to the inner metadata so that a derived class only has to
+/// override the members it actually changes.
+///
+[CLSCompliant( false )]
+public abstract class DelegatingModelMetadata : ModelMetadata
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The model metadata to delegate to.
+ /// The identity of the model metadata.
+ protected DelegatingModelMetadata( ModelMetadata inner, ModelMetadataIdentity identity )
+ : base( identity )
+ {
+ ArgumentNullException.ThrowIfNull( inner );
+ Inner = inner;
+ }
+
+ ///
+ /// Gets the model metadata all members are delegated to.
+ ///
+ /// The inner model metadata.
+ protected ModelMetadata Inner { get; }
+
+ ///
+ public override IReadOnlyDictionary AdditionalValues => Inner.AdditionalValues;
+
+ ///
+ public override ModelPropertyCollection Properties => Inner.Properties;
+
+ ///
+ public override string? BinderModelName => Inner.BinderModelName;
+
+ ///
+ public override Type? BinderType => Inner.BinderType;
+
+ ///
+ public override BindingSource? BindingSource => Inner.BindingSource;
+
+ ///
+ public override bool ConvertEmptyStringToNull => Inner.ConvertEmptyStringToNull;
+
+ ///
+ public override string? DataTypeName => Inner.DataTypeName;
+
+ ///
+ public override string? Description => Inner.Description;
+
+ ///
+ public override string? DisplayFormatString => Inner.DisplayFormatString;
+
+ ///
+ public override string? DisplayName => Inner.DisplayName;
+
+ ///
+ public override string? EditFormatString => Inner.EditFormatString;
+
+ ///
+ public override ModelMetadata? ElementMetadata => Inner.ElementMetadata;
+
+ ///
+ public override IEnumerable>? EnumGroupedDisplayNamesAndValues =>
+ Inner.EnumGroupedDisplayNamesAndValues;
+
+ ///
+ public override IReadOnlyDictionary? EnumNamesAndValues => Inner.EnumNamesAndValues;
+
+ ///
+ public override bool HasNonDefaultEditFormat => Inner.HasNonDefaultEditFormat;
+
+ ///
+ public override bool HtmlEncode => Inner.HtmlEncode;
+
+ ///
+ public override bool HideSurroundingHtml => Inner.HideSurroundingHtml;
+
+ ///
+ public override bool IsBindingAllowed => Inner.IsBindingAllowed;
+
+ ///
+ public override bool IsBindingRequired => Inner.IsBindingRequired;
+
+ ///
+ public override bool IsEnum => Inner.IsEnum;
+
+ ///
+ public override bool IsFlagsEnum => Inner.IsFlagsEnum;
+
+ ///
+ public override bool IsReadOnly => Inner.IsReadOnly;
+
+ ///
+ public override bool IsRequired => Inner.IsRequired;
+
+ ///
+ public override ModelBindingMessageProvider ModelBindingMessageProvider => Inner.ModelBindingMessageProvider;
+
+ ///
+ public override int Order => Inner.Order;
+
+ ///
+ public override string? Placeholder => Inner.Placeholder;
+
+ ///
+ public override string? NullDisplayText => Inner.NullDisplayText;
+
+ ///
+ public override IPropertyFilterProvider? PropertyFilterProvider => Inner.PropertyFilterProvider;
+
+ ///
+ public override bool ShowForDisplay => Inner.ShowForDisplay;
+
+ ///
+ public override bool ShowForEdit => Inner.ShowForEdit;
+
+ ///
+ public override string? SimpleDisplayProperty => Inner.SimpleDisplayProperty;
+
+ ///
+ public override string? TemplateHint => Inner.TemplateHint;
+
+ ///
+ public override bool ValidateChildren => Inner.ValidateChildren;
+
+ ///
+ public override IReadOnlyList ValidatorMetadata => Inner.ValidatorMetadata;
+
+ ///
+ public override Func? PropertyGetter => Inner.PropertyGetter;
+
+ ///
+ public override Action? PropertySetter => Inner.PropertySetter;
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs
index b1509b47b..64ddb698d 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs
@@ -63,6 +63,7 @@ private static void AddApiExplorerServices( IApiVersioningBuilder builder )
services.TryAddSingleton, ApiExplorerOptionsFactory>();
services.TryAddTransient();
services.TryAddSingleton( static sp => sp.GetRequiredService().Create() );
+ services.TryAddEnumerable( Transient() );
// use internal constructor until ASP.NET Core fixes their bug
// BUG: https://github.com/dotnet/aspnetcore/issues/41773
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ModelMetadataApiVersionExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ModelMetadataApiVersionExtensions.cs
new file mode 100644
index 000000000..600534445
--- /dev/null
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ModelMetadataApiVersionExtensions.cs
@@ -0,0 +1,41 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.ApiExplorer;
+
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+///
+/// Provides API version related extension methods for model metadata.
+///
+///
+/// The API version a model was described for is recorded in keyed by
+/// . A well-known key is used rather than a shared type because the packages that produce
+/// versioned metadata do not all reference each other by design.
+///
+[CLSCompliant( false )]
+public static class ModelMetadataApiVersionExtensions
+{
+ /// The extended model metadata.
+ extension( ModelMetadata metadata )
+ {
+ ///
+ /// Gets the API version the model metadata was described for, if any.
+ ///
+ /// The described API version, or null if the metadata is not
+ /// specific to an API version.
+ /// Metadata that reports an API version is describing a subset of its model type. Metadata that
+ /// does not is describing the type as declared, which is not the same as describing a subset with no
+ /// members.
+ public ApiVersion? DescribedApiVersion
+ {
+ get
+ {
+ ArgumentNullException.ThrowIfNull( metadata );
+
+ return metadata.AdditionalValues.TryGetValue( typeof( ApiVersion ), out var value )
+ ? value as ApiVersion
+ : default;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/README.md b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/README.md
index 38544fde5..eb4b7ba6f 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/README.md
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/README.md
@@ -11,7 +11,4 @@ number of scenarios such as test automation or OpenAPI document generation.
## Commonly Used Types
- Asp.Versioning.ApiExplorerOptions
-- Asp.Versioning.VersionedApiDescriptionProvider
-
-## Release Notes
-
+- Asp.Versioning.VersionedApiDescriptionProvider
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedModelMetadata.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedModelMetadata.cs
new file mode 100644
index 000000000..ee1b83e3c
--- /dev/null
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedModelMetadata.cs
@@ -0,0 +1,148 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.ApiExplorer;
+
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+
+///
+/// Represents model metadata that reports only the members visible in a single API version.
+///
+///
+/// The metadata of a model is shared by every API version because it is keyed by the model type, which cannot
+/// express a subset of its own members. A new instance is created per API version rather than mutating the shared
+/// instance, which every version of a cloned API description otherwise points at.
+///
+internal sealed class VersionedModelMetadata : DelegatingModelMetadata
+{
+ private readonly IAnnotation annotations;
+ private readonly ApiVersion apiVersion;
+ private IReadOnlyDictionary? additionalValues;
+ private ModelPropertyCollection? properties;
+ private ModelMetadata? elementMetadata;
+ private bool elementMetadataResolved;
+
+ internal VersionedModelMetadata(
+ ModelMetadata inner,
+ IAnnotation annotations,
+ ApiVersion apiVersion )
+ : this( inner, NewIdentity( inner ), annotations, apiVersion ) { }
+
+ private VersionedModelMetadata(
+ ModelMetadata inner,
+ ModelMetadataIdentity identity,
+ IAnnotation annotations,
+ ApiVersion apiVersion )
+ : base( inner, identity )
+ {
+ this.annotations = annotations;
+ this.apiVersion = apiVersion;
+ }
+
+ ///
+ public override IReadOnlyDictionary AdditionalValues =>
+ additionalValues ??= NewAdditionalValues();
+
+ ///
+ /// Evaluated on demand so that a model which references itself, directly or transitively, doesn't
+ /// recurse.
+ public override ModelPropertyCollection Properties => properties ??= NewProperties();
+
+ ///
+ public override ModelMetadata? ElementMetadata
+ {
+ get
+ {
+ if ( !elementMetadataResolved )
+ {
+ elementMetadataResolved = true;
+ elementMetadata = Wrap( Inner.ElementMetadata );
+ }
+
+ return elementMetadata;
+ }
+ }
+
+ // ModelMetadataIdentity is only reachable through a protected member, so an equivalent identity is rebuilt
+ // from the public surface of the metadata being wrapped
+ [UnconditionalSuppressMessage(
+ "ILLink",
+ "IL2075",
+ Justification = "MVC does not currently support trimming or native AOT. https://aka.ms/aspnet/trimming" )]
+ private static ModelMetadataIdentity NewIdentity( ModelMetadata metadata )
+ {
+ if ( metadata.MetadataKind == ModelMetadataKind.Property &&
+ metadata.ContainerType is { } containerType &&
+ metadata.PropertyName is { Length: > 0 } propertyName &&
+ containerType.GetProperty( propertyName ) is { } propertyInfo )
+ {
+ return ModelMetadataIdentity.ForProperty( propertyInfo, metadata.ModelType, containerType );
+ }
+
+ return ModelMetadataIdentity.ForType( metadata.ModelType );
+ }
+
+ // the API version is recorded so that a consumer can tell metadata which describes a subset of a model from
+ // metadata which describes the model as declared
+ private Dictionary NewAdditionalValues()
+ {
+ var values = new Dictionary( Inner.AdditionalValues.Count + 1 );
+
+ foreach ( var pair in Inner.AdditionalValues )
+ {
+ values[pair.Key] = pair.Value;
+ }
+
+ values[typeof( ApiVersion )] = apiVersion;
+
+ return values;
+ }
+
+ [UnconditionalSuppressMessage(
+ "ILLink",
+ "IL2075",
+ Justification = "MVC does not currently support trimming or native AOT. https://aka.ms/aspnet/trimming" )]
+ private ModelPropertyCollection NewProperties()
+ {
+ var innerProperties = Inner.Properties;
+ var members = new List( innerProperties.Count );
+
+ for ( var i = 0; i < innerProperties.Count; i++ )
+ {
+ var property = innerProperties[i];
+
+ // the declaring member is resolved from the container rather than the metadata identity, which is not
+ // accessible outside the assembly that declares it
+ if ( property.ContainerType is { } containerType &&
+ property.PropertyName is { Length: > 0 } propertyName &&
+ containerType.GetProperty( propertyName ) is { } propertyInfo &&
+ !annotations.IsVisible( propertyInfo, apiVersion ) )
+ {
+ continue;
+ }
+
+ members.Add( Wrap( property )! );
+ }
+
+ return new( members );
+ }
+
+ [return: NotNullIfNotNull( nameof( metadata ) )]
+ private ModelMetadata? Wrap( ModelMetadata? metadata ) =>
+ metadata switch
+ {
+ null => default,
+
+ // a model already described for an API version, such as one reported by another provider, is left as
+ // it is. wrapping it a second time would filter members that were already filtered
+ { } described when described.DescribedApiVersion is not null => described,
+
+ // a type with no members of its own has nothing to filter. leaving it alone keeps it out of the set
+ // of types that report an authoritative member list
+ { Properties.Count: 0, ElementMetadata: null } simple => simple,
+
+ _ => new VersionedModelMetadata( metadata, annotations, apiVersion ),
+ };
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedModelMetadataProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedModelMetadataProvider.cs
new file mode 100644
index 000000000..a638aaed6
--- /dev/null
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedModelMetadataProvider.cs
@@ -0,0 +1,93 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable CA1812
+
+namespace Asp.Versioning.ApiExplorer;
+
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+
+///
+/// Reports the model metadata of each API description for the API version it describes.
+///
+internal sealed class VersionedModelMetadataProvider(
+ IModelMetadataProvider modelMetadataProvider,
+ IAnnotation annotations ) : IApiDescriptionProvider
+{
+ // OnProvidersExecuting runs in ascending order, but OnProvidersExecuted runs in descending order. ordering
+ // below every other provider means this runs after the versioned API Explorer has expanded each result into
+ // one API description per version, and after any provider that reports its own versioned metadata, such as
+ // gRPC or OData, has already replaced the metadata it owns
+ public int Order => -1200;
+
+ public void OnProvidersExecuting( ApiDescriptionProviderContext context ) { }
+
+ public void OnProvidersExecuted( ApiDescriptionProviderContext context )
+ {
+ ArgumentNullException.ThrowIfNull( context );
+
+ var results = context.Results;
+
+ for ( var i = 0; i < results.Count; i++ )
+ {
+ var result = results[i];
+
+ if ( result.ApiVersion is not { } apiVersion )
+ {
+ continue;
+ }
+
+ // a minimal API is described by an action descriptor that is not a controller action
+ var minimalApi = result.ActionDescriptor is not ControllerActionDescriptor;
+ var parameters = result.ParameterDescriptions;
+
+ for ( var j = 0; j < parameters.Count; j++ )
+ {
+ var parameter = parameters[j];
+
+ if ( parameter.Source == BindingSource.Body )
+ {
+ parameter.ModelMetadata = Describe( parameter.ModelMetadata, parameter.Type, apiVersion, minimalApi );
+ }
+ }
+
+ var responseTypes = result.SupportedResponseTypes;
+
+ for ( var j = 0; j < responseTypes.Count; j++ )
+ {
+ var responseType = responseTypes[j];
+
+ responseType.ModelMetadata = Describe( responseType.ModelMetadata, responseType.Type, apiVersion, minimalApi );
+ }
+ }
+ }
+
+ [return: NotNullIfNotNull( nameof( metadata ) )]
+ private ModelMetadata? Describe( ModelMetadata? metadata, Type? type, ApiVersion apiVersion, bool minimalApi )
+ {
+ // metadata that already describes an API version belongs to another provider
+ if ( metadata?.DescribedApiVersion is not null )
+ {
+ return metadata;
+ }
+
+ // the metadata reported for a minimal API is a placeholder that never reports any members, so complete
+ // metadata is resolved for the described type instead. this is specific to how minimal APIs are described
+ // today and becomes a no-op if that ever reports real metadata
+ if ( minimalApi && type is not null && metadata is null or { Properties.Count: 0, ElementMetadata: null } )
+ {
+ metadata = modelMetadataProvider.GetMetadataForType( type );
+ }
+
+ // a type with no members of its own has nothing to filter
+ if ( metadata is null or { Properties.Count: 0, ElementMetadata: null } )
+ {
+ return metadata;
+ }
+
+ return new VersionedModelMetadata( metadata, annotations, apiVersion );
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj
index 5d83d4ee5..fdff23afe 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj
@@ -1,8 +1,8 @@
- 10.0.1
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0$(DefaultTargetFramework)Asp.VersioningASP.NET Core API Versioning
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs
index 68f7551d5..fd9e70f89 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs
@@ -8,6 +8,7 @@ namespace Microsoft.Extensions.DependencyInjection;
using Asp.Versioning.ApiExplorer;
using Asp.Versioning.ApplicationModels;
using Asp.Versioning.Conventions;
+using Asp.Versioning.Json;
using Asp.Versioning.Routing;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
@@ -103,6 +104,7 @@ private static void AddServices( IServiceCollection services )
services.TryAddSingleton( static sp => new ReportApiVersionsAttribute( sp.GetRequiredService() ) );
services.AddSingleton();
services.TryAddEnumerable( Transient, ApiVersioningMvcOptionsSetup>() );
+ services.TryAddEnumerable( Transient, MemberVisibilityMvcJsonOptionsSetup>() );
services.TryAddEnumerable( Transient() );
services.TryAddEnumerable( Transient() );
services.TryAddEnumerable( Transient() );
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Json/MemberVisibilityMvcJsonOptionsSetup.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Json/MemberVisibilityMvcJsonOptionsSetup.cs
new file mode 100644
index 000000000..5d772df15
--- /dev/null
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Json/MemberVisibilityMvcJsonOptionsSetup.cs
@@ -0,0 +1,33 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+#pragma warning disable CA1812
+
+namespace Asp.Versioning.Json;
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+using System.Reflection;
+using System.Text.Json.Serialization.Metadata;
+
+///
+/// Applies API version member visibility to the JSON options used by MVC.
+///
+/// MVC resolves its own JSON options, which are distinct from the options used by minimal APIs. Both are
+/// configured so that a member is filtered the same way regardless of how the endpoint was defined.
+internal sealed class MemberVisibilityMvcJsonOptionsSetup(
+ IAnnotation annotation,
+ IHttpContextAccessor httpContextAccessor ) : IPostConfigureOptions
+{
+ private readonly MemberVisibilityJsonModifier modifier = new( annotation, httpContextAccessor );
+
+ public void PostConfigure( string? name, JsonOptions options )
+ {
+ var serializerOptions = options.JsonSerializerOptions;
+
+ if ( serializerOptions.TypeInfoResolver is { } resolver )
+ {
+ serializerOptions.TypeInfoResolver = resolver.WithAddedModifier( modifier.Modify );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/README.md b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/README.md
index 26e067d6f..ddd1aa771 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/README.md
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/README.md
@@ -12,7 +12,4 @@ client-based applications.
- Asp.Versioning.ControllerNameAttribute
- Asp.Versioning.MvcApiVersioningOptions
-- Asp.Versioning.ReportApiVersionsAttribute
-
-## Release Notes
-
+- Asp.Versioning.ReportApiVersionsAttribute
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Asp.Versioning.OpenApi.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Asp.Versioning.OpenApi.csproj
index 2919c8352..a0cf1fe25 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Asp.Versioning.OpenApi.csproj
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Asp.Versioning.OpenApi.csproj
@@ -1,8 +1,8 @@
- 10.0.1
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0$(DefaultTargetFramework)Asp.Versioning.OpenApiASP.NET Core API Versioning
@@ -17,7 +17,7 @@
-
+
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/README.md b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/README.md
index 18e2fbd45..5f0dc46d1 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/README.md
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/README.md
@@ -10,7 +10,4 @@ This package contains the OpenAPI extensions which integrates [Microsoft.AspNetC
- Asp.Versioning.OpenApi.IApiVersioningBuilderExtensions
- Asp.Versioning.OpenApi.IEndpointConventionBuilderExtensions
-- Asp.Versioning.OpenApi.IEndpointRouteBuilderExtensions
-
-## Release Notes
-
+- Asp.Versioning.OpenApi.IEndpointRouteBuilderExtensions
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/ReleaseNotes.txt
index ee1453e31..5f282702b 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/ReleaseNotes.txt
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/ReleaseNotes.txt
@@ -1,5 +1 @@
-Clip dependent package version because the next major version is incompatible [dotnet/aspnetcore#67930](https://github.com/dotnet/aspnetcore/issues/67930)
-Add support for gRPC [Issue #1109](https://github.com/dotnet/aspnet-api-versioning/issues/1109)
-Fix `` summaries [Issue #1189](https://github.com/dotnet/aspnet-api-versioning/issues/1189)
-Support more XML comment tags [Issue #1205](https://github.com/dotnet/aspnet-api-versioning/issues/1205)
-Fix the description of a member that refers to another schema being applied to that schema
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ModelMetadataSchemaTransformer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ModelMetadataSchemaTransformer.cs
index a0a2e78e8..873bb665e 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ModelMetadataSchemaTransformer.cs
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ModelMetadataSchemaTransformer.cs
@@ -2,6 +2,7 @@
namespace Asp.Versioning.OpenApi.Transformers;
+using Asp.Versioning.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.OpenApi;
@@ -19,8 +20,8 @@ namespace Asp.Versioning.OpenApi.Transformers;
/// A model type is a single CLR type, but the members it exposes can differ by API version. An API description
/// provider that reports a reduced set of properties is describing a
/// subset of the type, which the schema generated from the CLR type alone cannot express. Only metadata that
-/// reports at least one property is considered authoritative; a type whose metadata reports no properties at all
-/// is left untouched.
+/// reports the API version it was described for is considered authoritative; metadata describing a type as
+/// declared is left untouched.
///
[CLSCompliant( false )]
public class ModelMetadataSchemaTransformer : IOpenApiSchemaTransformer
@@ -145,13 +146,16 @@ private static void Collect( ref Dictionary>? map, ModelMe
return;
}
- var properties = metadata.Properties;
-
- if ( properties.Count == 0 )
+ // only metadata described for an API version reports an authoritative member list. metadata that describes
+ // a model type as declared reports every member it has, which says nothing about the described API. a type
+ // described with no visible members is meaningfully different from a type that was never described
+ if ( metadata.DescribedApiVersion is null )
{
return;
}
+ var properties = metadata.Properties;
+
map ??= [];
if ( !map.TryAdd( metadata.ModelType, [] ) )
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs
index 759449ec0..c0ea3823c 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs
@@ -330,13 +330,19 @@ private static IEnumerable GetInheritedMembers( MemberInfo member )
// pass proceeds
Dedent( copy );
- // order matters. is resolved first so an inline tag nested in a block stays literal, then so
- // that inline code survives being flattened into the text of a list item or a paragraph. the tags that
- // absorb their content are resolved last for the same reason.
- ResolveCodeTags( copy, "code", "```" );
- ResolveCodeTags( copy, "c", "`" );
- ResolveListTags( copy );
+ // order matters. is resolved first so an inline tag nested in a block stays literal, then the
+ // tags that produce inline text so they survive being flattened into a list item, a table cell, or a
+ // paragraph. the tags that absorb their content are resolved last for the same reason.
+ //
+ // every pass below snapshots what it walks with ToArray() before it rewrites anything. Descendants() is
+ // a lazy walk over the live tree, so replacing an element mid-enumeration detaches the node the iterator
+ // is standing on and the walk faults. the Parent check that follows the snapshot is a separate concern:
+ // a node the snapshot captured may have since been absorbed by the replacement of an ancestor.
+ ResolveCodeBlocks( copy );
+ ResolveInlineCode( copy );
ResolveParamRefTags( copy );
+ ResolveInlineTags( copy );
+ ResolveListTags( copy );
ResolveParaTags( copy );
return copy;
@@ -349,14 +355,16 @@ private static IEnumerable GetInheritedMembers( MemberInfo member )
// refers to a parameter of the operation, which is part of the API over the wire, so its name
// is meaningful in a description. is not; a type parameter is a C# concept with no
// representation in a request or a response, and neither is or .
+ //
+ // The tag names a code element rather than describing one, so the name is rendered as inline code. It reads
+ // as the identifier it is instead of running together with the prose around it.
private static void ResolveParamRefTags( XElement element )
{
- foreach ( var paramRef in element.Descendants( "paramref" ).ToArray() )
+ foreach ( var paramRef in element.Descendants( "paramref" ).ToArray().Where( e => e.Parent is not null ) )
{
- if ( paramRef.Parent is not null
- && paramRef.Attribute( "name" )?.Value is { Length: > 0 } name )
+ if ( paramRef.Attribute( "name" )?.Value is { Length: > 0 } name )
{
- paramRef.ReplaceWith( new XText( name ) );
+ paramRef.ReplaceWith( new XText( Delimit( name, "`" ) ) );
}
}
}
@@ -426,11 +434,11 @@ private static void ResolveListTags( XElement element )
}
}
- // An item can be written as plain text or as a definition of a by a . Markdown has no
- // definition list, so a definition renders as a bolded term followed by its description. Reading the text of
+ // an item can be written as plain text or as a definition of a by a . markdown has no
+ // definition list, so a definition renders as a bolded term followed by its description. reading the text of
// the item would run the two together because the tags are adjacent.
//
- // This is deliberately not the cell handling used for a table. Outside of a table a pipe is an ordinary
+ // this is deliberately not the cell handling used for a table. outside of a table a pipe is an ordinary
// character and the line structure of a description is worth keeping.
private static string ItemOf( XElement item )
{
@@ -453,9 +461,9 @@ private static string ItemOf( XElement item )
return text.Length == 0 ? name : "**" + name + "**: " + text;
}
- // A table is the one list type with a Markdown equivalent that is not a list. It only resolves to a table when
+ // a table is the one list type with a markdown equivalent that is not a list. it only resolves to a table when
// there is more than one column; a table of one column conveys nothing a bulleted list does not, so the caller
- // falls back to bullets. Markdown requires a header row, so a list with no gets a blank one.
+ // falls back to bullets. markdown requires a header row, so a list with no gets a blank one.
private static bool TryResolveTable( XElement list, [NotNullWhen( true )] out string? table )
{
var header = CellsOf( list.Element( "listheader" ) );
@@ -509,72 +517,143 @@ private static List CellsOf( XElement? row )
return cells;
}
- // A row occupies a single line and is delimited by pipes, so the whitespace in a cell is collapsed and any
- // pipe of its own is escaped. Neither can be represented in a cell otherwise.
- private static string CellOf( string text )
+ // a row occupies a single line and is delimited by pipes, so the whitespace in a cell is collapsed and any
+ // pipe of its own is escaped. neither can be represented in a cell otherwise.
+ private static string CellOf( string text ) => Flatten( text ).Replace( "|", "\\|", StringComparison.Ordinal );
+
+ private static void AppendRow( StringBuilder table, List cells, int columns )
{
- var cell = new StringBuilder( text.Length );
- var space = false;
+ table.Append( '|' );
- for ( var i = 0; i < text.Length; i++ )
+ for ( var i = 0; i < columns; i++ )
{
- var ch = text[i];
+ var cell = i < cells.Count ? cells[i] : string.Empty;
- if ( char.IsWhiteSpace( ch ) )
- {
- space = cell.Length > 0;
- continue;
- }
+ table.Append( cell.Length == 0 ? " " : " " + cell + " " ).Append( '|' );
+ }
- if ( space )
- {
- cell.Append( ' ' );
- space = false;
- }
+ table.Append( '\n' );
+ }
- if ( ch == '|' )
- {
- cell.Append( '\\' );
- }
+ // a fence only opens and closes a code block when it sits on a line of its own, so the block is surrounded
+ // by line breaks instead of being wrapped in place; a fence written inline is literal text and the block
+ // would never be closed. a code block cannot interrupt a paragraph either, which the leading break also
+ // takes care of.
+ private static void ResolveCodeBlocks( XElement element )
+ {
+ foreach ( var code in element.Descendants( "code" ).ToArray().Where( e => e.Parent is not null ) )
+ {
+ var content = TrimEachLine( code.Value );
+ var fence = FenceFor( content );
- cell.Append( ch );
+ code.ReplaceWith( new XText( "\n" + fence + "\n" + content + "\n" + fence + "\n" ) );
+ }
+ }
+
+ // a fence has to be longer than any run of backticks in the block it delimits, three being the customary
+ // minimum. a sample that contains a fence of its own would otherwise close the block early and leave the
+ // remainder of the sample to be read as prose.
+ private static string FenceFor( string code )
+ {
+ const int MinimumFence = 3;
+ var longest = 0;
+ var run = 0;
+
+ for ( var i = 0; i < code.Length; i++ )
+ {
+ run = code[i] == '`' ? run + 1 : 0;
+ longest = Math.Max( longest, run );
}
- return cell.ToString();
+ return new string( '`', Math.Max( MinimumFence, longest + 1 ) );
}
- private static void AppendRow( StringBuilder table, List cells, int columns )
+ private static void ResolveInlineCode( XElement element )
{
- table.Append( '|' );
+ foreach ( var code in element.Descendants( "c" ).ToArray().Where( e => e.Parent is not null ) )
+ {
+ code.ReplaceWith( new XText( Delimit( code.Value, "`" ) ) );
+ }
+ }
- for ( var i = 0; i < columns; i++ )
+ // , , and are the html tags a documentation comment carries inline, and each has a direct markdown
+ // equivalent. rewriting them keeps the meaning that reading the text of the enclosing element would drop.
+ // the tags are visited from the inside out so that one nested in another is rewritten before it is absorbed.
+ private static void ResolveInlineTags( XElement element )
+ {
+ foreach ( var inline in element.Descendants().Reverse().ToArray().Where( e => e.Parent is not null ) )
{
- var cell = i < cells.Count ? cells[i] : string.Empty;
+ var text = inline.Name.LocalName switch
+ {
+ "b" => Delimit( inline.Value, "**" ),
+ "i" => Delimit( inline.Value, "_" ),
+ "a" => LinkOf( inline ),
+ _ => default,
+ };
- table.Append( cell.Length == 0 ? " " : " " + cell + " " ).Append( '|' );
+ if ( text is not null )
+ {
+ inline.ReplaceWith( new XText( text ) );
+ }
}
+ }
- table.Append( '\n' );
+ // a link with no text renders as its own address, which is all there is to show. a link with no address is
+ // not a link at all, so only the text it wrapped is kept.
+ private static string LinkOf( XElement anchor )
+ {
+ var text = Flatten( anchor.Value );
+ var href = Flatten( anchor.Attribute( "href" )?.Value ?? string.Empty );
+
+ if ( href.Length == 0 )
+ {
+ return text;
+ }
+
+ return text.Length == 0 ? href : "[" + text + "](" + href + ")";
}
- private static void ResolveCodeTags( XElement element, string name, string delimiter )
+ // a span occupies a single line and its delimiters cannot be padded by whitespace, so the content is
+ // flattened. an empty tag is dropped; a pair of delimiters with nothing between them is literal text.
+ private static string Delimit( string value, string delimiter )
{
- foreach ( var code in element.Descendants( name ).ToArray() )
+ var text = Flatten( value );
+
+ return text.Length == 0 ? string.Empty : delimiter + text + delimiter;
+ }
+
+ // collapses a run of text onto a single line. the markdown constructs that occupy exactly one line - a table
+ // row, a span - cannot carry the line breaks and indentation the xml file is written with.
+ private static string Flatten( string text )
+ {
+ var flattened = new StringBuilder( text.Length );
+ var space = false;
+
+ for ( var i = 0; i < text.Length; i++ )
{
- if ( code.Parent is null )
+ var ch = text[i];
+
+ if ( char.IsWhiteSpace( ch ) )
{
+ space = flattened.Length > 0;
continue;
}
- var text = delimiter + TrimEachLine( code.Value ) + delimiter;
+ if ( space )
+ {
+ flattened.Append( ' ' );
+ space = false;
+ }
- code.ReplaceWith( new XText( text ) );
+ flattened.Append( ch );
}
+
+ return flattened.ToString();
}
- // The XML file is written with its own indentation, which becomes part of the text of every element inside a
- // member. Remove it from each text node so the member reads as it was written and substituted text does not
- // have to reproduce it. An XML processor normalizes line endings, so only '\n' occurs here.
+ // the xml file is written with its own indentation, which becomes part of the text of every element inside a
+ // member. remove it from each text node so the member reads as it was written and substituted text does not
+ // have to reproduce it. an xml processor normalizes line endings, so only '\n' occurs here.
private static void Dedent( XElement element )
{
var margin = MarginOf( element.Value );
@@ -642,7 +721,7 @@ private static int MarginOf( string text )
return margin == int.MaxValue ? 0 : margin;
}
- // Trims each line while preserving relative indentation. A code sample is indented to match the source it
+ // trims each line while preserving relative indentation. a code sample is indented to match the source it
// was written in; removing only the common indentation keeps the sample readable without the leading noise.
private static string TrimEachLine( string text )
{
diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsTransformer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsTransformer.cs
index c92ee3fc9..2480f5f06 100644
--- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsTransformer.cs
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsTransformer.cs
@@ -114,10 +114,17 @@ public virtual Task TransformAsync(
var description = operation.Description;
- if ( string.IsNullOrEmpty( description )
- && !string.IsNullOrEmpty( description = Documentation.GetDescription( method ) ) )
+ if ( string.IsNullOrEmpty( description ) )
{
- operation.Description = description;
+ if ( string.IsNullOrEmpty( description = Documentation.GetRemarks( method ) ) )
+ {
+ description = Documentation.GetDescription( method );
+ }
+
+ if ( !string.IsNullOrEmpty( description ) )
+ {
+ operation.Description = description;
+ }
}
if ( operation.Responses is { } responses )
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/GrpcJsonTranscodingDescriptionProviderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/ApiExplorer/GrpcJsonTranscodingDescriptionProviderTest.cs
similarity index 99%
rename from src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/GrpcJsonTranscodingDescriptionProviderTest.cs
rename to src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/ApiExplorer/GrpcJsonTranscodingDescriptionProviderTest.cs
index d9c4f91d2..f26479faa 100644
--- a/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/GrpcJsonTranscodingDescriptionProviderTest.cs
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/ApiExplorer/GrpcJsonTranscodingDescriptionProviderTest.cs
@@ -2,7 +2,6 @@
namespace Asp.Versioning.ApiExplorer;
-using Asp.Versioning.Grpc.Tests;
using Asp.Versioning.Routing;
using Google.Protobuf.WellKnownTypes;
using Microsoft.AspNetCore.Mvc.Controllers;
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/Asp.Versioning.Grpc.ApiExplorer.Tests.csproj b/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/Asp.Versioning.Grpc.ApiExplorer.Tests.csproj
index ee0c35f8f..2da916887 100644
--- a/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/Asp.Versioning.Grpc.ApiExplorer.Tests.csproj
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/Asp.Versioning.Grpc.ApiExplorer.Tests.csproj
@@ -21,13 +21,15 @@
-
-
+
+
+
+
+
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/Protos/orders.proto b/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/Protos/orders.proto
index 7a5b9ed21..cde5ff47e 100644
--- a/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/Protos/orders.proto
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/Protos/orders.proto
@@ -1,6 +1,6 @@
syntax = "proto3";
-option csharp_namespace = "Asp.Versioning.Grpc.Tests";
+option csharp_namespace = "Asp.Versioning";
import "google/api/annotations.proto";
import "google/protobuf/empty.proto";
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/TestApplication.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/TestApplication.cs
index f289317ae..f1c4bbca4 100644
--- a/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/TestApplication.cs
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/TestApplication.cs
@@ -1,14 +1,11 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
-namespace Asp.Versioning.ApiExplorer;
+namespace Asp.Versioning;
-using Asp.Versioning.Grpc.Tests;
using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
// the API descriptions are only produced from a live EndpointDataSource, so the endpoints have to be
// materialized by an application that has started
@@ -22,7 +19,7 @@ public static async Task> DescribeApisAsync(
builder.WebHost.UseTestServer();
builder.Services.AddRouting();
- builder.Services.AddGrpcApiExplorer();
+ builder.Services.AddApiVersioning().AddGrpc().AddGrpcApiExplorer();
configureServices?.Invoke( builder.Services );
var app = builder.Build();
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/TestOrdersService.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/TestOrdersService.cs
index 9099c64c7..b3f0b3116 100644
--- a/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/TestOrdersService.cs
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Grpc.ApiExplorer.Tests/TestOrdersService.cs
@@ -1,6 +1,6 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
-namespace Asp.Versioning.Grpc.Tests;
+namespace Asp.Versioning;
using global::Grpc.Core;
using Google.Protobuf.WellKnownTypes;
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MemberVisibilityJsonTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MemberVisibilityJsonTest.cs
new file mode 100644
index 000000000..d24589288
--- /dev/null
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MemberVisibilityJsonTest.cs
@@ -0,0 +1,143 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning;
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+
+public class MemberVisibilityJsonTest
+{
+ [Theory]
+ [InlineData( "1.0", """{"id":42,"name":"Bill"}""" )]
+ [InlineData( "2.0", """{"id":42,"name":"Bill","email":"bill@contoso.com"}""" )]
+ [InlineData( "3.0", """{"id":42,"name":"Bill","email":"bill@contoso.com","rank":7}""" )]
+ public void serialize_should_only_write_members_visible_to_api_version( string version, string expected )
+ {
+ // arrange
+ var options = NewSerializerOptions( version );
+
+ // act
+ var json = JsonSerializer.Serialize( new Person(), options );
+
+ // assert
+ json.Should().Be( expected );
+ }
+
+ [Fact]
+ public void serialize_should_filter_nested_and_repeated_members()
+ {
+ // arrange
+ var options = NewSerializerOptions( "1.0" );
+ var order = new Order();
+
+ // act
+ var json = JsonSerializer.Serialize( order, options );
+
+ // assert
+ json.Should().Be( """{"home":{"street":"1 Main St"},"people":[{"id":42,"name":"Bill"}]}""" );
+ }
+
+ [Fact]
+ public void serialize_should_write_all_members_when_api_version_is_unspecified()
+ {
+ // arrange
+ var options = NewSerializerOptions( version: default );
+
+ // act
+ var json = JsonSerializer.Serialize( new Person(), options );
+
+ // assert
+ json.Should().Be( """{"id":42,"name":"Bill","email":"bill@contoso.com","rank":7}""" );
+ }
+
+ [Fact]
+ public void serialize_should_not_change_a_type_without_filtered_members()
+ {
+ // arrange
+ var options = NewSerializerOptions( "1.0" );
+
+ // act
+ var json = JsonSerializer.Serialize( new Address(), options );
+
+ // assert
+ json.Should().Be( """{"street":"1 Main St"}""" );
+ }
+
+ [Fact]
+ public void deserialize_should_reject_a_member_not_visible_to_api_version()
+ {
+ // arrange
+ var options = NewSerializerOptions( "1.0" );
+ var json = """{"id":1,"name":"Ann","email":"ann@contoso.com"}""";
+
+ // act
+ var deserialize = () => JsonSerializer.Deserialize( json, options );
+
+ // assert
+ deserialize.Should().Throw();
+ }
+
+ [Fact]
+ public void deserialize_should_allow_a_member_visible_to_api_version()
+ {
+ // arrange
+ var options = NewSerializerOptions( "2.0" );
+ var json = """{"id":1,"name":"Ann","email":"ann@contoso.com"}""";
+
+ // act
+ var person = JsonSerializer.Deserialize( json, options );
+
+ // assert
+ person.Email.Should().Be( "ann@contoso.com" );
+ }
+
+ private static JsonSerializerOptions NewSerializerOptions( string version )
+ {
+ var httpContext = new DefaultHttpContext();
+
+ if ( version is not null )
+ {
+ httpContext.ApiVersioningFeature.RequestedApiVersion = ApiVersionParser.Default.Parse( version );
+ }
+
+ var services = new ServiceCollection();
+
+ services.AddLogging();
+ services.AddApiVersioning();
+ services.AddSingleton( new HttpContextAccessor() { HttpContext = httpContext } );
+
+ var provider = services.BuildServiceProvider();
+
+ return provider.GetRequiredService>().Value.SerializerOptions;
+ }
+
+#pragma warning disable CA1812
+
+ private sealed class Address
+ {
+ public string Street { get; set; } = "1 Main St";
+ }
+
+ private sealed class Person
+ {
+ public int Id { get; set; } = 42;
+
+ public string Name { get; set; } = "Bill";
+
+ [VisibleInApiVersion( "2.0" )]
+ public string Email { get; set; } = "bill@contoso.com";
+
+ [VisibleInApiVersion( "3.0" )]
+ public int Rank { get; set; } = 7;
+ }
+
+ private sealed class Order
+ {
+ public Address Home { get; set; } = new();
+
+ public List People { get; set; } = [new()];
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Address.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Address.cs
new file mode 100644
index 000000000..1205f70e2
--- /dev/null
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Address.cs
@@ -0,0 +1,20 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.OpenApi.Simulators;
+
+///
+/// Represents an address.
+///
+public class Address
+{
+ ///
+ /// Gets or sets the street.
+ ///
+ public string Street { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the country.
+ ///
+ [VisibleInApiVersion( "2.0" )]
+ public string Country { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Documented.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Documented.cs
index 6e64562e8..a34c19da8 100644
--- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Documented.cs
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Documented.cs
@@ -53,6 +53,14 @@ public class Documented
///
public string Sample { get; set; }
+ ///
+ /// Gets or sets the snippet.
+ ///
+ /// The fence is ``` here.
+ ///
+ ///
+ public string Snippet { get; set; }
+
///
/// Gets or sets the definitions.
///
@@ -97,4 +105,25 @@ public class Documented
/// The second note, which mentions Status.
///
public string Notes { get; set; }
+
+ ///
+ /// Gets or sets the highlights, which are important and subtle.
+ ///
+ public string Highlights { get; set; }
+
+ ///
+ /// Gets or sets the emphasis, which is very strongly worded.
+ ///
+ public string Emphasis { get; set; }
+
+ ///
+ /// Gets or sets the reference, which is described by the
+ /// specification.
+ ///
+ public string Reference { get; set; }
+
+ ///
+ /// Gets or sets the site, which is .
+ ///
+ public string Site { get; set; }
}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/MinimalApi.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/MinimalApi.cs
index 4447df1ed..320e5a5c7 100644
--- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/MinimalApi.cs
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/MinimalApi.cs
@@ -28,6 +28,14 @@ public static class MinimalApi
/// An ambiguous answer.
public static int Many() => 42;
+ ///
+ /// Detailed
+ ///
+ /// The long-form explanation.
+ /// The short-form explanation.
+ /// The detailed answer.
+ public static int Detailed() => 42;
+
///
/// Echo
///
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Order.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Order.cs
new file mode 100644
index 000000000..5f58cbe25
--- /dev/null
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Order.cs
@@ -0,0 +1,30 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.OpenApi.Simulators;
+
+///
+/// Represents an order.
+///
+public class Order
+{
+ ///
+ /// Gets or sets the order identifier.
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Gets or sets the customer.
+ ///
+ public string Customer { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the address the order ships to.
+ ///
+ public Address ShipTo { get; set; } = new();
+
+ ///
+ /// Gets or sets the order notes.
+ ///
+ [VisibleInApiVersion( "2.0" )]
+ public string Notes { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/OrdersController.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/OrdersController.cs
new file mode 100644
index 000000000..4cc129f74
--- /dev/null
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/OrdersController.cs
@@ -0,0 +1,20 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.OpenApi.Simulators;
+
+using Microsoft.AspNetCore.Mvc;
+
+// declared internal so that the default controller feature provider, which only discovers public types, does not
+// add it to the documents generated by the other tests in this assembly. it is registered explicitly instead
+#pragma warning disable CA1812
+#pragma warning disable CA1822
+
+[ApiController]
+[ApiVersion( 1.0 )]
+[ApiVersion( 2.0 )]
+[Route( "api/orders" )]
+internal sealed class OrdersController : ControllerBase
+{
+ [HttpGet( "{id:int}" )]
+ public Order Get( int id ) => new() { Id = id };
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/OrdersControllerFeatureProvider.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/OrdersControllerFeatureProvider.cs
new file mode 100644
index 000000000..8307d797e
--- /dev/null
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/OrdersControllerFeatureProvider.cs
@@ -0,0 +1,17 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.OpenApi.Simulators;
+
+using Microsoft.AspNetCore.Mvc.ApplicationParts;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using System.Reflection;
+
+///
+/// Registers , which the default feature provider does not discover because it is
+/// declared internal.
+///
+internal sealed class OrdersControllerFeatureProvider : IApplicationFeatureProvider
+{
+ public void PopulateFeature( IEnumerable parts, ControllerFeature feature ) =>
+ feature.Controllers.Add( typeof( OrdersController ).GetTypeInfo() );
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/MemberVisibilityTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/MemberVisibilityTest.cs
new file mode 100644
index 000000000..678b7844f
--- /dev/null
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/MemberVisibilityTest.cs
@@ -0,0 +1,130 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace Asp.Versioning.OpenApi.Transformers;
+
+using Asp.Versioning.OpenApi.Simulators;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+
+public class MemberVisibilityTest
+{
+ [Fact]
+ public async Task minimal_api_should_only_describe_members_visible_to_api_version()
+ {
+ // arrange
+ using var app = NewApplication();
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ await app.StartAsync( cancellationToken );
+
+ using var client = app.GetTestClient();
+
+ // act
+ var v1 = await client.GetFromJsonAsync( "/openapi/v1.json", cancellationToken );
+ var v2 = await client.GetFromJsonAsync( "/openapi/v2.json", cancellationToken );
+
+ // assert
+ MembersOf( v1, nameof( Order ) ).Should().BeEquivalentTo( "id", "customer", "shipTo" );
+ MembersOf( v2, nameof( Order ) ).Should().BeEquivalentTo( "id", "customer", "shipTo", "notes" );
+ }
+
+ [Fact]
+ public async Task minimal_api_should_only_describe_nested_members_visible_to_api_version()
+ {
+ // arrange
+ using var app = NewApplication();
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ await app.StartAsync( cancellationToken );
+
+ using var client = app.GetTestClient();
+
+ // act
+ var v1 = await client.GetFromJsonAsync( "/openapi/v1.json", cancellationToken );
+ var v2 = await client.GetFromJsonAsync( "/openapi/v2.json", cancellationToken );
+
+ // assert
+ MembersOf( v1, nameof( Address ) ).Should().BeEquivalentTo( "street" );
+ MembersOf( v2, nameof( Address ) ).Should().BeEquivalentTo( "street", "country" );
+ }
+
+ [Fact]
+ public async Task controller_should_only_describe_members_visible_to_api_version()
+ {
+ // arrange
+ using var app = NewControllerApplication();
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ await app.StartAsync( cancellationToken );
+
+ using var client = app.GetTestClient();
+
+ // act
+ var v1 = await client.GetFromJsonAsync( "/openapi/v1.json", cancellationToken );
+ var v2 = await client.GetFromJsonAsync( "/openapi/v2.json", cancellationToken );
+
+ // assert
+ MembersOf( v1, nameof( Order ) ).Should().BeEquivalentTo( "id", "customer", "shipTo" );
+ MembersOf( v2, nameof( Order ) ).Should().BeEquivalentTo( "id", "customer", "shipTo", "notes" );
+ MembersOf( v1, nameof( Address ) ).Should().BeEquivalentTo( "street" );
+ MembersOf( v2, nameof( Address ) ).Should().BeEquivalentTo( "street", "country" );
+ }
+
+ private static WebApplication NewControllerApplication()
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ builder.WebHost.UseTestServer();
+
+ // the controller is internal so that it stays out of the documents generated by the other tests, which
+ // means the default feature provider will not discover it and it has to be registered explicitly
+ builder.Services.AddControllers()
+ .ConfigureApplicationPartManager( m =>
+ {
+ m.ApplicationParts.Clear();
+ m.FeatureProviders.Add( new OrdersControllerFeatureProvider() );
+ } );
+
+ builder.Services.AddApiVersioning()
+ .AddMvc()
+ .AddApiExplorer( options => options.GroupNameFormat = "'v'VVV" )
+ .AddOpenApi();
+
+ var app = builder.Build();
+
+ app.MapControllers();
+ app.MapOpenApi().WithDocumentPerVersion();
+
+ return app;
+ }
+
+ private static WebApplication NewApplication()
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ builder.WebHost.UseTestServer();
+ builder.Services.AddApiVersioning()
+ .AddApiExplorer( options => options.GroupNameFormat = "'v'VVV" )
+ .AddOpenApi();
+
+ // keep controllers declared elsewhere in this assembly out of the document
+ builder.Services.AddMvcCore().ConfigureApplicationPartManager( m => m.ApplicationParts.Clear() );
+
+ var app = builder.Build();
+ var api = app.NewVersionedApi( "Orders" )
+ .MapGroup( "/orders" )
+ .HasApiVersion( 1.0 )
+ .HasApiVersion( 2.0 );
+
+ api.MapGet( "{id:int}", ( int id ) => new Order() );
+ app.MapOpenApi().WithDocumentPerVersion();
+
+ return app;
+ }
+
+ private static IEnumerable MembersOf( JsonNode document, string schema ) =>
+ document!["components"]!["schemas"]![schema]!["properties"]!.AsObject().Select( p => p.Key );
+}
\ No newline at end of file
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsStructureTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsStructureTest.cs
index 953f30720..6d220f50a 100644
--- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsStructureTest.cs
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsStructureTest.cs
@@ -211,7 +211,7 @@ public void inline_code_should_be_resolved_into_a_span()
}
[Fact]
- public void code_should_be_resolved_into_a_block()
+ public void code_should_be_resolved_into_a_fenced_block()
{
// arrange
var comments = XmlComments.FromFile( FilePath.XmlCommentFile );
@@ -221,7 +221,115 @@ public void code_should_be_resolved_into_a_block()
var summary = comments.GetSummary( property );
// assert
- summary.Should().Be( "Gets or sets the sample.\n```var value = 42;\nUse( value );```" );
+ summary.Should().Be(
+ "Gets or sets the sample.\n" +
+ "\n" +
+ "```\n" +
+ "var value = 42;\n" +
+ "Use( value );\n" +
+ "```" );
+ }
+
+ [Fact]
+ public void code_containing_a_fence_should_be_resolved_into_a_longer_fence()
+ {
+ // arrange
+ var comments = XmlComments.FromFile( FilePath.XmlCommentFile );
+ var property = typeof( Documented ).GetProperty( nameof( Documented.Snippet ) );
+
+ // act
+ var summary = comments.GetSummary( property );
+
+ // assert
+ summary.Should().Be(
+ "Gets or sets the snippet.\n" +
+ "\n" +
+ "````\n" +
+ "The fence is ``` here.\n" +
+ "````" );
+ }
+
+ [Fact]
+ public void bold_and_italic_should_be_resolved_into_emphasis()
+ {
+ // arrange
+ var comments = XmlComments.FromFile( FilePath.XmlCommentFile );
+ var property = typeof( Documented ).GetProperty( nameof( Documented.Highlights ) );
+
+ // act
+ var summary = comments.GetSummary( property );
+
+ // assert
+ summary.Should().Be( "Gets or sets the highlights, which are **important** and _subtle_." );
+ }
+
+ [Fact]
+ public void nested_emphasis_should_be_resolved_from_the_inside_out()
+ {
+ // arrange
+ var comments = XmlComments.FromFile( FilePath.XmlCommentFile );
+ var property = typeof( Documented ).GetProperty( nameof( Documented.Emphasis ) );
+
+ // act
+ var summary = comments.GetSummary( property );
+
+ // assert
+ summary.Should().Be( "Gets or sets the emphasis, which is **very _strongly_ worded**." );
+ }
+
+ [Fact]
+ public void anchor_should_be_resolved_into_a_link()
+ {
+ // arrange
+ var comments = XmlComments.FromFile( FilePath.XmlCommentFile );
+ var property = typeof( Documented ).GetProperty( nameof( Documented.Reference ) );
+
+ // act
+ var summary = comments.GetSummary( property );
+
+ // assert
+ summary.Should().Be(
+ "Gets or sets the reference, which is described by [the specification](https://example.com)." );
+ }
+
+ [Fact]
+ public void anchor_without_text_should_be_resolved_into_its_address()
+ {
+ // arrange
+ var comments = XmlComments.FromFile( FilePath.XmlCommentFile );
+ var property = typeof( Documented ).GetProperty( nameof( Documented.Site ) );
+
+ // act
+ var summary = comments.GetSummary( property );
+
+ // assert
+ summary.Should().Be( "Gets or sets the site, which is https://example.com." );
+ }
+
+ [Fact]
+ public async Task remarks_should_take_precedence_over_description()
+ {
+ // arrange
+ var paths = await GeneratePathsAsync();
+
+ // act
+ var description = paths["/test/detailed"]["get"]["description"];
+
+ // assert
+ description.GetValue().Should().Be( "The long-form explanation." );
+ }
+
+ [Fact]
+ public async Task description_should_be_used_without_remarks()
+ {
+ // arrange
+ var paths = await GeneratePathsAsync();
+
+ // act
+ var description = paths["/test/{id}"]["get"]["description"];
+
+ // assert
+ description.GetValue().Should().Be( "A test API." );
}
[Fact]
@@ -239,7 +347,7 @@ public void paragraphs_should_be_separated_by_a_blank_line()
}
[Fact]
- public void paramref_should_be_resolved_into_the_parameter_name()
+ public void paramref_should_be_resolved_into_the_parameter_name_as_code()
{
// arrange
var comments = XmlComments.FromFile( FilePath.XmlCommentFile );
@@ -249,7 +357,7 @@ public void paramref_should_be_resolved_into_the_parameter_name()
var returns = comments.GetReturns( method );
// assert
- returns.Should().Be( "The value of id." );
+ returns.Should().Be( "The value of `id`." );
}
[Fact]
@@ -262,7 +370,7 @@ public async Task paramref_should_be_resolved_in_the_document()
var description = paths["/test/echo/{id}"]["get"]["responses"]["200"]["description"];
// assert
- description.GetValue().Should().Be( "The value of id." );
+ description.GetValue().Should().Be( "The value of `id`." );
}
[Fact]
@@ -310,6 +418,7 @@ private static async Task GenerateDocumentAsync()
api.MapGet( "one", MinimalApi.One );
api.MapGet( "many", MinimalApi.Many ).Produces().Produces( 400 );
api.MapGet( "{id:int}", MinimalApi.Get ).Produces().Produces( 400 );
+ api.MapGet( "detailed", MinimalApi.Detailed );
api.MapGet( "documented", () => new Documented() );
api.MapGet( "echo/{id:int}", MinimalApi.Echo );
app.MapOpenApi().WithDocumentPerVersion();
diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs
index 040911b4e..2863faaac 100644
--- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs
+++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs
@@ -35,6 +35,20 @@ public void description_should_be_retrieved_for_minimal_api()
description.Should().Be( "A test API." );
}
+ [Fact]
+ public void remarks_should_be_retrieved_for_minimal_api()
+ {
+ // arrange
+ var comments = XmlComments.FromFile( FilePath.XmlCommentFile );
+ var method = typeof( MinimalApi ).GetMethod( nameof( MinimalApi.Detailed ) );
+
+ // act
+ var remarks = comments.GetRemarks( method );
+
+ // assert
+ remarks.Should().Be( "The long-form explanation." );
+ }
+
[Fact]
public void parameter_description_should_be_retrieved_for_minimal_api()
{
diff --git a/src/Client/src/Asp.Versioning.Http.Client/Asp.Versioning.Http.Client.csproj b/src/Client/src/Asp.Versioning.Http.Client/Asp.Versioning.Http.Client.csproj
index 5dcd61708..6bbd72fc6 100644
--- a/src/Client/src/Asp.Versioning.Http.Client/Asp.Versioning.Http.Client.csproj
+++ b/src/Client/src/Asp.Versioning.Http.Client/Asp.Versioning.Http.Client.csproj
@@ -1,8 +1,8 @@
- 10.0.0
- 10.0.0.0
+ 10.2.0
+ 10.2.0.0$(DefaultTargetFramework);netstandard1.1;netstandard2.0Asp.Versioning.HttpAPI Versioning Client Extensions
diff --git a/src/Client/src/Asp.Versioning.Http.Client/README.md b/src/Client/src/Asp.Versioning.Http.Client/README.md
index ac62f7783..0bd959251 100644
--- a/src/Client/src/Asp.Versioning.Http.Client/README.md
+++ b/src/Client/src/Asp.Versioning.Http.Client/README.md
@@ -7,7 +7,4 @@ conventions that you use to describe which API versions are implemented by your
- Asp.Versioning.ApiVersionHandler
- Asp.Versioning.ApiVersionInformation
- Asp.Versioning.ApiVersionWriter
-- Asp.Versioning.IApiNotification
-
-## Release Notes
-
+- Asp.Versioning.IApiNotification
\ No newline at end of file
diff --git a/src/Common/src/Common.ApiExplorer/ApiExplorerOptions.cs b/src/Common/src/Common.ApiExplorer/ApiExplorerOptions.cs
index 2cd629ce6..53f17a196 100644
--- a/src/Common/src/Common.ApiExplorer/ApiExplorerOptions.cs
+++ b/src/Common/src/Common.ApiExplorer/ApiExplorerOptions.cs
@@ -23,6 +23,7 @@ public partial class ApiExplorerOptions
/// For information about API version formatting, review
/// as well as the and
/// methods.
+ [StringSyntax( "ApiVersionFormat" )]
public string GroupNameFormat { get; set; } = string.Empty;
///
@@ -34,6 +35,7 @@ public partial class ApiExplorerOptions
/// For information about API version formatting, review
/// as well as the and
/// methods.
+ [StringSyntax( "ApiVersionFormat" )]
public string SubstitutionFormat { get; set; } = "VVV";
///
diff --git a/src/Common/src/Common.ApiExplorer/Common.ApiExplorer.shproj b/src/Common/src/Common.ApiExplorer/Common.ApiExplorer.shproj
index ead9622fe..90ec2633f 100644
--- a/src/Common/src/Common.ApiExplorer/Common.ApiExplorer.shproj
+++ b/src/Common/src/Common.ApiExplorer/Common.ApiExplorer.shproj
@@ -4,10 +4,14 @@
1e4b750a-60b7-43a9-9b1a-bc4359ef1ac514.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/src/Common.Backport/StringSyntaxAttribute.cs b/src/Common/src/Common.Backport/StringSyntaxAttribute.cs
new file mode 100644
index 000000000..f186c746f
--- /dev/null
+++ b/src/Common/src/Common.Backport/StringSyntaxAttribute.cs
@@ -0,0 +1,28 @@
+// Copyright (c) .NET Foundation and contributors. All rights reserved.
+
+namespace System.Diagnostics.CodeAnalysis;
+
+// REF: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/StringSyntaxAttribute.cs
+//
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+[ExcludeFromCodeCoverage]
+[AttributeUsage( AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false )]
+internal sealed class StringSyntaxAttribute : Attribute
+{
+ public StringSyntaxAttribute( string syntax )
+ {
+ Syntax = syntax;
+ Arguments = [];
+ }
+
+ public StringSyntaxAttribute( string syntax, params object?[] arguments )
+ {
+ Syntax = syntax;
+ Arguments = arguments;
+ }
+
+ public string Syntax { get; }
+
+ public object?[] Arguments { get; }
+}
\ No newline at end of file
diff --git a/src/Common/src/Common.Mvc/Common.Mvc.shproj b/src/Common/src/Common.Mvc/Common.Mvc.shproj
index 5cb3a1a6d..2f2217514 100644
--- a/src/Common/src/Common.Mvc/Common.Mvc.shproj
+++ b/src/Common/src/Common.Mvc/Common.Mvc.shproj
@@ -4,10 +4,14 @@
6629a038-4ff4-45fa-8d32-3a640d83160114.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.shproj b/src/Common/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.shproj
index 7c9a5b4d3..dcdefc6be 100644
--- a/src/Common/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.shproj
+++ b/src/Common/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.shproj
@@ -4,10 +4,14 @@
75b059a2-6656-4ffd-ab41-75d272b78e9d14.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/src/Common.OData/Common.OData.shproj b/src/Common/src/Common.OData/Common.OData.shproj
index c860efd7b..b377214a4 100644
--- a/src/Common/src/Common.OData/Common.OData.shproj
+++ b/src/Common/src/Common.OData/Common.OData.shproj
@@ -4,10 +4,14 @@
1ed0d3ef-16a1-40d1-a3dc-978df1eb7d3f14.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/src/Common.ProblemDetails/Common.ProblemDetails.shproj b/src/Common/src/Common.ProblemDetails/Common.ProblemDetails.shproj
index ca8560dba..bc8ec0c99 100644
--- a/src/Common/src/Common.ProblemDetails/Common.ProblemDetails.shproj
+++ b/src/Common/src/Common.ProblemDetails/Common.ProblemDetails.shproj
@@ -4,10 +4,14 @@
0fa0aa78-4356-4593-854a-e9698d27ab3d14.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/src/Common.TypeInfo/Common.TypeInfo.shproj b/src/Common/src/Common.TypeInfo/Common.TypeInfo.shproj
index a99e4b18c..55a28b1c8 100644
--- a/src/Common/src/Common.TypeInfo/Common.TypeInfo.shproj
+++ b/src/Common/src/Common.TypeInfo/Common.TypeInfo.shproj
@@ -4,10 +4,14 @@
7a5f3994-0df5-48b5-af3d-3f88a1d4eb0414.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/src/Common/Common.shproj b/src/Common/src/Common/Common.shproj
index b3500f4fe..a9e827e30 100644
--- a/src/Common/src/Common/Common.shproj
+++ b/src/Common/src/Common/Common.shproj
@@ -4,10 +4,14 @@
a2df7cb6-142e-43d0-82c0-47ad5e89f4e314.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/test/Common.Acceptance.Tests/Common.Acceptance.Tests.shproj b/src/Common/test/Common.Acceptance.Tests/Common.Acceptance.Tests.shproj
index 524c3b32d..f7d89cda5 100644
--- a/src/Common/test/Common.Acceptance.Tests/Common.Acceptance.Tests.shproj
+++ b/src/Common/test/Common.Acceptance.Tests/Common.Acceptance.Tests.shproj
@@ -4,10 +4,14 @@
75b0a776-45a2-4167-9d15-145e5352f99f14.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/test/Common.Mvc.Tests/Common.Mvc.Tests.shproj b/src/Common/test/Common.Mvc.Tests/Common.Mvc.Tests.shproj
index 213b90372..0cde686a9 100644
--- a/src/Common/test/Common.Mvc.Tests/Common.Mvc.Tests.shproj
+++ b/src/Common/test/Common.Mvc.Tests/Common.Mvc.Tests.shproj
@@ -4,10 +4,14 @@
e3e486e4-107b-488f-835b-d53a727c2c5e14.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/test/Common.OData.ApiExplorer.Tests/Common.OData.ApiExplorer.Tests.shproj b/src/Common/test/Common.OData.ApiExplorer.Tests/Common.OData.ApiExplorer.Tests.shproj
index 85dc6acec..fb5080d77 100644
--- a/src/Common/test/Common.OData.ApiExplorer.Tests/Common.OData.ApiExplorer.Tests.shproj
+++ b/src/Common/test/Common.OData.ApiExplorer.Tests/Common.OData.ApiExplorer.Tests.shproj
@@ -4,10 +4,14 @@
496a5b79-afd2-45ac-af9a-1cd28a7e1cdb14.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/test/Common.OData.Tests/Common.OData.Tests.shproj b/src/Common/test/Common.OData.Tests/Common.OData.Tests.shproj
index d7fedafb3..7ab0053ba 100644
--- a/src/Common/test/Common.OData.Tests/Common.OData.Tests.shproj
+++ b/src/Common/test/Common.OData.Tests/Common.OData.Tests.shproj
@@ -4,10 +4,14 @@
62c25010-2f1d-4146-bdfc-89831d5993d414.0
-
-
-
+
+
+
-
+
diff --git a/src/Common/test/Common.Tests/Common.Tests.shproj b/src/Common/test/Common.Tests/Common.Tests.shproj
index 09e7a9dd5..4b7605d01 100644
--- a/src/Common/test/Common.Tests/Common.Tests.shproj
+++ b/src/Common/test/Common.Tests/Common.Tests.shproj
@@ -4,10 +4,14 @@
feb58f0f-cfde-4da7-9336-af593e33634f14.0
-
-
-
+
+
+
-
+
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index d3f5e202f..8141ef058 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -9,16 +9,19 @@
$([MSBuild]::EnsureTrailingSlash($([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), .gitignore))))$([MSBuild]::EnsureTrailingSlash($(RootDir)build))$([MSBuild]::EnsureTrailingSlash($([System.IO.Path]::Combine('$(RootDir)','src','Common','src','Common.Backport'))))
+ $([MSBuild]::EnsureTrailingSlash($([System.IO.Path]::Combine('$(RootDir)','src','Abstractions','src','Asp.Versioning.Abstractions'))))enablefalsetrue
- true
- false$(MSBuildProjectName.Contains('Tests'))$(MSBuildProjectDirectory.Contains('AspNetCore'))
+ $(MSBuildProjectName.Contains('Analyzers'))
+ $(IsAnalyzer)
+ true
+ false
@@ -29,6 +32,9 @@
+
+
+
diff --git a/wiki/book.toml b/wiki/book.toml
new file mode 100644
index 000000000..e5e0cad95
--- /dev/null
+++ b/wiki/book.toml
@@ -0,0 +1,31 @@
+[book]
+title = "ASP.NET API Versioning Documentation"
+authors = ["Chris Martinez"]
+language = "en"
+
+[output.html]
+site-url = "/docs/"
+no-section-label = true
+git-repository-url = "https://github.com/dotnet/aspnet-api-versioning"
+edit-url-template = "https://github.com/dotnet/aspnet-api-versioning/edit/main/wiki/{path}"
+preferred-dark-theme = "Ayu"
+additional-css = ["theme/custom.css"]
+additional-js = ["theme/pagetoc.js", "theme/sidebar-fold.js", "theme/footer.js"]
+
+[output.html.fold]
+enable = true
+level = 0
+
+[output.html.playground]
+editable = true
+line-numbers = true
+runnable = false
+
+[output.html.search]
+limit-results = 20
+use-boolean-and = true
+boost-title = 2
+boost-hierarchy = 2
+boost-paragraph = 1
+expand = true
+heading-split-level = 2
\ No newline at end of file
diff --git a/wiki/src/README.md b/wiki/src/README.md
new file mode 100644
index 000000000..3c0450e4e
--- /dev/null
+++ b/wiki/src/README.md
@@ -0,0 +1,81 @@
+# Introduction
+
+Versioning is an important aspect of any mature web service. Microsoft has published REST API guidelines that require
+that all compliant services must support explicit versioning. This ensures that clients can rely on services to be
+stable over time, while still enabling service changes and new features. The goal of the ASP.NET API Versioning project
+is to adhere to the [Microsoft REST Guidelines for versioning] using the ASP.NET technology stack out-of-the-box, but
+there are numerous extensions and customizations that allow you to version your APIs however you like. Detailed
+information about the recommended guidance can be found in the [Microsoft REST Guidelines].
+
+## Features
+
+### .NET
+
+#### [Abstractions](https://www.nuget.org/packages/Asp.Versioning.Abstractions)
+
+The core abstractions provide a common set of interfaces and types for API versioning across all supported platforms.
+These capabilities can be used to version your data models or using version metadata outside of ASP.NET.
+
+#### [Client](https://www.nuget.org/packages/Asp.Versioning.Client)
+
+The client-side extensions make it simple to create API version-aware HTTP clients.
+
+### ASP.NET Core
+
+#### [Minimal API](https://www.nuget.org/packages/Asp.Versioning.Http)
+
+Everything you need to add service API versioning to your ASP.NET Core applications and Minimal APIs. The
+[API Explorer][explorer] and [OpenAPI][openapi] extensions provided everything you need to document your services.
+
+#### [MVC (Core)](https://www.nuget.org/packages/Asp.Versioning.Mvc)
+
+Expands upon the service API versioning for ASP.NET Core and adds support for controller classes. The
+[API Explorer][explorer] and [OpenAPI][openapi] extensions provided everything you need to document your services.
+
+#### [gRPC](https://www.nuget.org/packages/Asp.Versioning.Grpc)
+
+Expands upon the service API versioning for ASP.NET Core and adds support for gRPC services. The
+[API Explorer][explorer-grpc] and [OpenAPI][openapi] extensions provided everything you need to document your services.
+
+#### [OData](https://www.nuget.org/packages/Asp.Versioning.OData)
+
+Expands upon the service API versioning for ASP.NET Core and adds OData-specific features for your OData v4.0
+applications and OData controllers, including support for versioned Entity Data Models (EDMs). The
+[API Explorer][explorer-odata] and [OpenAPI][openapi] extensions provided everything you need to document your services.
+
+### ASP.NET (Classic)
+
+#### [Web API](https://www.nuget.org/packages/Asp.Versioning.WebApi)
+
+Everything you need to add service API versioning to your Web API applications and controller classes. The
+[API Explorer][explorer-old] extensions provided everything you need to document your services.
+
+#### [OData](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData)
+
+Expands upon the service API versioning for Web API and adds OData-specific features for your OData v4.0 applications
+and OData controllers, including support for versioned Entity Data Models (EDMs). The
+[API Explorer][explorer-odata-old] extensions provided everything you need to document your services.
+
+## Contributing
+
+ASP.NET API Versioning is free and open source. You can find the source code on [GitHub] and issues and feature requests
+can be posted on the [GitHub issue tracker]. ASP.NET API Versioning relies on the community to fix bugs and add
+features: if you'd like to contribute, please read the [CONTRIBUTING] guide and consider opening a [pull request].
+
+## License
+
+This project is licensed under the [MIT] license.
+
+[explorer]: https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer
+[explorer-grpc]: https://www.nuget.org/packages/Asp.Versioning.Grpc.ApiExplorer
+[explorer-odata]: https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer
+[openapi]: https://www.nuget.org/packages/Asp.Versioning.OpenApi.ApiExplorer
+[explorer-old]: https://www.nuget.org/packages/Asp.Versioning.WebApi.ApiExplorer
+[explorer-odata-old]: https://www.nuget.org/packages/Asp.Versioning.WebApi.OData.ApiExplorer
+[GitHub]: https://github.com/dotnet/aspnet-api-versioning
+[GitHub issue tracker]: https://github.com/dotnet/aspnet-api-versioning/issues
+[CONTRIBUTING]: https://github.com/dotnet/aspnet-api-versioning/blob/main/docs/CONTRIBUTING.md
+[pull request]: https://github.com/dotnet/aspnet-api-versioning/pulls
+[MIT]: https://github.com/dotnet/aspnet-api-versioning/blob/main/LICENSE.txt
+[Microsoft REST Guidelines]: https://github.com/Microsoft/api-guidelines
+[Microsoft REST Guidelines for versioning]: https://github.com/Microsoft/api-guidelines/blob/master/Guidelines.md#12-versioning
\ No newline at end of file
diff --git a/wiki/src/SUMMARY.md b/wiki/src/SUMMARY.md
new file mode 100644
index 000000000..7cabf9b05
--- /dev/null
+++ b/wiki/src/SUMMARY.md
@@ -0,0 +1,135 @@
+# Summary
+
+[Introduction](README.md)
+[Getting Started](getting-started.md)
+
+- [ASP.NET Core]()
+ - [Quick Starts]()
+ - [New Services](aspnet-core/quick-starts/new-services.md)
+ - [Existing Services](aspnet-core/quick-starts/existing-services.md)
+ - [Migration](aspnet-core/quick-starts/migration.md)
+ - [Version Format](aspnet-core/version-format.md)
+ - [Version Discovery](aspnet-core/version-discovery.md)
+ - [Version Policies](aspnet-core/version-policies.md)
+ - [How to Version Your Service](aspnet-core/how-to/overview.md)
+ - [Defining a Service Version](aspnet-core/how-to/define-service-version.md)
+ - [Query String Versioning](aspnet-core/how-to/version-by-query-string.md)
+ - [Media Type Versioning](aspnet-core/how-to/version-by-media-type.md)
+ - [Header Versioning](aspnet-core/how-to/version-by-header.md)
+ - [URL Path Versioning](aspnet-core/how-to/version-by-url.md)
+ - [Version Interleaving](aspnet-core/how-to/version-interleaving.md)
+ - [Version Neutrality](aspnet-core/how-to/version-neutral.md)
+ - [Requested API Version](aspnet-core/how-to/requested-version.md)
+ - [Existing Services](aspnet-core/how-to/existing-services.md)
+ - [Deprecating Versions](aspnet-core/how-to/deprecate-version.md)
+ - [Version Advertisement](aspnet-core/how-to/version-advertisement.md)
+ - [Naming Conventions](aspnet-core/how-to/naming-conventions.md)
+ - [Versioned Models](aspnet-core/how-to/versioned-models.md)
+ - [Configuring Your Application](aspnet-core/config/overview.md)
+ - [API Versioning Options](aspnet-core/config/options.md)
+ - [API Version Reader](aspnet-core/config/reader.md)
+ - [API Version Conventions](aspnet-core/config/conventions.md)
+ - [API Version Selector](aspnet-core/config/selector.md)
+ - [API Versioning with OData](aspnet-core/odata/overview.md)
+ - [Model Configurations](aspnet-core/odata/model-config.md)
+ - [Model Substitution](aspnet-core/odata/model-substitution.md)
+ - [Versioned Model Builder](aspnet-core/odata/model-builder.md)
+ - [Versioned Controllers](aspnet-core/odata/controllers.md)
+ - [Versioned Metadata](aspnet-core/odata/metadata.md)
+ - [Batching](aspnet-core/odata/batching.md)
+ - [API Versioning with gRPC](aspnet-core/grpc/overview.md)
+ - [Request Parameters](aspnet-core/grpc/request-parameters.md)
+ - [Versioned Message Fields](aspnet-core/grpc/versioned-fields.md)
+ - [Error Responses](aspnet-core/errors.md)
+ - [API Documentation](aspnet-core/docs/overview.md)
+ - [API Explorer Options](aspnet-core/docs/options.md)
+ - [gRPC Options](aspnet-core/docs/grpc-options.md)
+ - [OData Options](aspnet-core/docs/odata-options.md)
+ - [OpenAPI Options](aspnet-core/docs/openapi-options.md)
+ - [Scalar Integration](aspnet-core/docs/scalar.md)
+ - [Swashbuckle Integration](aspnet-core/docs/swashbuckle.md)
+ - [Extensions and Customizations]()
+ - [Attributes](aspnet-core/ext/custom-attributes.md)
+ - [Version Format](aspnet-core/ext/custom-format.md)
+ - [Versioned Clients](aspnet-core/ext/clients.md)
+ - [Third-Party](aspnet-core/ext/third-party.md)
+ - [Diagnostics](diagnostic/overview.md)
+ - [AV0001](diagnostic/av0001.md)
+ - [AV0002](diagnostic/av0002.md)
+ - [AV0003](diagnostic/av0003.md)
+ - [AV0004](diagnostic/av0004.md)
+ - [AV0005](diagnostic/av0005.md)
+ - [AV0006](diagnostic/av0006.md)
+ - [AV0007](diagnostic/av0007.md)
+ - [AV0008](diagnostic/av0008.md)
+ - [AV0009](diagnostic/av0009.md)
+ - [AV0010](diagnostic/av0010.md)
+ - [AV0011](diagnostic/av0011.md)
+ - [AV0012](diagnostic/av0012.md)
+ - [AV0013](diagnostic/av0013.md)
+ - [AV0014](diagnostic/av0014.md)
+ - [AV0015](diagnostic/av0015.md)
+ - [AV0016](diagnostic/av0016.md)
+ - [AV0017](diagnostic/av0017.md)
+ - [AV0018](diagnostic/av0018.md)
+ - [AV0019](diagnostic/av0019.md)
+ - [AV0020](diagnostic/av0020.md)
+ - [AV0021](diagnostic/av0021.md)
+ - [AV0022](diagnostic/av0022.md)
+ - [AV0023](diagnostic/av0023.md)
+ - [AV0024](diagnostic/av0024.md)
+ - [AV0025](diagnostic/av0025.md)
+ - [AV0026](diagnostic/av0026.md)
+ - [AV0027](diagnostic/av0027.md)
+ - [AV0028](diagnostic/av0028.md)
+ - [AV0029](diagnostic/av0029.md)
+ - [AV0030](diagnostic/av0030.md)
+ - [AV0031](diagnostic/av0031.md)
+ - [Known Limitations](aspnet-core/limitations.md)
+ - [FAQ](aspnet-core/faq.md)
+ - [Examples](aspnet-core/examples.md)
+- [ASP.NET Web API (Classic)]()
+ - [Quick Starts]()
+ - [New Services](aspnet/quick-starts/new-services.md)
+ - [Existing Services](aspnet/quick-starts/existing-services.md)
+ - [Migration](aspnet/quick-starts/migration.md)
+ - [Version Format](aspnet/version-format.md)
+ - [Version Discovery](aspnet/version-discovery.md)
+ - [Version Policies](aspnet/version-policies.md)
+ - [How to Version Your Service](aspnet/how-to/overview.md)
+ - [Defining a Service Version](aspnet/how-to/define-service-version.md)
+ - [Query String Versioning](aspnet/how-to/version-by-query-string.md)
+ - [Media Type Versioning](aspnet/how-to/version-by-media-type.md)
+ - [Header Versioning](aspnet/how-to/version-by-header.md)
+ - [URL Path Versioning](aspnet/how-to/version-by-url.md)
+ - [Version Interleaving](aspnet/how-to/version-interleaving.md)
+ - [Version Neutrality](aspnet/how-to/version-neutral.md)
+ - [Requested API Version](aspnet/how-to/requested-version.md)
+ - [Existing Services](aspnet/how-to/existing-services.md)
+ - [Deprecating Versions](aspnet/how-to/deprecate-version.md)
+ - [Version Advertisement](aspnet/how-to/version-advertisement.md)
+ - [Naming Conventions](aspnet/how-to/naming-conventions.md)
+ - [Configuring Your Application](aspnet/config/overview.md)
+ - [API Versioning Options](aspnet/config/options.md)
+ - [API Version Reader](aspnet/config/reader.md)
+ - [API Version Conventions](aspnet/config/conventions.md)
+ - [API Version Selector](aspnet/config/selector.md)
+ - [API Versioning with OData](aspnet/odata/overview.md)
+ - [Model Configurations](aspnet/odata/model-config.md)
+ - [Model Substitution](aspnet/odata/model-substitution.md)
+ - [Versioned Model Builder](aspnet/odata/model-builder.md)
+ - [Versioned Controllers](aspnet/odata/controllers.md)
+ - [Versioned Metadata](aspnet/odata/metadata.md)
+ - [Protocol Transitions](aspnet/odata/protocol-transition.md)
+ - [Error Responses](aspnet/errors.md)
+ - [API Documentation](aspnet/docs/overview.md)
+ - [API Explorer Options](aspnet/docs/options.md)
+ - [OData Options](aspnet/docs/odata-options.md)
+ - [Swashbuckle Integration](aspnet/docs/swashbuckle.md)
+ - [Extensions and Customizations]()
+ - [Attributes](aspnet/ext/custom-attributes.md)
+ - [Version Format](aspnet/ext/custom-format.md)
+ - [Versioned Clients](aspnet/ext/clients.md)
+ - [Known Limitations](aspnet/limitations.md)
+ - [FAQ](aspnet/faq.md)
+ - [Examples](aspnet/examples.md)
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/config/conventions.md b/wiki/src/aspnet-core/config/conventions.md
new file mode 100644
index 000000000..c1e321f84
--- /dev/null
+++ b/wiki/src/aspnet-core/config/conventions.md
@@ -0,0 +1,60 @@
+{{#include ../../shared/config/conventions-pre.md}}
+
+```c#
+services.AddApiVersioning()
+ .AddMvc( options =>
+ {
+ options.Conventions.Controller().HasApiVersion( 1.0 );
+ } );
+```
+
+All of the semantics that can be expressed with .NET attributes can be defined using conventions. Consider what version
+`2.0` of the previous controller with interleaved API versions might look like:
+
+```c#
+[ApiController]
+[Route( "[controller]" )]
+public class MyController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+
+ [HttpGet]
+ public IActionResult GetV2() => Ok();
+
+ [HttpGet( "{id:int}" )]
+ public IActionResult GetV2( int id ) => Ok();
+}
+```
+
+The API version conventions might then be defined as:
+
+```c#
+options.Conventions.Controller()
+ .HasDeprecatedApiVersion( 1.0 )
+ .HasApiVersion( 2.0 )
+ .Action( c => c.GetV2() ).MapToApiVersion( 2.0 )
+ .Action( c => c.GetV2( default ) ).MapToApiVersion( 2.0 );
+```
+
+If you use API version conventions and .NET attributes, then the constructed `ApiVersionModel` for the corresponding
+controller will be an aggregated union of the two sets of information.
+
+## Custom
+
+You can also define custom conventions via the `IControllerConvention` interface and add them to the builder:
+
+```c#
+public interface IControllerConvention
+{
+ bool Apply( IControllerConventionBuilder controller, ControllerModel controllerModel );
+}
+```
+
+Custom conventions are added to the convention builder through the API versioning options:
+
+```c#
+options.Conventions.Add( new MyCustomConvention() );
+```
+
+{{#include ../../shared/config/conventions-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/config/options.md b/wiki/src/aspnet-core/config/options.md
new file mode 100644
index 000000000..7dacf4699
--- /dev/null
+++ b/wiki/src/aspnet-core/config/options.md
@@ -0,0 +1 @@
+{{#include ../../shared/config/options.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/config/overview.md b/wiki/src/aspnet-core/config/overview.md
new file mode 100644
index 000000000..dc0dd2482
--- /dev/null
+++ b/wiki/src/aspnet-core/config/overview.md
@@ -0,0 +1,38 @@
+# Configuring Your Application
+
+Although different variations of ASP.NET have distinct application initialization methods, careful consideration was
+taken to make the API versioning configuration as similar as possible across all applications models.
+
+Two methods of configuration are supported. All examples will use the new top-level statements method, but the older
+`Startup.cs` method is still supported.
+
+## Top-Level Statements
+
+```c#
+
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers();
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning()
+ .AddMvc(); // ← brings in MVC Core; unnecessary for Minimal APIs
+
+// remaining setup omitted for brevity
+```
+
+## Startup
+
+The configuration for ASP.NET Core applications typically occur in the `ConfigureServices` method of the **Startup.cs**
+file. To enable API versioning support with the default options, use the following configuration:
+
+```c#
+public void ConfigureServices( IServiceCollection services )
+{
+ services.AddControllers();
+ services.AddProblemDetails();
+ services.AddApiVersioning()
+ .AddMvc(); // ← brings in MVC Core; unnecessary for Minimal APIs
+
+ // remaining setup omitted for brevity
+}
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/config/reader.md b/wiki/src/aspnet-core/config/reader.md
new file mode 100644
index 000000000..38d23c8e7
--- /dev/null
+++ b/wiki/src/aspnet-core/config/reader.md
@@ -0,0 +1 @@
+{{#include ../../shared/config/reader.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/config/selector.md b/wiki/src/aspnet-core/config/selector.md
new file mode 100644
index 000000000..cfc5e1f2a
--- /dev/null
+++ b/wiki/src/aspnet-core/config/selector.md
@@ -0,0 +1 @@
+{{#include ../../shared/config/selector.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/docs/grpc-options.md b/wiki/src/aspnet-core/docs/grpc-options.md
new file mode 100644
index 000000000..9458a63f1
--- /dev/null
+++ b/wiki/src/aspnet-core/docs/grpc-options.md
@@ -0,0 +1,30 @@
+# gRPC Options
+
+The API Explorer support for gRPC has a few options that allow you to customize the behavior. The options are minimal
+because the API Explorer support can technically operate without API Versioning. Most of the configuration will be
+performed against the versioned API Explorer, but there are a few options that are specific to gRPC.
+
+The `GrpcApiExplorerOptions` have the following configuration settings:
+
+- [RouteParameter](#route-parameter)
+
+## Route Parameter
+
+Represents route parameter information required for API exploration.
+
+### Name
+
+When versioning by URL path segment, there is not a clear way to identify which segment represents the API version.
+Rather than use an explicit annotation, the route parameter is matched by name. The default value of the `Name`
+property is `"api_version"`; however, you can use whatever name you choose. The name will be matched in a
+case-sensitive manner.
+
+## Prefix Literal
+
+gRPC supports route parameters in route templates, but a parameter must match an entire segment. It cannot match part
+of a segment in the same manner as an ASP.NET route constraint and an API version does not include literal characters
+such as `'v'`. As a result, the character is not included in the route template.
+
+The `PrefixLiteral` property adds the expected literal in the route template when it is built for the API Explorer. As
+an example, the gRPC route template `"api/{api-version}/example"` will be generated as `"api/v{api-version}/example"`
+and produce the expected behavior in the API Explorer.
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/docs/odata-options.md b/wiki/src/aspnet-core/docs/odata-options.md
new file mode 100644
index 000000000..ac65380b5
--- /dev/null
+++ b/wiki/src/aspnet-core/docs/odata-options.md
@@ -0,0 +1,167 @@
+{{#include ../../shared/docs/odata-options-pre.md}}
+
+{{#include ../../shared/docs/odata-options-post.md}}
+
+{{#include ../../shared/docs/odata-options-query.md}}
+
+{{#include ../../shared/docs/odata-options-attributes.md}}
+
+```c#
+using Asp.Versioning;
+using Asp.Versioning.OData;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.OData.Query;
+using Microsoft.AspNetCore.OData.Results;
+using Microsoft.AspNetCore.OData.Routing.Controllers;
+using static Microsoft.AspNetCore.Http.StatusCodes;
+using static Microsoft.AspNetCore.OData.Query.AllowedQueryOptions;
+using static System.DateTime;
+
+[ApiVersion( 1.0 )]
+public class OrdersController : ODataController
+{
+ [Produces( "application/json" )]
+ [ProducesResponseType( typeof( ODataValue> ), Status200OK )]
+ [EnableQuery( MaxTop = 100, AllowedQueryOptions = Select | Top | Skip | Count )]
+ public IQueryable Get()
+ {
+ var orders = new[]
+ {
+ new Order(){ Id = 1, Customer = "John Doe" },
+ new Order(){ Id = 2, Customer = "John Doe" },
+ new Order(){ Id = 3, Customer = "Jane Doe", EffectiveDate = UtcNow.AddDays(7d) }
+ };
+
+ return orders.AsQueryable();
+ }
+
+ [Produces( "application/json" )]
+ [ProducesResponseType( typeof( Order ), Status200OK )]
+ [ProducesResponseType( Status404NotFound )]
+ [EnableQuery( AllowedQueryOptions = Select )]
+ public SingleResult Get( int key )
+ {
+ var orders = new[] { new Order(){ Id = key, Customer = "John Doe" } };
+ return SingleResult.Create( orders.AsQueryable() );
+ }
+}
+```
+
+{{#include ../../shared/docs/odata-options-model-bound.md}}
+
+```c#
+using Asp.Versioning;
+using Asp.Versioning.OData;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.OData.Query;
+using Microsoft.AspNetCore.OData.Results;
+using Microsoft.AspNetCore.OData.Routing.Controllers;
+using static Microsoft.AspNetCore.Http.StatusCodes;
+using static Microsoft.AspNetCore.OData.Query.AllowedQueryOptions;
+using static System.DateTime;
+
+public class PeopleController : ODataController
+{
+ [Produces( "application/json" )]
+ [ProducesResponseType( typeof( ODataValue> ), Status200OK )]
+ public IActionResult Get( ODataQueryOptions options )
+ {
+ var validationSettings = new ODataValidationSettings()
+ {
+ AllowedQueryOptions = Select | OrderBy | Top | Skip | Count,
+ AllowedOrderByProperties = { "firstName", "lastName" },
+ AllowedArithmeticOperators = AllowedArithmeticOperators.None,
+ AllowedFunctions = AllowedFunctions.None,
+ AllowedLogicalOperators = AllowedLogicalOperators.None,
+ MaxOrderByNodeCount = 2,
+ MaxTop = 100,
+ };
+
+ try
+ {
+ options.Validate( validationSettings );
+ }
+ catch ( ODataException )
+ {
+ return BadRequest();
+ }
+
+ var people = new[]
+ {
+ new Person()
+ {
+ Id = 1,
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john.doe@somewhere.com",
+ Phone = "555-987-1234",
+ },
+ new Person()
+ {
+ Id = 2,
+ FirstName = "Bob",
+ LastName = "Smith",
+ Email = "bob.smith@somewhere.com",
+ Phone = "555-654-4321",
+ },
+ new Person()
+ {
+ Id = 3,
+ FirstName = "Jane",
+ LastName = "Doe",
+ Email = "jane.doe@somewhere.com",
+ Phone = "555-789-3456",
+ }
+ };
+
+ return Ok( options.ApplyTo( people.AsQueryable() ) );
+ }
+
+ [Produces( "application/json" )]
+ [ProducesResponseType( typeof( Person ), Status200OK )]
+ [ProducesResponseType( Status404NotFound )]
+ public IActionResult Get( int key, ODataQueryOptions options )
+ {
+ var people = new[]
+ {
+ new Person()
+ {
+ Id = key,
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john.doe@somewhere.com",
+ Phone = "555-987-1234",
+ }
+ };
+
+ var person = options.ApplyTo( people.AsQueryable() ).SingleOrDefault();
+
+ if ( person == null )
+ {
+ return NotFound();
+ }
+
+ return Ok( person );
+ }
+}
+```
+
+{{#include ../../shared/docs/odata-options-mid.md}}
+
+{{#include ../../shared/docs/odata-options-partial-pre.md}}
+
+```c#
+[ApiVersion( 1.0 )]
+[ApiController]
+[Route( "[controller]" )]
+public class BooksController : ControllerBase
+{
+ [HttpGet]
+ [Produces( "application/json" )]
+ [ProducesResponseType( typeof( IEnumerable ), 200 )]
+ public IActionResult Get( ODataQueryOptions options ) =>
+ Ok( options.ApplyTo( books.AsQueryable() ) );
+}
+```
+
+{{#include ../../shared/docs/odata-options-partial-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/docs/openapi-options.md b/wiki/src/aspnet-core/docs/openapi-options.md
new file mode 100644
index 000000000..3e31cf511
--- /dev/null
+++ b/wiki/src/aspnet-core/docs/openapi-options.md
@@ -0,0 +1,56 @@
+# OpenAPI Options
+
+The OpenAPI options allows you to configure, customize, and extend the default behaviors when you add OpenAPI support.
+The configuration options are specified by providing a callback to the appropriate extension method:
+
+The `VersionedOpenApiOptions` has the following configuration settings:
+
+- [Description](#description)
+- [Document](#document)
+- [DocumentDescription](#document-description)
+
+## Description
+
+The description provides the `ApiVersionDescription` for the current OpenAPI document being generated. This information
+includes the API version, its group name, and whether it is deprecated.
+
+## Document
+
+This provides access to the current `OpenApiOptions`. These are the same options you would configure when you document
+an unversioned API. Your configuration can be the same for all versions or vary version by version.
+
+## Document Description
+
+The document description provides the configuration settings for the current OpenAPI document being generated. The
+`description` for an OpenAPI document is defined as text, but most user interfaces allow Markdown to be specified.
+These additional settings control the text and format to be included in the `description` attribute.
+
+The `OpenApiDocumentDescriptionOptions` provide the following configuration settings:
+
+- [HidePolicyLinks](#hide-policy-links)
+- [DeprecationNotice](#deprecation-notice)
+- [SunsetNotice](#sunset-notice)
+
+### Hide Policy Links
+
+This setting controls whether deprecation or sunset policy links are displayed. The default value is `false`, which
+means any defined policies links are displayed as bulleted list of hyperlinks. Policy links rendered for user interface
+purposes must have the link type `text/html`.
+
+Policies may define other types of links, but these are not shown in the user interface. These links are rendered in
+OpenAPI documents in the `x-api-versioning` document extension.
+
+### Deprecation
+
+If an API has an applicable deprecation policy, this setting defines the callback function used to generate the message
+text based on the provided policy. The default setting returns a generic message in English indicating that the API is
+deprecated if no date is specified or a date-specific message in English when the API became, or will become,
+deprecated.
+
+### Sunset
+
+If an API has an applicable sunset policy, this setting defines the callback function used to generate the message text
+based on the provided policy. If the policy does not have a date, then no message is generated; otherwise, a
+date-specific message in English is generated indicating when the API became, or will become, sunset. A sunset policy is
+for an API that is sunset and will no longer exist. The effective sunset policy date should always be in the future as
+there should be no information about the API after it is sunset.
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/docs/options.md b/wiki/src/aspnet-core/docs/options.md
new file mode 100644
index 000000000..f3e4b106c
--- /dev/null
+++ b/wiki/src/aspnet-core/docs/options.md
@@ -0,0 +1,43 @@
+{{#include ../../shared/docs/options-pre.md}}
+- [FormatGroupName](#format-group-name)
+
+### Format Group Name
+
+This option allows you to define an optional `FormatGroupNameCallback`, which will provide the current group name and
+formatted API version. By default, the formatted API version is used as the group name and is the most logical choice.
+A developer, however, may specify their own group name in a variety of ways such as
+`[ApiExplorerSettings(GroupName = "Custom")]`. When a developer explicitly sets a group name, that name is honored.
+If, and **only** if, a developer sets both a custom group name and defines a `FormatGroupName` callback, the method
+will be invoked to produce a combination of both.
+
+Consider the following controller:
+
+```c#
+[ApiVersion( 1.0 )]
+[ApiExplorerSettings( GroupName = "Sales" )]
+[Route( "[controller]" )]
+public class OrderController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+A callback can be defined to control how the combination of the API version and group name will be formatted.
+
+```c#
+builder.Services.AddApiVersioning()
+ .AddMvc()
+ .AddApiExplorer(
+ options =>
+ {
+ // the default is ToString(), but we want "'v'major[.minor][-status]"
+ options.GroupNameFormat = "'v'VVV";
+
+ // if we have both parts, decided how to format the group
+ // from the example: "Sales - v1"
+ options.FormatGroupName = (group, version) => $"{group} - {version}";
+ } );
+```
+
+{{#include ../../shared/docs/odata-options-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/docs/overview.md b/wiki/src/aspnet-core/docs/overview.md
new file mode 100644
index 000000000..745ff7192
--- /dev/null
+++ b/wiki/src/aspnet-core/docs/overview.md
@@ -0,0 +1,172 @@
+{{#include ../../shared/docs/overview-pre.md}}
+
+Any OpenAPI generator such as [Microsoft][openapi-ms], [Swashbuckle][openapi-swashbuckle], or [NSwag][openapi-nswag]
+that leverage the API Explorer can be used.
+
+## Minimal API or MVC (Core)
+
+[](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer) [](https://www.nuget.org/packages/Asp.Versioning.OpenApi)
+
+>[!NOTE]
+>Applies to ASP.NET Core 10.0+. For earlier versions, see the [previous examples] with
+[Swashbuckle][openapi-swashbuckle].
+
+Everything you need to add versioned documentation to your Minimal and controller-based APIs using the
+[API Explorer extensions][explorer-mvc], [OpenAPI extensions], and [Scalar].
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+// only required if you're using controllers
+builder.Services.AddControllers();
+
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning()
+ .AddMvc() // ← bring in MVC (Core); not required for Minimal APIs
+ .AddApiExplorer(
+ // (optional) format the version as "'v'major[.minor][-status]"
+ options => options.GroupNameFormat = "'v'VVV" )
+ .AddOpenApi(
+ // (optional) apply Scalar-specific transformers
+ options => options.Document.AddScalarTransformers()
+ );
+
+var app = builder.Build();
+
+// configure OpenAPI and Scalar to use a document per version
+app.MapOpenApi().WithDocumentPerVersion();
+app.MapScalarApiReference(
+ options =>
+ {
+ var descriptions = app.DescribeApiVersions();
+
+ for ( var i = 0; i < descriptions.Count; i++ )
+ {
+ var description = descriptions[i];
+ var isDefault = i == descriptions.Count - 1;
+
+ options.AddDocument( description.GroupName, description.GroupName, isDefault: isDefault );
+ }
+ } );
+
+// only required if you're using controllers
+app.MapControllers();
+
+app.Run();
+```
+
+Review the following example projects for additional setup and configuration options:
+
+- [OpenAPI Example](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/WebApi/OpenApiExample)
+- [Minimal OpenAPI Example](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/WebApi/MinimalOpenApiExample)
+
+## gRPC
+
+[](https://www.nuget.org/packages/Asp.Versioning.Grpc) [](https://www.nuget.org/packages/Asp.Versioning.Grpc.ApiExplorer) [](https://www.nuget.org/packages/Asp.Versioning.OpenApi)
+
+Everything you need to add versioned documentation to your gRPC APIs using the [API Explorer extensions],
+[OpenAPI extensions], and [Scalar].
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning()
+ .AddGrpc()
+ .AddApiExplorer(
+ // (optional) format the version as "'v'major[.minor][-status]"
+ options => options.GroupNameFormat = "'v'VVV" )
+ .AddGrpcApiExplorer()
+ .AddOpenApi(
+ // (optional) apply Scalar-specific transformers
+ options => options.Document.AddScalarTransformers()
+ );
+
+var app = builder.Build();
+var greeter = app.NewVersionedApi( "Greeter" );
+
+greeter.MapGrpcService()
+ .HasApiVersion( 1.0 )
+ .HasApiVersion( 2.0 )
+ .HasApiVersion( 3.0 );
+
+// configure OpenAPI and Scalar to use a document per version
+app.MapOpenApi().WithDocumentPerVersion();
+app.MapScalarApiReference(
+ options =>
+ {
+ var descriptions = app.DescribeApiVersions();
+
+ for ( var i = 0; i < descriptions.Count; i++ )
+ {
+ var description = descriptions[i];
+ var isDefault = i == descriptions.Count - 1;
+
+ options.AddDocument( description.GroupName, description.GroupName, isDefault: isDefault );
+ }
+ } );
+
+app.Run();
+```
+
+Review the following example projects for additional setup and configuration options:
+
+- [gRPC OpenAPI Example](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/WebApi/GrpcOpenApiExample)
+
+## OData
+
+[](https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer) [](https://www.nuget.org/packages/Asp.Versioning.OpenApi)
+
+Everything you need to add versioned documentation to your OData controllers using the
+[API Explorer extensions][explorer-odata], [OpenAPI extensions], and [Scalar].
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers().AddOData();
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning()
+ .AddOData( options => options.AddRouteComponents() )
+ .AddODataApiExplorer(
+ // (optional) format the version as "'v'major[.minor][-status]"
+ options => options.GroupNameFormat = "'v'VVV" )
+ .AddOpenApi(
+ // (optional) apply Scalar-specific transformers
+ options => options.Document.AddScalarTransformers()
+ );
+
+var app = builder.Build();
+
+// configure OpenAPI and Scalar to use a document per version
+app.MapOpenApi().WithDocumentPerVersion();
+app.MapScalarApiReference(
+ options =>
+ {
+ var descriptions = app.DescribeApiVersions();
+
+ for ( var i = 0; i < descriptions.Count; i++ )
+ {
+ var description = descriptions[i];
+ var isDefault = i == descriptions.Count - 1;
+
+ options.AddDocument( description.GroupName, description.GroupName, isDefault: isDefault );
+ }
+ } );
+
+app.MapControllers();
+app.Run();
+```
+
+Review the following example projects for additional setup and configuration options:
+
+- [OData OpenAPI Example](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/OData/ODataOpenApiExample)
+- [Partial OData OpenAPI Example](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/OData/SomeODataOpenApiExample)
+
+[previous examples]: https://github.com/dotnet/aspnet-api-versioning/tree/release/8.1/examples/AspNetCore/WebApi
+[explorer-mvc]: https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer
+[explorer-odata]: https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer
+[OpenAPI extensions]: https://www.nuget.org/packages/Asp.Versioning.OpenApi
+
+[openapi-ms]: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview
+[openapi-swashbuckle]: https://github.com/domaindrivendev/Swashbuckle.AspNetCore
+[openapi-nswag]: https://github.com/RicoSuter/NSwag
+[Scalar]: https://scalar.com/
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/docs/scalar.md b/wiki/src/aspnet-core/docs/scalar.md
new file mode 100644
index 000000000..5d2e34003
--- /dev/null
+++ b/wiki/src/aspnet-core/docs/scalar.md
@@ -0,0 +1,80 @@
+# Scalar Integration
+
+[Scalar](https://scalar.com/) has quickly become one of the more common, modern OpenAPI user interfaces and it easily
+integrates with API Versioning. The only thing you need to do is tell Scalar about the documents your application will
+generate.
+
+Remember to add the necessary references to one or both of the following:
+
+- [Versioned OpenAPI Extensions for ASP.NET Core](https://www.nuget.org/packages/Asp.Versioning.OpenApi)
+- [API Explorer Extensions for ASP.NET Core](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer)
+- [API Explorer Extensions for ASP.NET Core with gRPC](https://www.nuget.org/packages/Asp.Versioning.Grpc.ApiExplorer)
+- [API Explorer Extensions for ASP.NET Core with OData](https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer)
+- [Scalar for ASP.NET Core](https://www.nuget.org/packages/Scalar.AspNetCore)
+- [Scalar for ASP.NET Core with Microsoft OpenAPI extensions](https://www.nuget.org/packages/Scalar.AspNetCore.Microsoft)
+
+**Minimal APIs**
+
+```c#
+builder.Services.AddApiVersioning()
+ .AddApiExplorer()
+ .AddOpenApi( options => options.Document.AddScalarTransformers() );
+```
+
+**Controllers**
+
+```c#
+builder.Services.AddApiVersioning()
+ .AddMvc()
+ .AddApiExplorer()
+ .AddOpenApi( options => options.Document.AddScalarTransformers() );
+```
+
+**gRPC**
+
+```c#
+builder.Services.AddApiVersioning()
+ .AddApiExplorer()
+ .AddGrpc()
+ .AddGrpcApiExplorer()
+ .AddOpenApi( options => options.Document.AddScalarTransformers() );
+```
+
+**OData**
+
+```c#
+builder.Services.AddApiVersioning()
+ .AddOData()
+ .AddODataApiExplorer()
+ .AddOpenApi( options => options.Document.AddScalarTransformers() );
+```
+
+Once you have that configured, you need only generate an OpenAPI document per version and let Scalar know which
+generated documents it should expect.
+
+```c#
+app.MapOpenApi().WithDocumentPerVersion();
+app.MapScalarApiReference(
+ options =>
+ {
+ var descriptions = app.DescribeApiVersions();
+
+ for ( var i = 0; i < descriptions.Count; i++ )
+ {
+ var description = descriptions[i];
+ var isDefault = i == descriptions.Count - 1;
+
+ options.AddDocument( description.GroupName, description.GroupName, isDefault: isDefault );
+ }
+ } );
+```
+
+## Examples
+
+There are end-to-end examples using API versioning, OpenAPI, and Scalar:
+
+- [Minimal APIs, API Versioning and Scalar](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/WebApi/MinimalOpenApiExample)
+- [MVC (Core), API Versioning and Scalar](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/WebApi/OpenApiExample)
+- [gRPC, API Versioning and Scalar](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/WebApi/GrpcOpenApiExample)
+- [OData, API Versioning, and Scalar](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/OData/ODataOpenApiExample)
+- [Partial OData, API Versioning, and Scalar](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/OData/SomeODataOpenApiExample)
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/docs/swashbuckle.md b/wiki/src/aspnet-core/docs/swashbuckle.md
new file mode 100644
index 000000000..71d4c5371
--- /dev/null
+++ b/wiki/src/aspnet-core/docs/swashbuckle.md
@@ -0,0 +1,120 @@
+{{#include ../../shared/docs/swashbuckle-pre.md}}
+
+Remember to add the necessary references to one or both of the following:
+
+- [API Explorer Extensions for ASP.NET Core](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer)
+- [API Explorer Extensions for ASP.NET Core with OData](https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer)
+
+```c#
+public class SwaggerDefaultValues : IOperationFilter
+{
+ public void Apply( OpenApiOperation operation, OperationFilterContext context )
+ {
+ var apiDescription = context.ApiDescription;
+
+ operation.Deprecated |= apiDescription.IsDeprecated();
+
+ foreach ( var responseType in context.ApiDescription.SupportedResponseTypes )
+ {
+ var responseKey = responseType.IsDefaultResponse
+ ? "default"
+ : responseType.StatusCode.ToString();
+ var response = operation.Responses[responseKey];
+
+ foreach ( var contentType in response.Content.Keys )
+ {
+ if ( !responseType.ApiResponseFormats.Any( x => x.MediaType == contentType ) )
+ {
+ response.Content.Remove( contentType );
+ }
+ }
+ }
+
+ if ( operation.Parameters == null )
+ {
+ return;
+ }
+
+ foreach ( var parameter in operation.Parameters )
+ {
+ var description = apiDescription.ParameterDescriptions
+ .First( p => p.Name == parameter.Name );
+
+ parameter.Description ??= description.ModelMetadata?.Description;
+
+ if ( parameter.Schema.Default == null && description.DefaultValue != null )
+ {
+ var json = JsonSerializer.Serialize(
+ description.DefaultValue,
+ description.ModelMetadata.ModelType );
+ parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson( json );
+ }
+
+ parameter.Required |= description.IsRequired;
+ }
+ }
+}
+```
+
+We also need a way to tell Swashbuckle about the API versions in the application:
+
+```c#
+public class ConfigureSwaggerOptions : IConfigureOptions
+{
+ private readonly IApiVersionDescriptionProvider provider;
+
+ public ConfigureSwaggerOptions( IApiVersionDescriptionProvider provider ) => this.provider = provider;
+
+ public void Configure( SwaggerGenOptions options )
+ {
+ foreach ( var description in provider.ApiVersionDescriptions )
+ {
+ options.SwaggerDoc(
+ description.GroupName,
+ new OpenApiInfo()
+ {
+ Title = "Example API",
+ Description = "An example API",
+ Version = description.ApiVersion.ToString(),
+ } );
+ }
+ }
+}
+```
+
+Now we can put it all together:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers();
+builder.Services.AddApiVersioning().AddMvc().AddApiExplorer();
+builder.Services.AddTransient, ConfigureSwaggerOptions>();
+builder.Services.AddSwaggerGen( options => options.OperationFilter() );
+
+var app = builder.Build();
+
+app.UseSwagger();
+app.UseSwaggerUI(
+ options =>
+ {
+ foreach ( var description in app.DescribeApiVersions() )
+ {
+ options.SwaggerEndpoint(
+ $"/swagger/{description.GroupName}/swagger.json",
+ description.GroupName );
+ }
+ } );
+
+app.MapControllers();
+app.Run();
+```
+
+## Examples
+
+There are end-to-end examples using API versioning and Swashbuckle:
+
+- [Minimal APIs, API Versioning and Swashbuckle](https://github.com/dotnet/aspnet-api-versioning/tree/release/8.1/examples/AspNetCore/WebApi/MinimalOpenApiExample)
+- [MVC (Core), API Versioning and Swashbuckle](https://github.com/dotnet/aspnet-api-versioning/tree/release/8.1/examples/AspNetCore/WebApi/OpenApiExample)
+- [OData, API Versioning, and Swashbuckle](https://github.com/dotnet/aspnet-api-versioning/tree/release/8.1/examples/AspNetCore/OData/ODataOpenApiExample)
+- [Partial OData, API Versioning, and Swashbuckle](https://github.com/dotnet/aspnet-api-versioning/tree/release/8.1/examples/AspNetCore/OData/SomeODataOpenApiExample)
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/errors.md b/wiki/src/aspnet-core/errors.md
new file mode 100644
index 000000000..4efc78c62
--- /dev/null
+++ b/wiki/src/aspnet-core/errors.md
@@ -0,0 +1,102 @@
+{{#include ../shared/errors-pre.md}}
+
+## Customization
+
+Error responses can be customized or extended in a variety of ways. You must opt into using problem details via:
+
+```c#
+services.AddProblemDetails();
+```
+
+>[!NOTE]
+>Applies to .NET 7+
+
+If problem details are not added, clients will receive an error response which only has the HTTP status code. You might
+choose this approach if you don't want a response body or your error responses do not comply with RFC 7807.
+
+To modify the way a problem is written to clients, you can implement and register a your own `IProblemDetailsWriter`
+implementation. Each registered implementation is injected into the `IProblemDetailsService`. The first matching writer
+is used to write the response body. For more information see the ASP.NET Core [Problem Details] documentation.
+
+
+The `IProblemDetailsService` was not added to support `ProblemDetails` in Minimal APIs and MVC Core until .NET 7. In API
+Versioning `6.x`, the `IProblemDetailsFactory` interface was used to bridge this gap. Contrary to the opt-in behavior of
+`AddProblemDetails()`, a default implementation of `IProblemDetailsFactory` is automatically registered for Minimal
+APIs. If MVC Core is added, then a decorated adapter is automatically provided over `ProblemDetailsFactory`. You have
+the choice of replacing the entire `IProblemDetailsFactory` service or the MVC Core specific `ProblemDetailsFactory`.
+
+The `IProblemDetailsFactory` interface was completely removed in .NET 7+ because it is no longer used in any way.
+
+>[!IMPORTANT]
+>Applies to .NET 6
+
+## Backward Compatibility
+
+While it is possible to customize error responses and retain the previous **Error Object** format, there is considerable
+work required to enable this behavior and may block adoption of new library versions. Additional extensions have been
+added to retain backward compatibility or continue to use **Error Objects** if you so desire.
+
+Using **Error Object** responses is as simple as registering the `ErrorObjectWriter` to emit them. The critical part of
+each setup is the order in which the writer is registered. If the writer is not registered in the correct order, it will
+not be selected. Each configuration **must** occur before `AddApiVersioning()`.
+
+The default implementation of the `ErrorObjectWriter` **only** writes **Error Objects** for API versioning related
+errors. The default ASP.NET Core behavior provided by `AddProblemDetails()` is used for writing other types of errors.
+If you want to use **Error Objects** for other error responses, you can extend `ErrorObjectWriter` and override which
+types of Problem Details it should match - perhaps all of them.
+
+>[!NOTE]
+>Applies to 8.1.0+
+
+#### Minimal API
+
+`AddErrorObjects()` adds the default behavior; however, you can register a custom `ErrorObjectWriter` via
+`AddErrorObject()`. Both methods allow a custom `Action` setup and will configure the default
+behavior if not otherwise specified.
+
+```c#
+builder.Services.AddProblemDetails().AddErrorObjects();
+builder.Services.ApiVersioning();
+```
+
+#### MVC (Core)
+
+```c#
+builder.Services.AddControllers();
+builder.Services.AddErrorObjects().AddProblemDetails();
+builder.Services.ApiVersioning().AddMvc();
+```
+
+>[!NOTE]
+>Applies to 7.1.0+
+
+#### Minimal API
+
+```c#
+builder.Services.AddProblemDetails();
+builder.Services.TryAddEnumerable( ServiceDescriptor.Singleton() );
+builder.Services.ApiVersioning();
+```
+
+#### MVC (Core)
+
+```c#
+builder.Services.AddControllers();
+builder.Services.TryAddEnumerable( ServiceDescriptor.Singleton() );
+builder.Services.AddProblemDetails();
+builder.Services.ApiVersioning().AddMvc();
+```
+
+>[!NOTE]
+>Applies to .NET 6 and 6.5.0+
+
+Since `IProblemDetailsService` did not exist in .NET 6, you must instead replace `IProblemDetailsFactory` with the
+`ErrorObjectFactory` service. The configuration process and order are the same regardless of whether you are using
+Minimal APIs or controllers. The replaced service should occur before `AddApiVersioning()`.
+
+```c#
+builder.Services.AddSingleton();
+builder.Services.ApiVersioning();
+```
+
+[Problem Details]: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling#problem-details
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/examples.md b/wiki/src/aspnet-core/examples.md
new file mode 100644
index 000000000..c0ae40af7
--- /dev/null
+++ b/wiki/src/aspnet-core/examples.md
@@ -0,0 +1,14 @@
+# Examples
+
+Complete, runnable sample projects live in the [examples] folder of the repository.
+
+- [Web API](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/WebApi)
+ - Includes Minimal APIs
+ - Includes controllers with MVC (Core)
+ - Includes gRPC
+ - Includes OpenAPI
+- [OData](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNetCore/OData)
+ - Includes controllers with MVC (Core) and OData
+ - Includes OpenAPI
+
+[examples]: https://github.com/dotnet/aspnet-api-versioning/tree/main/examples
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/ext/clients.md b/wiki/src/aspnet-core/ext/clients.md
new file mode 100644
index 000000000..5960a704e
--- /dev/null
+++ b/wiki/src/aspnet-core/ext/clients.md
@@ -0,0 +1 @@
+{{#include ../../shared/ext/clients.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/ext/custom-attributes.md b/wiki/src/aspnet-core/ext/custom-attributes.md
new file mode 100644
index 000000000..c30a705af
--- /dev/null
+++ b/wiki/src/aspnet-core/ext/custom-attributes.md
@@ -0,0 +1,14 @@
+{{#include ../../shared/ext/custom-attributes-pre.md}}
+
+```
+[V1]
+[ApiController]
+[Route( "api/[controller]" )]
+public class HelloWorldController : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world!";
+}
+```
+
+{{#include ../../shared/ext/custom-attributes-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/ext/custom-format.md b/wiki/src/aspnet-core/ext/custom-format.md
new file mode 100644
index 000000000..fee96ef79
--- /dev/null
+++ b/wiki/src/aspnet-core/ext/custom-format.md
@@ -0,0 +1 @@
+{{#include ../../shared/ext/custom-format.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/ext/third-party.md b/wiki/src/aspnet-core/ext/third-party.md
new file mode 100644
index 000000000..04992b1c8
--- /dev/null
+++ b/wiki/src/aspnet-core/ext/third-party.md
@@ -0,0 +1,7 @@
+# Third-Party
+
+The following are external, third-party extensions that showcase extensibility.
+
+### Custom Version Ranges
+
+[https://github.com/purplebricks/PB.ITOps.AspNetCore.Versioning](https://github.com/purplebricks/PB.ITOps.AspNetCore.Versioning)
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/faq.md b/wiki/src/aspnet-core/faq.md
new file mode 100644
index 000000000..e008042c7
--- /dev/null
+++ b/wiki/src/aspnet-core/faq.md
@@ -0,0 +1 @@
+{{#include ../shared/faq.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/grpc/overview.md b/wiki/src/aspnet-core/grpc/overview.md
new file mode 100644
index 000000000..e04b9dffa
--- /dev/null
+++ b/wiki/src/aspnet-core/grpc/overview.md
@@ -0,0 +1,43 @@
+# API Versioning with gRPC
+
+Service API versioning using gRPC is nearly identical to the standard configuration with only a few modifications. When
+a gRPC service is registered, it indicates which API versions it serves. As with all other versioned services, the
+routes for gRPC services can overlap and they will be disambiguated by the requested API version.
+
+```protobuf
+syntax = "proto3";
+
+import "google/api/annotations.proto";
+
+package greet;
+
+message HelloRequest {
+ string name = 1;
+}
+
+message HelloReply {
+ string message = 1;
+}
+
+service Greeter {
+ // GET /greet/{name}?api-version=1.0
+ rpc SayHello (HelloRequest) returns (HelloReply) {
+ option (google.api.http) = {
+ get: "/greet/{name}"
+ response_body: "message"
+ };
+ }
+}
+```
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning().AddGrpc();
+
+var app = builder.Build();
+var greeter = app.NewVersionedApi();
+
+greeter.MapGrpcService().HasApiVersion( 1.0 );
+app.Run();
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/grpc/request-parameters.md b/wiki/src/aspnet-core/grpc/request-parameters.md
new file mode 100644
index 000000000..0b59b43f2
--- /dev/null
+++ b/wiki/src/aspnet-core/grpc/request-parameters.md
@@ -0,0 +1,115 @@
+# Request Parameters
+
+In most cases, adding API versioning to your gRPC services is orthogonal to how you define your service. gRPC sits atop
+HTTP. API versioning relies on the _Uniform Interface_ REST constraint, which does not require knowing anything about
+your service. It doesn't even know that gRPC is in use. This allows versioning by query string, HTTP header, or media
+type to work without any direct changes to your service. gRPC only supports mapping query string and route parameters
+in the path to messages defined by your service.
+
+## Query String
+
+When you version by query string, you do not need to define any additional message fields, but you _can_. Fields that
+do not appear as route parameters, the request body, nor response body are considered to be query parameters. The
+default query parameter is `"api-version"`, but this can be any name you like as long as the API versioning
+configuration and message field name align.
+
+```protobuf
+syntax = "proto3";
+
+import "google/api/annotations.proto";
+
+package greet;
+
+message HelloRequest {
+ // optional if you want to retrieve the api-version query parameter in requests
+ string api_version = 1 [json_name = "api-version"];
+ string name = 2;
+}
+
+message HelloReply {
+ string message = 1;
+}
+
+service Greeter {
+ // GET /greet/{name}?api-version=1.0
+ rpc SayHello (HelloRequest) returns (HelloReply) {
+ option (google.api.http) = {
+ get: "/greet/{name}"
+ response_body: "message"
+ };
+ }
+}
+```
+
+If you need or want to retrieve the requested API version, but you do not want to include it as a message field, you
+can get it directly from the incoming request:
+
+```c#
+public class GreeterService : Greeter.GreeterBase
+{
+ public override Task SayHello( HelloRequest request, ServerCallContext context )
+ {
+ var apiVersion = context.GetHttpContext().ApiVersioningFeature.RawRequestedApiVersion;
+ return Task.FromResult( new HelloReply { Message = $"Hello {request.Name} (v{apiVersion})" } );
+ }
+}
+```
+
+>[!NOTE]
+>`RawRequestedApiVersion` is the API version as it appear over the wire, whereas `RequestedApiVersion` will be the
+>parsed `ApiVersion` type.
+
+## URL Path Segment
+
+An alternate method of API versioning is using a URL path segment. This method of versioning contradicts the
+_Uniform Interface_ REST constraint and, therefore, comes with several limitations and additional configuration.
+
+```protobuf
+syntax = "proto3";
+
+import "google/api/annotations.proto";
+
+package greet;
+
+message HelloRequest {
+ // required because it is a route parameter
+ string api_version = 1;
+ string name = 2;
+}
+
+message HelloReply {
+ string message = 1;
+}
+
+service Greeter {
+ // GET /v1/greet/{name}
+ rpc SayHello (HelloRequest) returns (HelloReply) {
+ option (google.api.http) = {
+ get: "/{api_version}/greet/{name}"
+ response_body: "message"
+ };
+ }
+}
+```
+
+When you version by a URL path segment, you need a route parameter to serve as the placeholder for the API version. If
+you do not do this, then `/v1/greet/{name}` cannot be mapped to other API versions. Mapping `v1 → 3.0` is nonsensical.
+
+gRPC route parameters do not have constraints and do not behave the same as ASP.NET Core route parameters and their
+constraints. API versioning has to do additional work to determine if the incoming request matches and is thus slower
+to execute. The matched route parameter will also hold the entire URL path segment, not just the API version. Recall
+that the literal `'v'` is not part of the API version. The `{api_version}` route parameter is, therefore, useful for
+routing, but not useful in the message. This method of versioning requires you to get the incoming API version from the
+server call context instead.
+
+```c#
+public class GreeterService : Greeter.GreeterBase
+{
+ public override Task SayHello( HelloRequest request, ServerCallContext context )
+ {
+ // required because `request.ApiVersion` will hold 'v1', but the requested version is '1.0'
+ var apiVersion = context.GetHttpContext().ApiVersioningFeature.RawRequestedApiVersion;
+ return Task.FromResult( new HelloReply { Message = $"Hello {request.Name} (v{apiVersion})" } );
+ }
+}
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/grpc/versioned-fields.md b/wiki/src/aspnet-core/grpc/versioned-fields.md
new file mode 100644
index 000000000..52ce6fb6a
--- /dev/null
+++ b/wiki/src/aspnet-core/grpc/versioned-fields.md
@@ -0,0 +1,89 @@
+# Versioned Message Fields
+
+Protocol Buffer messages are designed to support backward compatibility. JSON schemas, on the other hand, can be strict
+or not provide extension points. API-to-model affinity is one of the reasons to version a service in the first place.
+gRPC messages do not have a built-in way to express this when they are transcoded.
+
+API Versioning provides a set of custom annotations that enables decorating which message fields are visible in which
+API versions. The `asp.api.version` annotation indicates an API version range which indicates which API versions the
+field should be visible in. The specified value must follow the [interval notation].
+
+While the examples illustrate numbers, any API version is valid; for example:
+
+`[2026-07-01,2027-01-01) → 2026-07-01 ≤ x < 2027-01-01`
+
+In rare cases, you might need a split range. The `asp.api.version` annotation supports multiple entries; for example:
+
+`[(asp.api.version) = "[,2.0)", (asp.api.version) = "(2.0,]"]`
+
+This would indicate a field that is included in every API version **except** `2.0`.
+
+## Annotations
+
+The following sample gRPC service demonstrates how fields can be annotated to indicate which API versions they appear
+in. When no annotation is applied, the field appears in all API versions.
+
+```protobuf
+syntax = "proto3";
+
+import "asp/api/annotations.proto";
+import "google/api/annotations.proto";
+import "google/protobuf/empty.proto";
+
+package people;
+
+message Person {
+ int32 id = 1;
+ string first_name = 2;
+ string middle_name = 4 [(asp.api.version) = "2.0"];
+ string last_name = 3;
+ string email = 5 [(asp.api.version) = "2.0"];
+ string phone = 6 [(asp.api.version) = "3.0"];
+}
+
+message PersonRequest {
+ int32 id = 1;
+ Person person = 2;
+}
+
+message PeopleReply {
+ Person person = 1;
+ repeated Person people = 2;
+}
+
+service People {
+ // GET /people?api-version=[1.0,2.0,3.0]
+ rpc GetPeople (google.protobuf.Empty) returns (PeopleReply) {
+ option (google.api.http) = {
+ get: "/people"
+ response_body: "people"
+ };
+ }
+
+ // GET /people/{id}?api-version=[1.0,2.0,3.0]
+ rpc GetPerson (PeopleRequest) returns (PeopleReply) {
+ option (google.api.http) = {
+ get: "/people/{id}"
+ response_body: "person"
+ };
+ }
+
+ // POST /people?api-version=[1.0,2.0,3.0]
+ rpc AddPerson (PersonRequest) returns (PeopleReply) {
+ option (google.api.http) = {
+ post: "/people"
+ body: "person"
+ response_body: "person"
+ };
+ }
+}
+```
+
+## Validation
+
+A transcoded message will appear to clients as though the field does not exist. This is pure obfuscation. The field
+does exist, but is omitted. There is nothing to prevent a client from sending a field that exists, but does not apply
+to an API version. A service author can choose to ignore the field according to the requested API version or the
+service can return an error if the client sends fields that are unexpected.
+
+[interval-notation]: ../how-to/versioned-models.md#notation
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/define-service-version.md b/wiki/src/aspnet-core/how-to/define-service-version.md
new file mode 100644
index 000000000..f0479fffd
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/define-service-version.md
@@ -0,0 +1 @@
+{{#include ../../shared/how-to/define-service-version.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/deprecate-version.md b/wiki/src/aspnet-core/how-to/deprecate-version.md
new file mode 100644
index 000000000..af3cede08
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/deprecate-version.md
@@ -0,0 +1,66 @@
+{{#include ../../shared/how-to/deprecate-version-pre.md}}
+
+This example demonstrates API versioning using all non-URL segment methods.
+
+### Minimal API
+
+```c#
+var api = app.NewVersionedApi();
+var hello = api.MapGroup( "/api/helloworld" )
+ .HasDeprecatedApiVersion( 1.0 )
+ .HasApiVersion( 2.0 );
+
+hello.MapGet( "/", () => "Hello world!" );
+hello.MapGet( "/", () => "Hello world v2.0!" ).MapToApiVersion( 2.0 );
+```
+
+### Mvc (Core)
+
+```c#
+[ApiController]
+[ApiVersion( 2.0 )]
+[ApiVersion( 1.0, Deprecated = true )]
+[Route( "api/[controller]" )]
+public class HelloWorldController : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world!"
+
+ [HttpGet, MapToApiVersion( 2.0 )]
+ public string GetV2() => "Hello world v2.0!";
+}
+```
+
+
+This example demonstrates API versioning using the URL segment method.
+
+### Minimal API
+
+```c#
+var api = app.NewVersionedApi();
+var hello = api.MapGroup( "/api/v{version:apiVersion}/helloworld" )
+ .HasDeprecatedApiVersion( 1.0 )
+ .HasApiVersion( 2.0 );
+
+hello.MapGet( "/", () => "Hello world!" );
+hello.MapGet( "/", () => "Hello world v2.0!" ).MapToApiVersion( 2.0 );
+```
+
+### MVC (Core)
+
+```c#
+[ApiController]
+[ApiVersion( 2.0 )]
+[ApiVersion( 1.0, Deprecated = true )]
+[Route( "api/v{version:apiVersion}/[controller]" )]
+public class HelloWorldController : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world!"
+
+ [HttpGet, MapToApiVersion( 2.0 )]
+ public string GetV2() => "Hello world v2.0!";
+}
+```
+
+{{#include ../../shared/how-to/deprecate-version-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/existing-services.md b/wiki/src/aspnet-core/how-to/existing-services.md
new file mode 100644
index 000000000..5246d8fa6
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/existing-services.md
@@ -0,0 +1,18 @@
+{{#include ../../shared/how-to/existing-services-pre.md}}
+
+```c#
+services.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true );
+```
+
+{{#include ../../shared/how-to/existing-services-mid.md}}
+
+```c#
+services.AddApiVersioning(
+ options =>
+ {
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.DefaultApiVersion = new ApiVersion( new DateOnly( 2016, 7, 1 ) );
+ } );
+```
+
+{{#include ../../shared/how-to/existing-services-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/naming-conventions.md b/wiki/src/aspnet-core/how-to/naming-conventions.md
new file mode 100644
index 000000000..8530f2dd2
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/naming-conventions.md
@@ -0,0 +1,119 @@
+{{#include ../../shared/how-to/naming-conventions-pre.md}}
+
+```c#
+namespace My.Services.V1
+{
+ [ApiVersion( 1.0 )]
+ [Route( "[controller]" )]
+ public class HelloWorldController : ControllerBase
+ {
+ [HttpGet]
+ public string Get() => "Hello world v1.0!";
+ }
+}
+
+namespace My.Services.V2
+{
+ [ApiVersion( 2.0 )]
+ [Route( "[controller]" )]
+ public class HelloWorldController : ControllerBase
+ {
+ [HttpGet]
+ public string Get() => "Hello world v2.0!";
+ }
+}
+```
+>_Controllers separated by .NET namespace_
+
+```c#
+namespace My.Services.Controllers
+{
+ [ApiVersion( 1.0 )]
+ [Route( "[controller]" )]
+ public class HelloWorldController : ControllerBase
+ {
+ [HttpGet]
+ public string Get() => "Hello world v1.0!";
+ }
+
+ [ApiVersion( 2.0 )]
+ [Route( "helloworld" )]
+ public class HelloWorld2Controller : ControllerBase
+ {
+ [HttpGet]
+ public string Get() => "Hello world v2.0!";
+ }
+}
+```
+>_Controllers with different names in the same .NET namespace_
+
+{{#include ../../shared/how-to/naming-conventions-post.md}}
+
+### Attribute
+
+If you do not want to rely on a convention, you can explicitly provide a name using the `ControllerNameAttribute`. The
+name provided will be used verbatim for the `[controller]` token, the controller name, and for grouping. This attribute
+is particularly useful with OData because the name of the controller must also exactly match the name of the associated
+entity set.
+
+```c#
+[ApiVersion( 2.0 )]
+[ControllerName( "HelloWorld" )]
+[Route( "[controller]" )]
+public class HelloWorld2Controller : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world v2.0!";
+}
+```
+
+## API Controllers
+
+A controller is just a controller in ASP.NET Core; there is no distinction between a _UI Controller_ and an _API
+Controller_. Some applications mix UI controllers and API controllers together. This will result in all controllers
+requiring an API version, which is undesirable for UI controllers. The advent of the `ApiControllerAttribute` made it
+possible to disambiguate the two types of controllers.
+
+API Versioning 3.0 introduced two new interfaces:
+
+```c#
+interface IApiControllerFilter
+{
+ IList Apply( IList controllers );
+}
+
+interface IApiControllerSpecification
+{
+ bool IsSatisifedBy( ControllerModel controller );
+}
+```
+
+The `IApiControllerFilter` filters which controllers should be considered API controllers. The default implementation
+typically does not need to be replaced. The `IApiControllerSpecification` defines a specification as to whether a
+particular controller is an API controller.
+
+There are two built-in specifications:
+
+- `ApiBehaviorSpecification` - matches controllers decorated by `[ApiController]`
+- `ODataControllerSpecification` - matches controllers decorated by `[ODataRouting]`
+
+ An _API controller_ will be considered any controller that matches at least one specification. If a built-in
+ specification does not meet your specific needs, you can create your own:
+
+```c#
+// considers controllers inheriting from Controller to be a UI controller
+public class NonUIControllerSpecification : IApiControllerSpecification
+{
+ private readonly Type UIControllerType = typeof( Controller ).GetTypeInfo();
+
+ public bool IsSatisfiedBy( ControllerModel controller ) =>
+ !UIControllerType.IsAssignableFrom( controller.ControllerType )
+}
+```
+
+Register your specification in the services configuration:
+
+```c#
+services.TryAddEnumerable(
+ ServiceDescriptor.Transient() );
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/overview.md b/wiki/src/aspnet-core/how-to/overview.md
new file mode 100644
index 000000000..20e7c9900
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/overview.md
@@ -0,0 +1,23 @@
+{{#include ../../shared/how-to/overview-pre.md}}
+
+### Minimal API
+
+Minimal APIs do not use controllers nor any of these conventions or attributes. The intrinsic grouping capabilities
+define collation without having to infer anything. It is, however, possible to add a logical API name to the group if
+you want to:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning();
+
+var app = builder.Build();
+var people = app.NewVersionedApi( "People" ); // ← provides optional, logical name
+
+people.MapGet( "/people", () => new[] { new Person() } ).HasApiVersion( 1.0 );
+
+app.Run();
+```
+
+{{#include ../../shared/how-to/overview-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/requested-version.md b/wiki/src/aspnet-core/how-to/requested-version.md
new file mode 100644
index 000000000..d88289308
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/requested-version.md
@@ -0,0 +1,38 @@
+{{#include ../../shared/how-to/requested-version-pre.md}}
+
+### Minimal API
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning().EnableApiVersionBinding();
+
+var app = builder.Build();
+var api = app.NewVersionedApi();
+
+api.MapGet( "/", ( ApiVersion version ) => Results.Ok() )
+ .HasApiVersion( 1.0 )
+ .HasApiVersion( 2.0 );
+
+app.Run();
+```
+
+### MVC (Core)
+
+```c#
+[ApiVersion( 1.0 )]
+[ApiVersion( 2.0 )]
+[ApiController]
+public class Controller : ControllerBase
+{
+ public IActionResult Get()
+ {
+ var apiVersion = HttpContext.RequestedApiVersion;
+ return Ok();
+ }
+
+ // supported in 3.0+
+ public IActionResult Get( int id, ApiVersion apiVersion ) => Ok();
+}
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/version-advertisement.md b/wiki/src/aspnet-core/how-to/version-advertisement.md
new file mode 100644
index 000000000..fad4f60f4
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/version-advertisement.md
@@ -0,0 +1,58 @@
+{{#include ../../shared/how-to/version-advertisement-pre.md}}
+
+```c#
+[ApiVersion( 2.0 )]
+[AdvertiseApiVersions( 1.0 )]
+[ApiController]
+[Route( "api/[controller]" )]
+public class HelloWorld2Controller : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world v2.0!" );
+}
+```
+
+```c#
+[ApiVersion( 2.0 )]
+[AdvertiseApiVersions( 1.0 )]
+[ApiController]
+[Route( "api/v{version:apiVersion}/helloworld" )]
+public class HelloWorld2Controller : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world v2.0!" );
+}
+```
+
+{{#include ../../shared/how-to/version-advertisement-post.md}}
+
+## Mixing Minimal APIs with Controllers
+
+Mixing existing controller-based APIs with Minimal APIs is a supported scenario, but the collation of API versions is
+broken by default. This is simply because there is no intrinsic way to group controllers and Minimal APIs together.
+However, by advertising API versions across implementations with the same name, the correct collation is possible.
+
+```c#
+[ApiController]
+[ApiVersion( 1.0 )]
+[AdvertiseApiVersions( 2.0 )]
+[Route( "api/[controller]" )]
+public class HelloWorld2Controller : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world v1.0!" );
+}
+```
+_Figure 1: the controller-based API in 1.0_
+
+```c#
+var hello = app.NewVersionedApi();
+
+hello.MapGet( "/api/helloworld", () => "Hello world v2.0!" )
+ .HasApiVersion( 2.0 )
+ .AdvertisesApiVersion( 1.0 );
+```
+_Figure 1: a minimal API in 2.0_
+
+When [ApiVersioningOptions.ReportApiVersions] is enabled the controller and Minimal API implementations will both return
+`api-supported-versions: 1.0, 2.0`.
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/version-by-header.md b/wiki/src/aspnet-core/how-to/version-by-header.md
new file mode 100644
index 000000000..bb127080d
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/version-by-header.md
@@ -0,0 +1,42 @@
+{{#include ../../shared/how-to/version-by-header-pre.md}}
+
+### Minimal API
+
+```c#
+var hello = app.NewVersionedApi();
+
+hello.MapGet( "/helloworld", () => "Hello world!" ).HasApiVersion( 1.0 );
+```
+
+### MVC (Core)
+
+```c#
+namespace Services.V1
+{
+ [ApiVersion( 1.0 )]
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class HelloWorldController : ControllerBase
+ {
+ [HttpGet]
+ public string Get() => "Hello world!";
+ }
+}
+
+namespace Services.V2
+{
+ [ApiVersion( 2.0 )]
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class HelloWorldController : ControllerBase
+ {
+ [HttpGet]
+ public string Get() => "Hello world!";
+
+ [HttpPost]
+ public string Post( string text ) => text;
+ }
+}
+```
+
+{{#include ../../shared/how-to/version-by-header-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/version-by-media-type.md b/wiki/src/aspnet-core/how-to/version-by-media-type.md
new file mode 100644
index 000000000..542c00e3d
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/version-by-media-type.md
@@ -0,0 +1,55 @@
+{{#include ../../shared/how-to/version-by-media-type-pre.md}}
+
+### Minimal API
+
+```c#
+var hello = app.NewVersionedApi();
+var v1 = hello.MapGroup( "/helloworld" ).HasApiVersion( 1.0 );
+var v2 = hello.MapGroup( "/helloworld" ).HasApiVersion( 2.0 );
+
+v1.MapGet( "/", () => "Hello world!" );
+v2.MapGet( "/", () => "Hello world!" );
+v2.MapPost( "/", (string text) => text );
+```
+
+### MVC (Core)
+
+```c#
+namespace Services.V1
+{
+ [ApiVersion( 1.0 )]
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class HelloWorldController : ControllerBase
+ {
+ [HttpGet]
+ public string Get() => "Hello world!";
+ }
+}
+
+namespace Services.V2
+{
+ [ApiVersion( 2.0 )]
+ [ApiController]
+ [Route( "api/[controller]" )]
+ public class HelloWorldController : ControllerBase
+ {
+ [HttpGet]
+ public string Get() => "Hello world!";
+
+ [HttpPost]
+ public string Post( string text ) => text;
+ }
+}
+```
+
+{{#include ../../shared/how-to/version-by-media-type-post.md}}
+
+The specific issues include:
+
+- Mapping
+ - `IInputFormatter` to the custom media type
+ - `IOutputFormatter` to the custom media type
+- OpenAPI
+ - Listing all of the consumes media types
+ - Listing all of the produces media types
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/version-by-query-string.md b/wiki/src/aspnet-core/how-to/version-by-query-string.md
new file mode 100644
index 000000000..5a9649140
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/version-by-query-string.md
@@ -0,0 +1,76 @@
+{{#include ../../shared/how-to/version-by-query-string-pre.md}}
+
+### Minimal API
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning();
+
+var app = builder.Build();
+var hello = app.NewVersionedApi();
+var v1 = hello.MapGroup( "/helloworld" ).HasApiVersion( 1.0 );
+var v2 = hello.MapGroup( "/helloworld" ).HasApiVersion( 2.0 );
+
+v1.MapGet( "/", () => "Hello world!" );
+v2.MapGet( "/", () => "Hello world!" );
+
+app.Run();
+```
+
+### MVC (Core)
+
+```c#
+[ApiController]
+[Route( "api/[controller]" )]
+public class HelloWorldController : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world!";
+}
+```
+
+### OData
+
+```c#
+public class PeopleController : ODataController
+{
+ [HttpGet]
+ public IHttpActionResult Get( ODataQueryOptions options ) =>
+ Ok( new[]{ new Person() } );
+}
+```
+
+### Next Version
+
+To create the next version of the controller, you can choose to create a new controller with the same route but
+decorate it as API version `2.0`. For example:
+
+#### MVC (Core)
+
+```c#
+[ApiVersion( 2.0 )]
+[ApiController]
+[Route( "api/helloworld" )]
+public class HelloWorld2Controller : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world!";
+}
+```
+
+#### OData
+
+```c#
+[ApiVersion( 2.0 )]
+[ControllerName( "People" )]
+public class People2Controller : ODataController
+{
+ [HttpGet]
+ public IHttpActionResult Get( ODataQueryOptions options ) =>
+ Ok( new[]{ new Person() } );
+}
+```
+
+{{#include ../../shared/how-to/version-by-query-string-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/version-by-url.md b/wiki/src/aspnet-core/how-to/version-by-url.md
new file mode 100644
index 000000000..19a26d908
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/version-by-url.md
@@ -0,0 +1,92 @@
+{{#include ../../shared/how-to/version-by-url-pre.md}}
+
+### Minimal API
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning();
+
+var app = builder.Build();
+var people = app.NewVersionedApi();
+var v1 = people.MapGroup( "/people/v{version:apiVersion}" ).HasApiVersion( 1.0 );
+
+v1.MapGet( "/", () => new[] { new Person() } );
+
+app.Run();
+```
+
+### MVC (Core)
+
+```c#
+[ApiVersion( 1.0 )]
+[ApiController]
+[Route( "api/v{version:apiVersion}/[controller]" )]
+public class HelloWorldController : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world!";
+}
+
+[ApiVersion( 2.0 )]
+[ApiVersion( 3.0 )]
+[ApiController]
+[Route( "api/v{version:apiVersion}/helloworld" )]
+public class HelloWorld2Controller : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world v2!";
+
+ [HttpGet, MapToApiVersion( 3.0 )]
+ public string GetV3() => "Hello world v3!";
+}
+```
+
+### OData
+
+```c#
+[ApiVersion( 1.0 )]
+public class PeopleController : ODataController
+{
+ [EnableQuery]
+ public IQueryable Get() => new[]{ new Person() }.AsQueryable();
+}
+
+[ApiVersion( 2.0 )]
+[ApiVersion( 3.0 )]
+[ControllerName( "People" )]
+public class People2Controller : ODataController
+{
+ [EnableQuery]
+ [ODataRoute]
+ public IQueryable Get() => new[]{ new Person() }.AsQueryable();
+
+ [EnableQuery]
+ [ODataRoute, MapToApiVersion( 3.0 )]
+ public IQueryable GetV3() => new[]{ new Person() }.AsQueryable();
+}
+```
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers().AddOData();
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning().AddOData(
+ options =>
+ {
+ options.ModelBuilder.DefaultModelConfiguration = ( builder, apiVersion, routePrefix ) =>
+ {
+ builder.EntitySet( "People" );
+ };
+ options.AddRouteComponents( "api/v{version:apiVersion}" );
+ } );
+
+var app = builder.Build();
+
+app.MapControllers();
+app.Run();
+```
+
+{{#include ../../shared/how-to/version-by-url-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/version-interleaving.md b/wiki/src/aspnet-core/how-to/version-interleaving.md
new file mode 100644
index 000000000..479d6c129
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/version-interleaving.md
@@ -0,0 +1,75 @@
+{{#include ../../shared/how-to/version-interleaving-pre.md}}
+
+### Minimal API
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning();
+
+var app = builder.Build();
+var hello = app.NewVersionedApi();
+var v1 = hello.MapGroup( "/helloworld" ).HasApiVersion( 1.0 );
+var v2_v3 = hello.MapGroup( "/helloworld" )
+ .HasApiVersion( 2.0 )
+ .HasApiVersion( 3.0 );
+
+v1.MapGet( "/", () => "Hello world v1.0!" );
+v2_v3.MapGet( "/", () => "Hello world v2.0!" ).MapToApiVersion( 2.0 );
+v2_v3.MapGet( "/", () => "Hello world v3.0!" ).MapToApiVersion( 3.0 );
+
+app.Run();
+```
+
+### MVC (Core)
+
+```c#
+[ApiVersion( 1.0 )]
+[ApiController]
+[Route( "api/[controller]" )]
+public class HelloWorldController : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world v1.0!";
+}
+
+[ApiVersion( 2.0 )]
+[ApiVersion( 3.0 )]
+[ApiController]
+[Route( "api/helloworld" )]
+public class HelloWorld2Controller : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world v2.0!";
+
+ [HttpGet, MapToApiVersion( 3.0 )]
+ public string GetV3() => "Hello world v3.0!";
+}
+```
+
+### OData
+
+```c#
+[ApiVersion( 1.0 )]
+public class PeopleController : ODataController
+{
+ public IActionResult Get( ODataQueryOptions options ) =>
+ Ok( new[]{ new Person() } );
+}
+
+[ApiVersion( 2.0 )]
+[ApiVersion( 3.0 )]
+[ControllerName( "People" )]
+public class People2Controller : ODataController
+{
+ public IActionResult Get( ODataQueryOptions options ) =>
+ Ok( new[]{ new Person() } );
+
+ [MapToApiVersion( 3.0 )]
+ public IActionResult GetV3( ODataQueryOptions options ) =>
+ Ok( new[]{ new Person() } );
+}
+```
+
+{{#include ../../shared/how-to/version-interleaving-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/version-neutral.md b/wiki/src/aspnet-core/how-to/version-neutral.md
new file mode 100644
index 000000000..44c135957
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/version-neutral.md
@@ -0,0 +1,45 @@
+{{#include ../../shared/how-to/version-neutral-pre.md}}
+
+### Minimal API
+
+```c#
+var hello = app.NewVersionedApi();
+
+hello.MapGet( "/api/health/ping", () => Results.Ok() ).IsApiVersionNeutral();
+```
+
+### MVC (Core)
+
+```c#
+[ApiVersionNeutral]
+[ApiController]
+[Route( "api/[controller]/[action]" )]
+public class HealthController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Ping() => Ok();
+}
+```
+
+{{#include ../../shared/how-to/version-neutral-post.md}}
+
+### Minimal API
+
+```c#
+var hello = app.NewVersionedApi();
+
+hello.MapGet( "/api/v{version:apiVersion}/health/ping", () => Results.Ok() ).IsApiVersionNeutral();
+```
+
+### MVC (Core)
+
+```c#
+[ApiVersionNeutral]
+[ApiController]
+[Route( "api/v{version:apiVersion}/[controller]/[action]" )]
+public class HealthController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Ping() => Ok();
+}
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/how-to/versioned-models.md b/wiki/src/aspnet-core/how-to/versioned-models.md
new file mode 100644
index 000000000..2789ec3c3
--- /dev/null
+++ b/wiki/src/aspnet-core/how-to/versioned-models.md
@@ -0,0 +1,92 @@
+# Versioned Models
+
+When an API is versioned, it is often necessary to version the models that are used in the API. This is especially true
+when shared models are used in request and response messages.
+
+[Asp.Versioning.Abstractions] provides the `[VisibleInApiVersion]` attribute to indicate which API versions a model is
+visible in. When no attribute is applied, the model is visible in all APIs. The abstractions library does not have any
+dependency on ASP.NET and carries no additional dependencies. Any intended use case is referencing abstractions in
+libraries that provide version-specific metadata.
+
+Consider the following model:
+
+```c#
+public class Person
+{
+ public int Id { get; set; }
+
+ public string FirstName { get; set; }
+
+ [VisibleInApiVersion( "2.0" )]
+ public string MiddleName { get; set; }
+
+ public string LastName { get; set; }
+
+ [VisibleInApiVersion( "2.0" )]
+ public string Email { get; set; }
+
+ [VisibleInApiVersion( "3.0" )]
+ public string Phone { get; set; }
+}
+```
+
+The `Person` model indicates that:
+
+- `Id`, `FirstName`, and `LastName` are visible in all API versions
+- `MiddleName` and `Email` are only visible starting in API version `2.0`
+- `Phone` is only visible starting in API version `3.0`
+
+Each value passed to the `[VisibleInApiVersion]` attribute is a range expression representing a rule set. The rule is
+parsed into a range that determines if the annotated member applies to an API version. Annotations never define any
+API versions.
+
+In rare cases, you might need a split range. The `[VisibleInApiVersion]` attribute supports multiple entries; for example:
+
+```c#
+public class ExperimentalSettings
+{
+ [VisibleInApiVersion( "[,2.0)", "(2.0,]" )]
+ public bool IsEnabled { get; set; }
+}
+```
+
+These rules would express that `IsEnabled` is included in every API version **except** `2.0`.
+
+## Notation
+
+The interval notation for version ranges is as follows:
+
+| Notation | Applied Rule | Description |
+| --------- | ------------- | ----------------------------------------------------- |
+| 1.0 | x ≥ 1.0 | Minimum version, inclusive |
+| [1.0,) | x ≥ 1.0 | Minimum version, inclusive |
+| (1.0,) | x > 1.0 | Minimum version, exclusive |
+| [1.0] | x == 1.0 | Exact version match |
+| (,1.0] | x ≤ 1.0 | Maximum version, inclusive |
+| (,1.0) | x < 1.0 | Maximum version, exclusive |
+| [1.0,2.0] | 1.0 ≤ x ≤ 2.0 | Exact range, inclusive |
+| (1.0,2.0) | 1.0 < x < 2.0 | Exact range, exclusive |
+| [1.0,2.0) | 1.0 ≤ x < 2.0 | Mixed inclusive minimum and exclusive maximum version |
+| (1.0) | invalid | invalid |
+
+[Asp.Versioning.Abstractions]: https://nuget.org/packages/Asp.Versioning.Abstractions
+
+## Validation
+
+>[!IMPORTANT]
+>This feature is currently only available for JSON content.
+
+When a versioned API receives a request the deserialization process will enforce that a client did not _over-post_
+more data than is allowed for the requested API version, even if the backing model defines the corresponding property.
+If a client attempts to post data that is not visible in the requested API version, the request will be rejected with
+HTTP status code `400` (Bad Request). The response body will indicate which properties were not visible in the
+requested API version. This is the same behavior as if the property did not exist on the model at all.
+
+## API Explorer
+
+The API Explorer will look for and respect annotations; specifically, `IAnnotation`. The explored
+API descriptions will only include models and properties that are visible in the API version being explored.
+
+The OpenAPI extensions will leverage this information to generate version-specific OpenAPI documents with constrained
+model properties. A client will not be able to tell whether you used a single model behind the scenes or many. From
+their perspective, each model will appear to be unique with its own affinity the API version that defined it.
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/limitations.md b/wiki/src/aspnet-core/limitations.md
new file mode 100644
index 000000000..26c2911a0
--- /dev/null
+++ b/wiki/src/aspnet-core/limitations.md
@@ -0,0 +1,43 @@
+# Known Limitations
+
+## URL Path Segment
+
+API versioning does not fundamentally change how routing works in ASP.NET. When you elect to support API versioning via
+a URL path segment, the API version is part of the path considered in routing. There is currently no built-in method to
+match a route where the API version URL path segment has not be specified.
+
+The recommended method to enable this scenario is to use _Double Route Registration_ by providing multiple routes for
+the corresponding controller actions as follows:
+
+```c#
+[ApiVersion( 1.0 )]
+[ApiController]
+[Route( "api/[controller]" )]
+[Route( "api/v{version:apiVersion}/[controller]" )]
+public class ValuesController : ControllerBase
+{
+ // ~/api/values
+ // ~/api/v1/values
+ [HttpGet]
+ public IHttpActionResult Get() => Ok();
+}
+
+[ApiVersion( 2.0 )]
+[ApiController]
+[Route( "api/v{version:apiVersion}/values" )]
+public class Values2Controller : ControllerBase
+{
+ // ~/api/v2/values
+ [HttpGet]
+ public IHttpActionResult Get() => Ok();
+}
+```
+
+### Alternative
+
+You can use middleware or other customizations to simplify your implementation. The [Gist] provides one such
+implementation that allows URL versioning to external clients, but allows simplified URL mapping internally. For
+example, `api/v1/values` becomes `api/values` internally, captures the `1.0` API version, and sets the requested API
+version via the `IApiVersioningFeature`.
+
+[Gist]: https://gist.github.com/fernando-almeida/2b1f59e5f7f99a2f31d95471b895f625
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/odata/batching.md b/wiki/src/aspnet-core/odata/batching.md
new file mode 100644
index 000000000..35ad7fe96
--- /dev/null
+++ b/wiki/src/aspnet-core/odata/batching.md
@@ -0,0 +1,27 @@
+# Batching
+
+OData batch operations are meant to execute the same way that other requests do; however, there may be some minor, but
+crucial differences required in the setup configuration depending on your target platform.
+
+OData batch operations are facilitated by the OData batching middleware in ASP.NET Core. The built-in OData middleware
+only allows a single `ODataBatchHandler` and cannot be extended. In theory, there should only be a single
+`ODataBatchHandler` for the entire application, but there is no guarantee that is what a developer has done or wants.
+API Versioning, therefore, provides alternate OData batch middleware that allows a version-specific `ODataBatchHandler`
+if that is what you have configured. Additionally, API Versioning always registers a default `ODataBatchHandler` in
+`AddRouteComponents` so you don't have to, which is something OData does **not** do by default.
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers().AddOData();
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning()
+ .AddOData( options => options.AddRouteComponents( "api" ) );
+
+var app = builder.Build();
+
+app.UseVersionedODataBatching();
+app.UseRouting()
+app.UseEndpoints( endpoints => endpoints.MapControllers() );
+app.Run();
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/odata/controllers.md b/wiki/src/aspnet-core/odata/controllers.md
new file mode 100644
index 000000000..7ecd626e4
--- /dev/null
+++ b/wiki/src/aspnet-core/odata/controllers.md
@@ -0,0 +1 @@
+{{#include ../../shared/odata/controllers.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/odata/metadata.md b/wiki/src/aspnet-core/odata/metadata.md
new file mode 100644
index 000000000..e5662e486
--- /dev/null
+++ b/wiki/src/aspnet-core/odata/metadata.md
@@ -0,0 +1 @@
+{{#include ../../shared/odata/metadata.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/odata/model-builder.md b/wiki/src/aspnet-core/odata/model-builder.md
new file mode 100644
index 000000000..819f21fd2
--- /dev/null
+++ b/wiki/src/aspnet-core/odata/model-builder.md
@@ -0,0 +1,8 @@
+{{#include ../../shared/odata/model-builder-pre.md}}
+
+>[!NOTE]
+>`IModelConfiguration` instances are automatically discovered through _Dependency Injection_ when you declare
+>`IEnumerable` or `VersionedODataModelBuilder` as a dependent parameter. The
+>`ModelConfigurations` property can be modified after injection, if required.
+
+{{#include ../../shared/odata/model-builder-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/odata/model-config.md b/wiki/src/aspnet-core/odata/model-config.md
new file mode 100644
index 000000000..0bc265ffe
--- /dev/null
+++ b/wiki/src/aspnet-core/odata/model-config.md
@@ -0,0 +1,11 @@
+{{#include ../../shared/odata/model-config.md}}
+
+## Dependency Injection
+
+Dependency injection (DI) is a first-class concept in ASP.NET Core. This intrinsic capability enables API versioning to
+automatically register all discovered implementations of **IModelConfiguration**. API versioning also registers a
+single, but replaceable mapping for `VersionedODataModelBuilder`. This enables you to declare
+`VersionedODataModelBuilder` as a dependent parameter wherever you would like ASP.NET Core to inject the configured
+instance. The injected instance will always have all of the discovered `IModelConfiguration` instances, but you can
+continue to modify the builder until you are ready to create all of the EDMs via
+`VersionedODataModelBuilder.GetEdmModels()`.
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/odata/model-substitution.md b/wiki/src/aspnet-core/odata/model-substitution.md
new file mode 100644
index 000000000..b98dc00c9
--- /dev/null
+++ b/wiki/src/aspnet-core/odata/model-substitution.md
@@ -0,0 +1 @@
+{{#include ../../shared/odata/model-substitution.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/odata/overview.md b/wiki/src/aspnet-core/odata/overview.md
new file mode 100644
index 000000000..05b8d4e47
--- /dev/null
+++ b/wiki/src/aspnet-core/odata/overview.md
@@ -0,0 +1,29 @@
+{{#include ../../shared/odata/overview-pre.md}}
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers().AddOData();
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning()
+ .AddOData( options => options.AddRouteComponents( "api" ) );
+
+var app = builder.Build();
+
+app.MapControllers();
+app.Run();
+ ```
+
+It is possible to imperatively use:
+
+```c#
+.AddOData( options => options.ModelConfigurations.Add( new PersonModelConfiguration() ) )
+```
+
+however, it is typically unnecessary because this will automatically happen via dependency injection.
+
+>[!IMPORTANT]
+>Calling `AddControllers().AddOData( options => options.AddRouteComponents( ... ) )` will be completely ignored by API
+>Versioning. Due to the OData design, it is impossible to extend or customize this behavior. Instead, you need to use
+>`AddApiVersioning().AddOData( options => options.AddRouteComponents( ... ) )`. The standard `AddOData` configuration
+>can still be used to configure global query option settings.
diff --git a/wiki/src/aspnet-core/quick-starts/existing-services.md b/wiki/src/aspnet-core/quick-starts/existing-services.md
new file mode 100644
index 000000000..f93bb745b
--- /dev/null
+++ b/wiki/src/aspnet-core/quick-starts/existing-services.md
@@ -0,0 +1,109 @@
+{{#include ../../shared/quick-starts/existing-services.md}}
+
+### Minimal API
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddProblemDetails();
+
+// allow a client to call you without specifying an api version
+// since we haven't configured it otherwise, the assumed api version will be 1.0
+builder.Services.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true );
+
+var app = builder.Build();
+var people = app.NewVersionedApi();
+var v1 = people.MapGroup( "/people" ).HasApiVersion( 1.0 );
+var v2 = people.MapGroup( "/people" ).HasApiVersion( 2.0 );
+
+v1.MapGet( "/", () => new[] { new Person() } );
+v2.MapGet( "/", () => new[] { new Person() } );
+
+app.Run();
+```
+
+### MVC (Core)
+
+```c#
+[ApiVersion( 1.0 )] // ← this attribute isn't required, but it's easier to understand
+[ApiController]
+[Route( "[controller]" )]
+public class PeopleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok( new[] { new Person() } );
+}
+
+[ApiVersion( 2.0 )]
+[ApiController]
+[Route( "People" )]
+public class People2Controller : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok( new[] { new Person() } );
+}
+```
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers();
+builder.Services.AddProblemDetails();
+
+// allow a client to call you without specifying an api version
+// since we haven't configured it otherwise, the assumed api version will be 1.0
+builder.Services.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true )
+ .AddMvc();
+
+var app = builder.Build();
+
+app.MapController();
+app.Run();
+```
+
+### OData
+
+```c#
+[ApiVersion( 1.0 )] // ← this attribute isn't required, but it's easier to understand
+public class PeopleController : ODataController
+{
+ // GET ~/people
+ // GET ~/people?api-version=1.0
+ [EnableQuery]
+ public IActionResult Get() => Ok( new[] { new Person() } );
+}
+
+[ApiVersion( 2.0 )]
+[ControllerName( "People" )]
+public class People2Controller : ODataController
+{
+ // GET ~/people?api-version=2.0
+ [EnableQuery]
+ public IActionResult Get() => Ok( new[] { new Person() } );
+}
+```
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers().AddOData();
+builder.Services.AddProblemDetails();
+
+// allow a client to call you without specifying an api version
+// since we haven't configured it otherwise, the assumed api version will be 1.0
+builder.Services
+ .AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true )
+ .AddOData( options =>
+ {
+ options.ModelBuilder.DefaultModelConfiguration = ( builder, apiVersion, routePrefix ) =>
+ {
+ builder.EntitySet( "People" );
+ };
+ options.AddRouteComponents();
+ } );
+
+var app = builder.Build();
+
+app.MapController();
+app.Run();
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/quick-starts/migration.md b/wiki/src/aspnet-core/quick-starts/migration.md
new file mode 100644
index 000000000..2ad8b1ef9
--- /dev/null
+++ b/wiki/src/aspnet-core/quick-starts/migration.md
@@ -0,0 +1,112 @@
+{{#include ../../shared/quick-starts/migration-overview.md}}
+
+## Package Identifiers
+
+The original `Microsoft.*` packages are now deprecated and will only undergo servicing:
+
+| Package | Version | TFM |
+| ---------------------------------------------- | -------- | --------------------- |
+| Microsoft.AspNetCore.Mvc.Versioning | <= 5.x.x | netcoreapp3.1, net5.0 |
+| Microsoft.AspNetCore.Mvc.ApiExplorer | <= 5.x.x | netcoreapp3.1, net5.0 |
+| Microsoft.AspNetCore.OData | <= 5.x.x | netcoreapp3.1, net5.0 |
+| Microsoft.AspNetCore.OData.ApiExplorer | <= 5.x.x | netcoreapp3.1, net5.0 |
+
+All new features and platform support will use the `Asp.Versioning.*` prefix:
+
+| Package | Version | TFM |
+| ------------------------------------------ | ------- | --------------------------------------- |
+| Asp.Versioning.Abstractions | 6.0.0+ | net6.0+, netstandard1.0, netstandard2.0 |
+| Asp.Versioning.Http1 | 6.0.0+ | net6.0+ |
+| Asp.Versioning.Mvc2 | 6.0.0+ | net6.0+ |
+| Asp.Versioning.Mvc.ApiExplorer3 | 6.0.0+ | net6.0+ |
+| Asp.Versioning.OData | 6.0.0+ | net6.0+ |
+| Asp.Versioning.OData.ApiExplorer | 6.0.0+ | net6.0+ |
+
+[1] Base library that supports Minimal APIs
+[2] MVC Core with controller support
+[3] Supports exploration of Minimal APIs and controllers
+
+{{#include ../../shared/quick-starts/migration-common.md}}
+
+## API Behaviors
+
+In versions `>= 2.1.0 && < 6.0.0`, the `ApiVersioningOptions` provided the property `UseApiBehavior`. This setting was a
+bridge to the API Behaviors feature introduced in ASP.NET Core 2.1. In earlier versions of ASP.NET Core, there was not a
+clear way to disambiguate between a UI and API controller. Adding API Behaviors via `[ApiController]` to a controller or
+assembly provided a way to solve that problem. API Versioning subsequently added two new services that align to it:
+
+- `IApiControllerFilter` - filters out non-API controllers
+- `IApiControllerSpecification` - determines whether a controller is for an API
+
+The default filter is an aggregation over all specifications. The default specifications look for API Behaviors and
+OData routing.
+
+In the `2.1.x` time frame, this was a behavioral breaking change. To facilitate a smoother transition, the
+`UseApiBehavior` option was introduced with a value of `false`, which maintained the existing behavior. Starting in
+`3.0`, the value defaulted to `true`, which only considers controllers with API Behaviors applied. Starting in `6.0`,
+the property has been completely removed as it is no longer necessary.
+
+`IApiControllerFilter` and any of the `IApiControllerSpecification` services can be modified through dependency
+injection. To align with the legacy behavior of `UseApiBehavior = false`, you can use the `NoControllerFilter`
+implementation:
+
+```c#
+builder.Services.AddTransient();
+builder.Services.AddApiVersioning().AddMvc();
+```
+
+## Routing Behaviors
+
+The legacy, convention-based routing with `IActionSelector` has been dropped. Limitations in the original ASP.NET Core
+routing design caused a number of issues and inconsistencies, which were resolved when Endpoint Routing was introduced;
+especially `405` or `415` responses. The primary reason it continued to be supported was waiting for OData to support
+Endpoint Routing, which it does as of `8.0`.
+
+The routing logic has been updated to properly return a response for `404`, `405`, `406`, and `415`. Due to necessary
+API Versioning fixes and the way routing works in ASP.NET Core, it is no longer possible to always report `400` when an
+API version _could_ be matched, but doesn't. In some of these cases it is also not possible to add `ProblemDetails`;
+especially prior to .NET 7 because ASP.NET Core did not provide a hook for it.
+
+What happens when an API version _could_ match, but doesn't has always been a bit of a gray area. The general consensus
+seems to be that developers don't care because it's a client error or they expect it to be `404`. These default rule
+will continue to return `400` when versioning by query string or header, but that can now be changed via
+[ApiVersioningOptions.UnsupportedApiVersionStatusCode]. Versioning by URL segment will always return `404`. Versioning
+by media type will always return `406` or `415`.
+
+[ApiVersioningOptions.UnsupportedApiVersionStatusCode]: https://github.com/dotnet/aspnet-api-versioning/wiki/API-Versioning-Options#unsupported-api-version-status-code
+
+The `UseApiVersioning()` middleware in ASP.NET Core has been removed. It never did anything except setup the
+`IApiVersioningFeature` in the current request, which doesn't require middleware.
+
+## Configuration
+
+Support for Minimal APIs and OData in ASP.NET Core required some changes to how services are configured in an
+application. The new `IApiVersioningBuilder` interface provides a way to hang all API Versioning related extensions off
+of. This approach also helps address extension method naming conflicts and scenarios where you might forget to register
+another set of required services. If you referenced and enabled everything supported by API Versioning, then your
+configuration _might_ look like:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+var services = builder.Services;
+
+services.AddApiVersioning() // Core services with support for Minimal APIs
+ .AddMvc() // MVC Core with controllers (not full MVC)
+ .AddApiExplorer() // API version-aware API Explorer extensions
+ .AddOData() // API versioning extensions for OData
+ .AddODataApiExplorer(); // API version-aware API Explorer extensions for OData
+```
+
+### Changes
+
+- As noted above, `ApiVersioningOptions.UseApiBehaviors` has been removed
+- `ApiVersioningOptions.Conventions` has been moved to `MvcApiVersioningOptions.Conventions` as API Versioning no longer requires MVC Core
+ - To configure conventions, use `.AddMvc(options => options.Conventions = ?)` via the `IApiVersioningBuilder` extension method
+- `ApiVersioningOptions.ControllerNameConvention` has been removed as an explicit option, but can be changed via dependency injection
+ - To configure a different naming convention, use `builder.Services.AddSingleton()`
+
+[RFC 7807]: https://datatracker.ietf.org/doc/html/rfc7807
+[Microsoft REST Guidelines error response format]: https://github.com/Microsoft/api-guidelines/blob/master/Guidelines.md#710-response-formats
+[OData JSON Format §21.1]: https://docs.oasis-open.org/odata/odata-json-format/v4.01/odata-json-format-v4.01.html#_Toc38457793
+[Error Response backward compatibility]: https://github.com/dotnet/aspnet-api-versioning/wiki/Error-Responses#Backward-Compatibility
+[Error Responses]: https://github.com/dotnet/aspnet-api-versioning/wiki/Error-Responses
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/quick-starts/new-services.md b/wiki/src/aspnet-core/quick-starts/new-services.md
new file mode 100644
index 000000000..6867a6093
--- /dev/null
+++ b/wiki/src/aspnet-core/quick-starts/new-services.md
@@ -0,0 +1,75 @@
+{{#include ../../shared/quick-starts/new-services.md}}
+
+### Minimal API
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning();
+
+var app = builder.Build();
+var people = app.NewVersionedApi();
+
+people.MapGet( "/people", () => new[] { new Person() } ).HasApiVersion( 1.0 );
+
+app.Run();
+```
+
+### MVC (Core)
+
+```c#
+[ApiVersion( 1.0 )]
+[ApiController]
+[Route( "[controller]" )]
+public class PeopleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok( new[] { new Person() } );
+}
+```
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers();
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning().AddMvc();
+
+var app = builder.Build();
+
+app.MapControllers();
+app.Run();
+```
+
+### OData
+
+```c#
+[ApiVersion( 1.0 )]
+public class PeopleController : ODataController
+{
+ [EnableQuery]
+ public IActionResult Get() => Ok( new[] { new Person() } );
+}
+```
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers().AddOData();
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning().AddOData(
+ options =>
+ {
+ options.ModelBuilder.DefaultModelConfiguration = ( builder, apiVersion, routePrefix ) =>
+ {
+ builder.EntitySet( "People" );
+ };
+ options.AddRouteComponents();
+ } );
+
+var app = builder.Build();
+
+app.MapControllers();
+app.Run();
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/version-discovery.md b/wiki/src/aspnet-core/version-discovery.md
new file mode 100644
index 000000000..6dd544ff1
--- /dev/null
+++ b/wiki/src/aspnet-core/version-discovery.md
@@ -0,0 +1,41 @@
+
+{{#include ../shared/version-discovery.md}}
+
+### Minimal API
+
+```c#
+using static Microsoft.AspNetCore.Http.HttpMethods;
+
+// OPTIONS ~/api/myservice?api-version=[1.0|2.0|3.0]
+app.MapMethods("/api/myservice", [Options], ( HttpContext context ) =>
+{
+ context.Response.Headers.Allow = new( [Get, Post, Options] );
+ return Results.Ok();
+});
+```
+
+```http
+HTTP/2 200
+allow: GET, POST, OPTIONS
+api-supported-versions: 1.0, 2.0, 3.0
+```
+
+### MVC (Core)
+
+```c#
+using static Microsoft.AspNetCore.Http.HttpMethods;
+
+// OPTIONS ~/api/myservice?api-version=[1.0|2.0|3.0]
+[HttpOptions]
+public IActionResult Options()
+{
+ Response.Headers.Allow = new( [Get, Post, Options] );
+ return Ok();
+}
+```
+
+```http
+HTTP/2 200
+allow: GET, POST, OPTIONS
+api-supported-versions: 1.0, 2.0, 3.0
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/version-format.md b/wiki/src/aspnet-core/version-format.md
new file mode 100644
index 000000000..31deaa1ca
--- /dev/null
+++ b/wiki/src/aspnet-core/version-format.md
@@ -0,0 +1 @@
+{{#include ../shared/version-format.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet-core/version-policies.md b/wiki/src/aspnet-core/version-policies.md
new file mode 100644
index 000000000..109af93a5
--- /dev/null
+++ b/wiki/src/aspnet-core/version-policies.md
@@ -0,0 +1,6 @@
+
+{{#include ../shared/version-policies.md}}
+
+The [Asp.Versioning.OpenApi] package will document these policies in OpenAPI when they are present.
+
+[Asp.Versioning.OpenApi]: https://www.nuget.org/packages/Asp.Versioning.OpenApi
\ No newline at end of file
diff --git a/wiki/src/aspnet/config/conventions.md b/wiki/src/aspnet/config/conventions.md
new file mode 100644
index 000000000..447e46eb3
--- /dev/null
+++ b/wiki/src/aspnet/config/conventions.md
@@ -0,0 +1,59 @@
+{{#include ../../shared/config/conventions-pre.md}}
+
+```c#
+configuration.AddApiVersioning( options =>
+{
+ options.Conventions.Controller().HasApiVersion( 1.0 );
+} );
+```
+
+All of the semantics that can be expressed with .NET attributes can be defined using conventions. Consider what version
+`2.0` of the previous controller with interleaved API versions might look like:
+
+```c#
+[RoutePrefix( "my" )]
+public class MyController : ApiController
+{
+ [Route]
+ public IHttpActionResult Get() => Ok();
+
+ [Route]
+ public IHttpActionResult GetV2() => Ok();
+
+ [Route( "{id:int}" )]
+ public IHttpActionResult GetV2( int id ) => Ok();
+}
+```
+
+The API version conventions might then be defined as:
+
+```c#
+options.Conventions.Controller()
+ .HasDeprecatedApiVersion( 1.0 )
+ .HasApiVersion( 2.0 )
+ .Action( c => c.GetV2() ).MapToApiVersion( 2.0 )
+ .Action( c => c.GetV2( default ) ).MapToApiVersion( 2.0 );
+```
+
+If you use API version conventions and .NET attributes, then the constructed `ApiVersionModel` for the corresponding
+controller will be an aggregated union of the two sets of information.
+
+## Custom
+
+You can also define custom conventions via the `IControllerConvention` interface and add them to the builder:
+
+```c#
+public interface IControllerConvention
+{
+ bool Apply( IControllerConventionBuilder controller,
+ HttpControllerDescriptor controllerDescriptor );
+}
+```
+
+Custom conventions are added to the convention builder through the API versioning options:
+
+```c#
+options.Conventions.Add( new MyCustomConvention() );
+```
+
+{{#include ../../shared/config/conventions-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/config/options.md b/wiki/src/aspnet/config/options.md
new file mode 100644
index 000000000..7dacf4699
--- /dev/null
+++ b/wiki/src/aspnet/config/options.md
@@ -0,0 +1 @@
+{{#include ../../shared/config/options.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/config/overview.md b/wiki/src/aspnet/config/overview.md
new file mode 100644
index 000000000..bb58eb856
--- /dev/null
+++ b/wiki/src/aspnet/config/overview.md
@@ -0,0 +1,47 @@
+# Configuring Your Application
+
+Although different variations of ASP.NET have distinct application initialization methods, careful consideration was
+taken to make the API versioning configuration as similar as possible across all applications models.
+
+The configuration for ASP.NET Web API applications typically occurs in the `Register` method of the **WebApiConfig.cs**
+file. To enable API versioning support with the default options, use the following configuration:
+
+```c#
+public static void Register( HttpConfiguration configuration )
+{
+ configuration.AddApiVersioning();
+
+ // remaining web api setup omitted for brevity
+}
+```
+
+If you intend to use the [URL segment versioning] method, then you also need to register the appropriate route
+constraint:
+
+```c#
+public static void Register( HttpConfiguration configuration )
+{
+ var constraintResolver = new DefaultInlineConstraintResolver()
+ {
+ ConstraintMap =
+ {
+ ["apiVersion"] = typeof( ApiVersionRouteConstraint ),
+ },
+ };
+ configuration.MapHttpAttributeRoutes( constraintResolver );
+ configuration.AddApiVersioning();
+
+ // remaining setup omitted for brevity
+}
+```
+
+Custom route constraints can only be configured through the `MapHttpAttributeRoutes` method. This method is only
+expected to be called once in an application. Since API versioning may be added to an existing application, you must
+explicitly add the route constraint to ensure the current configuration does not break.
+
+This is also the same basic setup for OData applications, except that you do not need to add any route constraints or
+map attribute routes. OData uses its own route constraints and convention-based routing. For more information, see the
+topic on [API versioning with OData].
+
+[URL segment versioning]: ../how-to/version-by-url.md
+[API versioning with OData]: ../odata/overview.md
\ No newline at end of file
diff --git a/wiki/src/aspnet/config/reader.md b/wiki/src/aspnet/config/reader.md
new file mode 100644
index 000000000..38d23c8e7
--- /dev/null
+++ b/wiki/src/aspnet/config/reader.md
@@ -0,0 +1 @@
+{{#include ../../shared/config/reader.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/config/selector.md b/wiki/src/aspnet/config/selector.md
new file mode 100644
index 000000000..cfc5e1f2a
--- /dev/null
+++ b/wiki/src/aspnet/config/selector.md
@@ -0,0 +1 @@
+{{#include ../../shared/config/selector.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/docs/odata-options.md b/wiki/src/aspnet/docs/odata-options.md
new file mode 100644
index 000000000..d31600eef
--- /dev/null
+++ b/wiki/src/aspnet/docs/odata-options.md
@@ -0,0 +1,173 @@
+{{#include ../../shared/docs/odata-options-pre.md}}
+- [UseApiExplorerSettings](#use-api-explorer-settings)1
+
+### Use API Explorer Settings
+
+OData controllers are not explored by default. The API explorer for OData services does not initially honor this
+setting so that OData APIs will be discovered. You might decide, however, to use the API explorer settings to explicitly
+define which OData services should be explored. You must set this property to a value of `true` in order for the API
+explorer to respect API explorer settings.
+
+{{#include ../../shared/docs/odata-options-post.md}}
+
+{{#include ../../shared/docs/odata-options-query.md}}
+
+{{#include ../../shared/docs/odata-options-attributes.md}}
+
+```c#
+using Asp.Versioning;
+using Asp.Versioning.OData;
+using Microsoft.AspNet.OData;
+using Microsoft.AspNet.OData.Routing;
+using Microsoft.Web.Http;
+using System.Web.Http;
+using System.Web.Http.Description;
+using static Microsoft.AspNet.OData.Query.AllowedQueryOptions;
+using static System.Net.HttpStatusCode;
+using static System.DateTime;
+
+[ApiVersion( 1.0 )]
+[ODataRoutePrefix( "Orders" )]
+public class OrdersController : ODataController
+{
+ [ODataRoute]
+ [Produces( "application/json" )]
+ [ProducesResponseType( typeof( ODataValue> ), Status200OK )]
+ [EnableQuery( MaxTop = 100, AllowedQueryOptions = Select | Top | Skip | Count )]
+ public IQueryable Get()
+ {
+ var orders = new[]
+ {
+ new Order(){ Id = 1, Customer = "John Doe" },
+ new Order(){ Id = 2, Customer = "John Doe" },
+ new Order(){ Id = 3, Customer = "Jane Doe", EffectiveDate = UtcNow.AddDays( 7d ) }
+ };
+
+ return orders.AsQueryable();
+ }
+
+ [ODataRoute( "{key}" )]
+ [Produces( "application/json" )]
+ [ProducesResponseType( typeof( Order ), Status200OK )]
+ [ProducesResponseType( Status404NotFound )]
+ [EnableQuery( AllowedQueryOptions = Select )]
+ public SingleResult Get( int key )
+ {
+ var orders = new[] { new Order(){ Id = key, Customer = "John Doe" } };
+ return SingleResult.Create( orders.AsQueryable() );
+ }
+}
+```
+
+{{#include ../../shared/docs/odata-options-model-bound.md}}
+
+```c#
+using Asp.Versioning;
+using Asp.Versioning.OData;
+using Microsoft.AspNet.OData;
+using Microsoft.AspNet.OData.Routing;
+using Microsoft.Web.Http;
+using System.Web.Http;
+using System.Web.Http.Description;
+using static Microsoft.AspNet.OData.Query.AllowedQueryOptions;
+using static System.Net.HttpStatusCode;
+using static System.DateTime;
+
+public class PeopleController : ODataController
+{
+ [HttpGet]
+ [ResponseType( typeof( ODataValue> ) )]
+ public IHttpActionResult Get( ODataQueryOptions options )
+ {
+ var validationSettings = new ODataValidationSettings()
+ {
+ AllowedQueryOptions = Select | OrderBy | Top | Skip | Count,
+ AllowedOrderByProperties = { "firstName", "lastName" },
+ AllowedArithmeticOperators = AllowedArithmeticOperators.None,
+ AllowedFunctions = AllowedFunctions.None,
+ AllowedLogicalOperators = AllowedLogicalOperators.None,
+ MaxOrderByNodeCount = 2,
+ MaxTop = 100,
+ };
+
+ try
+ {
+ options.Validate( validationSettings );
+ }
+ catch ( ODataException )
+ {
+ return BadRequest();
+ }
+
+ var people = new[]
+ {
+ new Person()
+ {
+ Id = 1,
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john.doe@somewhere.com",
+ Phone = "555-987-1234",
+ },
+ new Person()
+ {
+ Id = 2,
+ FirstName = "Bob",
+ LastName = "Smith",
+ Email = "bob.smith@somewhere.com",
+ Phone = "555-654-4321",
+ },
+ new Person()
+ {
+ Id = 3,
+ FirstName = "Jane",
+ LastName = "Doe",
+ Email = "jane.doe@somewhere.com",
+ Phone = "555-789-3456",
+ }
+ };
+
+ return this.Success( options.ApplyTo( people.AsQueryable() ) );
+ }
+
+ [HttpGet]
+ [ResponseType( typeof( Person ) )]
+ public IHttpActionResult Get( int key, ODataQueryOptions options )
+ {
+ var people = new[]
+ {
+ new Person()
+ {
+ Id = key,
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john.doe@somewhere.com",
+ Phone = "555-987-1234",
+ }
+ };
+
+ var query = options.ApplyTo( people.AsQueryable();
+ return this.SuccessOrNotFound( query ).SingleOrDefault() );
+ }
+}
+```
+
+{{#include ../../shared/docs/odata-options-mid.md}}
+
+{{#include ../../shared/docs/odata-options-partial-pre.md}}
+
+```c#
+[ApiVersion( 1.0 )]
+[ApiController]
+[Route( "[controller]" )]
+public class BooksController : ControllerBase
+{
+ [HttpGet]
+ [Produces( "application/json" )]
+ [ProducesResponseType( typeof( IEnumerable ), 200 )]
+ public IActionResult Get( ODataQueryOptions options ) =>
+ Ok( options.ApplyTo( books.AsQueryable() ) );
+}
+```
+
+{{#include ../../shared/docs/odata-options-partial-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/docs/options.md b/wiki/src/aspnet/docs/options.md
new file mode 100644
index 000000000..67d7b7b00
--- /dev/null
+++ b/wiki/src/aspnet/docs/options.md
@@ -0,0 +1,3 @@
+{{#include ../../shared/docs/options-pre.md}}
+
+{{#include ../../shared/docs/odata-options-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/docs/overview.md b/wiki/src/aspnet/docs/overview.md
new file mode 100644
index 000000000..71babd13b
--- /dev/null
+++ b/wiki/src/aspnet/docs/overview.md
@@ -0,0 +1,85 @@
+{{#include ../../shared/docs/overview-pre.md}}
+
+Any OpenAPI generator such as [Swashbuckle][openapi-swashbuckle], or [NSwag][openapi-nswag] that leverage the API
+Explorer can be used.
+
+## Web API
+
+[](https://www.nuget.org/packages/Asp.Versioning.WebApi)
+
+Everything you need to add versioned documentation to your API controllers using [API Explorer extensions](https://www.nuget.org/packages/Asp.Versioning.WebApi.ApiExplorer) with [Swashbuckle][openapi-swashbuckle-old].
+
+```c#
+config.AddApiVersioning();
+
+// (optional) format the version as "'v'major[.minor][-status]"
+var apiExplorer = config.AddVersionedApiExplorer( o => o.GroupNameFormat = "'v'VVV" );
+
+config.EnableSwagger(
+ "{apiVersion}/swagger",
+ swagger =>
+ {
+ swagger.MultipleApiVersions(
+ ( apiDescription, version ) => apiDescription.GetGroupName() == version,
+ info =>
+ {
+ foreach ( var group in apiExplorer.ApiDescriptions )
+ {
+ info.Version( group.Name, $"Example API {group.ApiVersion}" );
+ }
+ } );
+ } )
+ .EnableSwaggerUi( swagger => swagger.EnableDiscoveryUrlSelector() );
+```
+
+Review the [example](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNet/WebApi/OpenApiWebApiExample) project for additional setup and configuration options.
+
+## OData
+
+[](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData)
+
+Everything you need to add versioned documentation to your OData controllers using the
+[OData API Explorer extensions][explorer-odata] with [Swashbuckle][openapi-swashbuckle].
+
+```c#
+configuration.AddApiVersioning();
+
+var modelBuilder = new VersionedODataModelBuilder( configuration )
+{
+ ModelConfigurations = { new MyModelConfiguration() }
+};
+
+configuration.MapVersionedODataRoutes( "odata", "api", modelBuilder );
+
+// (optional) format the version as "'v'major[.minor][-status]"
+var apiExplorer = configuration.AddODataApiExplorer( o => o.GroupNameFormat = "'v'VVV" );
+
+configuration.EnableSwagger(
+ "{apiVersion}/swagger",
+ swagger =>
+ {
+ swagger.MultipleApiVersions(
+ ( apiDescription, version ) => apiDescription.GetGroupName() == version,
+ info =>
+ {
+ foreach ( var group in apiExplorer.ApiDescriptions )
+ {
+ info.Version( group.Name, $"Example API {group.ApiVersion}" );
+ }
+ } );
+ } )
+ .EnableSwaggerUi( swagger => swagger.EnableDiscoveryUrlSelector() );
+```
+
+Review the following example projects for additional setup and configuration options:
+
+- [OData OpenAPI Example](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNet/OData/OpenApiODataWebApiExample)
+- [Partial OData OpenAPI Example](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNet/OData/SomeOpenApiODataWebApiExample)
+
+>[!NOTE]
+>This API explorer does not directly tie into [Swashbuckle with OData](https://github.com/rbeauchamp/Swashbuckle.OData)
+>because that project also prescribes how API versioning is performed, which is incompatible with this project.
+
+[openapi-swashbuckle]: https://github.com/domaindrivendev/Swashbuckle.WebApi
+[openapi-nswag]: https://github.com/RicoSuter/NSwag
+[explorer-odata]: https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer
\ No newline at end of file
diff --git a/wiki/src/aspnet/docs/swashbuckle.md b/wiki/src/aspnet/docs/swashbuckle.md
new file mode 100644
index 000000000..8da22e5b8
--- /dev/null
+++ b/wiki/src/aspnet/docs/swashbuckle.md
@@ -0,0 +1,64 @@
+{{#include ../../shared/docs/swashbuckle-pre.md}}
+
+Remember to add the necessary references to one or both of the following:
+
+- [API Explorer Extensions for ASP.NET Web API](https://www.nuget.org/packages/Asp.Versioning.WebApi.ApiExplorer)
+- [API Explorer Extensions for ASP.NET Web API with OData](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData.ApiExplorer)
+
+```c#
+public class SwaggerDefaultValues : IOperationFilter
+{
+ public void Apply(
+ Operation operation,
+ SchemaRegistry schemaRegistry,
+ ApiDescription apiDescription )
+ {
+ operation.deprecated |= apiDescription.IsDeprecated();
+
+ if ( operation.parameters == null )
+ {
+ return;
+ }
+
+ foreach ( var parameter in operation.parameters )
+ {
+ var description = apiDescription.ParameterDescriptions
+ .First( p => p.Name == parameter.name );
+
+ parameter.description ??= description.Documentation;
+ parameter.@default ??= description.ParameterDescriptor?.DefaultValue;
+ }
+ }
+}
+```
+
+Use `MultipleApiVersions` to iterate over each `ApiDescription` and collate them by their corresponding group. The
+default group name for each `ApiDescription` is the formatted API version that is associated with it.
+
+```c#
+configuration.EnableSwagger(
+ "{apiVersion}/swagger",
+ swagger =>
+ {
+ swagger.MultipleApiVersions(
+ ( apiDescription, version ) => apiDescription.GetGroupName() == version,
+ info =>
+ {
+ foreach ( var group in apiExplorer.ApiDescriptions )
+ {
+ info.Version( group.Name, $"Example API {group.ApiVersion}" )
+ .Description( "An example API" );
+ }
+ } );
+ swagger.OperationFilter();
+ } )
+ .EnableSwaggerUi( swagger => swagger.EnableDiscoveryUrlSelector() );
+```
+
+### Examples
+
+There are end-to-end examples using API versioning and Swashbuckle:
+
+- [API Versioning and Swashbuckle](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNet/WebApi/OpenApiWebApiExample)
+- [OData, API Versioning, and Swashbuckle](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNet/OData/OpenApiODataWebApiExample)
+- [Partial OData, API Versioning, and Swashbuckle](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNet/OData/SomeOpenApiODataWebApiExample)
\ No newline at end of file
diff --git a/wiki/src/aspnet/errors.md b/wiki/src/aspnet/errors.md
new file mode 100644
index 000000000..31f831260
--- /dev/null
+++ b/wiki/src/aspnet/errors.md
@@ -0,0 +1,24 @@
+{{#include ../shared/errors-pre.md}}
+
+## Customization
+
+Error responses can be customized or extended in a variety of ways. RFC 7807 was ratified after active development on
+ASP.NET Web API ceased. There are no out-of-the-box services provided. API Versioning provides a backport of the
+`ProblemDetails` type as well as the `IProblemDetailsFactory`. The default implementation can be replaced by
+implementing `IProblemDetailsFactory` and exposing it as a resolvable service via `HttpConfiguration.DependencyResolver`.
+
+## Backward Compatibility
+
+While it is possible to customize error responses and retain the previous **Error Object** format, there is
+considerable work required to enable this behavior and may block adoption of new library versions. Additional extensions
+have been added to retain backward compatibility or continue to use **Error Objects** if you so desire.
+
+ASP.NET Web API does not provide an out-of-the-box dependency injection container; however, the following extension
+method will wire up the necessary changes without having to add one of your own.
+
+```c#
+configuration.ConvertProblemDetailsToErrorObject();
+```
+
+>[!NOTE]
+>Applies to 7.1.0+
\ No newline at end of file
diff --git a/wiki/src/aspnet/examples.md b/wiki/src/aspnet/examples.md
new file mode 100644
index 000000000..54c1e6d93
--- /dev/null
+++ b/wiki/src/aspnet/examples.md
@@ -0,0 +1,12 @@
+# Examples
+
+Complete, runnable sample projects live in the [examples] folder of the repository.
+
+- [Web API](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNet/WebApi)
+ - Includes controllers with Web API
+ - Includes OpenAPI/Swagger
+- [OData](https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNet/OData)
+ - Includes controllers with Web API and OData
+ - Includes OpenAPI/Swagger
+
+[examples]: https://github.com/dotnet/aspnet-api-versioning/tree/main/examples
\ No newline at end of file
diff --git a/wiki/src/aspnet/ext/clients.md b/wiki/src/aspnet/ext/clients.md
new file mode 100644
index 000000000..5960a704e
--- /dev/null
+++ b/wiki/src/aspnet/ext/clients.md
@@ -0,0 +1 @@
+{{#include ../../shared/ext/clients.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/ext/custom-attributes.md b/wiki/src/aspnet/ext/custom-attributes.md
new file mode 100644
index 000000000..167d34924
--- /dev/null
+++ b/wiki/src/aspnet/ext/custom-attributes.md
@@ -0,0 +1,13 @@
+{{#include ../../shared/ext/custom-attributes-pre.md}}
+
+```
+[V1]
+[RoutePrefix( "api/helloworld" )]
+public class HelloWorldController : ApiController
+{
+ [Route]
+ public string Get() => "Hello world!";
+}
+```
+
+{{#include ../../shared/ext/custom-attributes-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/ext/custom-format.md b/wiki/src/aspnet/ext/custom-format.md
new file mode 100644
index 000000000..fee96ef79
--- /dev/null
+++ b/wiki/src/aspnet/ext/custom-format.md
@@ -0,0 +1 @@
+{{#include ../../shared/ext/custom-format.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/faq.md b/wiki/src/aspnet/faq.md
new file mode 100644
index 000000000..e008042c7
--- /dev/null
+++ b/wiki/src/aspnet/faq.md
@@ -0,0 +1 @@
+{{#include ../shared/faq.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/define-service-version.md b/wiki/src/aspnet/how-to/define-service-version.md
new file mode 100644
index 000000000..238be3933
--- /dev/null
+++ b/wiki/src/aspnet/how-to/define-service-version.md
@@ -0,0 +1,2 @@
+
+{{#include ../../shared/how-to/define-service-version.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/deprecate-version.md b/wiki/src/aspnet/how-to/deprecate-version.md
new file mode 100644
index 000000000..883f76247
--- /dev/null
+++ b/wiki/src/aspnet/how-to/deprecate-version.md
@@ -0,0 +1,35 @@
+{{#include ../../shared/how-to/deprecate-version-pre.md}}
+
+This example demonstrates API versioning using all non-URL segment methods.
+
+```c#
+[ApiVersion( 2.0 )]
+[ApiVersion( 1.0, Deprecated = true )]
+[RoutePrefix( "api/helloworld" )]
+public class HelloWorldController : ApiController
+{
+ [Route]
+ public string Get() => "Hello world!"
+
+ [Route, MapToApiVersion( 2.0 )]
+ public string GetV2() => "Hello world v2.0!";
+}
+```
+
+This example demonstrates API versioning using the URL segment method.
+
+```c#
+[ApiVersion( 2.0 )]
+[ApiVersion( 1.0, Deprecated = true )]
+[RoutePrefix( "api/v{version:apiVersion}/helloworld" )]
+public class HelloWorldController : ApiController
+{
+ [Route]
+ public string Get() => "Hello world!"
+
+ [Route, MapToApiVersion( 2.0 )]
+ public string GetV2() => "Hello world v2.0!";
+}
+```
+
+{{#include ../../shared/how-to/deprecate-version-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/existing-services.md b/wiki/src/aspnet/how-to/existing-services.md
new file mode 100644
index 000000000..3fc692759
--- /dev/null
+++ b/wiki/src/aspnet/how-to/existing-services.md
@@ -0,0 +1,18 @@
+{{#include ../../shared/how-to/existing-services-pre.md}}
+
+```c#
+config.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true );
+```
+
+{{#include ../../shared/how-to/existing-services-mid.md}}
+
+```c#
+configuration.AddApiVersioning(
+ options =>
+ {
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.DefaultApiVersion = new ApiVersion( new DateTime( 2016, 7, 1 ) );
+ } );
+```
+
+{{#include ../../shared/how-to/existing-services-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/naming-conventions.md b/wiki/src/aspnet/how-to/naming-conventions.md
new file mode 100644
index 000000000..9236b4cbc
--- /dev/null
+++ b/wiki/src/aspnet/how-to/naming-conventions.md
@@ -0,0 +1,67 @@
+{{#include ../../shared/how-to/naming-conventions-pre.md}}
+
+```c#
+namespace My.Services.V1
+{
+ [ApiVersion( 1.0 )]
+ [RoutePrefix( "helloworld" )]
+ public class HelloWorldController : ApiController
+ {
+ [Route]
+ public string Get() => "Hello world v1.0!";
+ }
+}
+
+namespace My.Services.V2
+{
+ [ApiVersion( 2.0 )]
+ [RoutePrefix( "helloworld" )]
+ public class HelloWorldController : ApiController
+ {
+ [Route]
+ public string Get() => "Hello world v2.0!";
+ }
+}
+```
+>_Controllers separated by .NET namespace_
+
+```c#
+namespace My.Services.Controllers
+{
+ [ApiVersion( 1.0 )]
+ [RoutePrefix( "helloworld" )]
+ public class HelloWorldController : ApiController
+ {
+ [Route]
+ public string Get() => "Hello world v1.0!";
+ }
+
+ [ApiVersion( 2.0 )]
+ [RoutePrefix( "helloworld" )]
+ public class HelloWorld2Controller : ApiController
+ {
+ [Route]
+ public string Get() => "Hello world v2.0!";
+ }
+}
+```
+>_Controllers with different names in the same .NET namespace_
+
+{{#include ../../shared/how-to/naming-conventions-post.md}}
+
+### Attribute
+
+If you do not want to rely on a convention, you can explicitly provide a name using the `ControllerNameAttribute`. This
+attribute is particularly useful with OData because the name of the controller must also exactly match the name of the
+associated entity set.
+
+```c#
+[ApiVersion( 2.0 )]
+[RoutePrefix( "helloworld" )]
+[ControllerName( "HelloWorld" )]
+public class HelloWorld2Controller : ControllerBase
+{
+ [Route]
+ public string Get() => "Hello world v2.0!";
+}
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/overview.md b/wiki/src/aspnet/how-to/overview.md
new file mode 100644
index 000000000..e1febded3
--- /dev/null
+++ b/wiki/src/aspnet/how-to/overview.md
@@ -0,0 +1,7 @@
+{{#include ../../shared/how-to/overview-pre.md}}
+
+>[!IMPORTANT]
+>Due to limitations in the routing infrastructure in ASP.NET Web API, API versioning is not guaranteed to work for
+>controllers that define both attribute and convention-based routes for the same route.
+
+{{#include ../../shared/how-to/overview-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/requested-version.md b/wiki/src/aspnet/how-to/requested-version.md
new file mode 100644
index 000000000..522302db2
--- /dev/null
+++ b/wiki/src/aspnet/how-to/requested-version.md
@@ -0,0 +1,19 @@
+{{#include ../../shared/how-to/requested-version-pre.md}}
+
+### Web API
+
+```c#
+[ApiVersion( 1.0 )]
+[ApiVersion( 2.0 )]
+public class MyController : ApiController
+{
+ public IHttpActionResult Get()
+ {
+ var apiVersion = Request.RequestedApiVersion;
+ return Ok();
+ }
+
+ // supported in 3.0+
+ public IHttpActionResult Get( int id, ApiVersion apiVersion ) => Ok();
+}
+```
diff --git a/wiki/src/aspnet/how-to/version-advertisement.md b/wiki/src/aspnet/how-to/version-advertisement.md
new file mode 100644
index 000000000..cb5bb1310
--- /dev/null
+++ b/wiki/src/aspnet/how-to/version-advertisement.md
@@ -0,0 +1,25 @@
+{{#include ../../shared/how-to/version-advertisement-pre.md}}
+
+```c#
+[ApiVersion( 2.0 )]
+[AdvertiseApiVersions( 1.0 )]
+[Route( "api/helloworld" )]
+public class HelloWorld2Controller : ApiController
+{
+ [HttpGet]
+ public string Get() => "Hello world v2.0!" );
+}
+```
+
+```c#
+[ApiVersion( 2.0 )]
+[AdvertiseApiVersions( 1.0 )]
+[Route( "api/v{version:apiVersion}/helloworld" )]
+public class HelloWorld2Controller : ControllerBase
+{
+ [HttpGet]
+ public string Get() => "Hello world v2.0!" );
+}
+```
+
+{{#include ../../shared/how-to/version-advertisement-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/version-by-header.md b/wiki/src/aspnet/how-to/version-by-header.md
new file mode 100644
index 000000000..9af32b000
--- /dev/null
+++ b/wiki/src/aspnet/how-to/version-by-header.md
@@ -0,0 +1,32 @@
+{{#include ../../shared/how-to/version-by-header-pre.md}}
+
+### Web API
+
+```c#
+namespace Services.V1
+{
+ [ApiVersion( 1.0 )]
+ [RoutePrefix( "api/helloworld" )]
+ public class HelloWorldController : ApiController
+ {
+ [Route]
+ public string Get() => "Hello world!";
+ }
+}
+
+namespace Services.V2
+{
+ [ApiVersion( 2.0 )]
+ [RoutePrefix( "api/helloworld" )]
+ public class HelloWorldController : ApiController
+ {
+ [Route]
+ public string Get() => "Hello world!";
+
+ [Route]
+ public string Post( string text ) => text;
+ }
+}
+```
+
+{{#include ../../shared/how-to/version-by-header-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/version-by-media-type.md b/wiki/src/aspnet/how-to/version-by-media-type.md
new file mode 100644
index 000000000..d110927a5
--- /dev/null
+++ b/wiki/src/aspnet/how-to/version-by-media-type.md
@@ -0,0 +1,41 @@
+{{#include ../../shared/how-to/version-by-media-type-pre.md}}
+
+### Web API
+
+```c#
+namespace Services.V1
+{
+ [ApiVersion( 1.0 )]
+ [RoutePrefix( "api/helloworld" )]
+ public class HelloWorldController : ApiController
+ {
+ [Route]
+ public string Get() => "Hello world!";
+ }
+}
+
+namespace Services.V2
+{
+ [ApiVersion( 2.0 )]
+ [RoutePrefix( "api/helloworld" )]
+ public class HelloWorldController : ApiController
+ {
+ [Route]
+ public string Get() => "Hello world!";
+
+ [Route]
+ public string Post( string text ) => text;
+ }
+}
+```
+
+{{#include ../../shared/how-to/version-by-media-type-post.md}}
+
+The specific issues include:
+
+- Mapping
+ - `MediaTypeFormatter` to the custom media type
+ - `MediaTypeFormatter` to the custom media type
+- OpenAPI
+ - Listing all of the consumes media types
+ - Listing all of the produces media types
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/version-by-query-string.md b/wiki/src/aspnet/how-to/version-by-query-string.md
new file mode 100644
index 000000000..920d705d0
--- /dev/null
+++ b/wiki/src/aspnet/how-to/version-by-query-string.md
@@ -0,0 +1,57 @@
+{{#include ../../shared/how-to/version-by-query-string-pre.md}}
+
+### Web API
+
+```c#
+[RoutePrefix( "api/helloworld" )]
+public class HelloWorldController : ApiController
+{
+ [Route]
+ public string Get() => "Hello world!";
+}
+```
+
+### OData
+
+```c#
+[ODataRoutePrefix( "People" )]
+public class PeopleController : ODataController
+{
+ [ODataRoute]
+ public IHttpActionResult Get( ODataQueryOptions options ) =>
+ Ok( new[]{ new Person() } );
+}
+```
+
+### Next Version
+
+To create the next version of the controller, you can choose to create a new controller with the same route but
+decorate it as API version `2.0`. For example:
+
+#### Web API
+
+```c#
+[ApiVersion( 2.0 )]
+[RoutePrefix( "api/helloworld" )]
+public class HelloWorldController : ApiController
+{
+ [Route]
+ public string Get() => "Hello world!";
+}
+```
+
+#### OData
+
+```c#
+[ApiVersion( 2.0 )]
+[ControllerName( "People" )]
+[ODataRoutePrefix( "People" )]
+public class People2Controller : ODataController
+{
+ [ODataRoute]
+ public IHttpActionResult Get( ODataQueryOptions options ) =>
+ Ok( new[]{ new Person() } );
+}
+```
+
+{{#include ../../shared/how-to/version-by-query-string-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/version-by-url.md b/wiki/src/aspnet/how-to/version-by-url.md
new file mode 100644
index 000000000..1318a02f8
--- /dev/null
+++ b/wiki/src/aspnet/how-to/version-by-url.md
@@ -0,0 +1,94 @@
+{{#include ../../shared/how-to/version-by-url-pre.md}}
+
+### Web API
+
+```c#
+public static class WebApiConfig
+{
+ public static void Configuration( HttpConfiguration configuration )
+ {
+ var constraintResolver = new DefaultInlineConstraintResolver()
+ {
+ ConstraintMap =
+ {
+ ["apiVersion"] = typeof( ApiVersionRouteConstraint )
+ }
+ };
+ configuration.MapHttpAttributeRoutes( constraintResolver );
+ configuration.AddApiVersioning();
+ }
+}
+```
+
+```c#
+[ApiVersion( 1.0 )]
+[Route( "api/v{version:apiVersion}/helloworld" )]
+public class HelloWorldController : ApiController
+{
+ public string Get() => "Hello world!";
+}
+
+[ApiVersion( 2.0 )]
+[ApiVersion( 3.0 )]
+[Route( "api/v{version:apiVersion}/helloworld" )]
+public class HelloWorld2Controller : ApiController
+{
+ public string Get() => "Hello world v2!";
+
+ [MapToApiVersion( 3.0 )]
+ public string GetV3() => "Hello world v3!";
+}
+```
+
+### OData
+
+Since the OData implementation uses convention-based routes under the hood, the `ApiVersionRouteConstraint` is
+automatically added to all versioned OData routes when needed. The name of the constraint used in prefixes of OData
+routes must be `apiVersion` and cannot be changed.
+
+```c#
+public static class WebApiConfig
+{
+ public static void Configuration( HttpConfiguration configuration )
+ {
+ var modelBuilder = new VersionedODataModelBuilder( configuration )
+ {
+ ModelConfigurations =
+ {
+ new PersonModelConfiguration()
+ }
+ };
+
+ configuration.AddApiVersioning();
+ configuration.MapVersionedODataRoutes( "odata-bypath", "api/v{apiVersion}", modelBuilder );
+ }
+}
+```
+
+```c#
+[ApiVersion( 1.0 )]
+[ODataRoutePrefix( "People" )]
+public class PeopleController : ODataController
+{
+ [EnableQuery]
+ [ODataRoute]
+ public IQueryable Get() => new[]{ new Person() }.AsQueryable();
+}
+
+[ApiVersion( 2.0 )]
+[ApiVersion( 3.0 )]
+[ControllerName( "People" )]
+[ODataRoutePrefix( "People" )]
+public class People2Controller : ODataController
+{
+ [EnableQuery]
+ [ODataRoute]
+ public IQueryable Get() => new[]{ new Person() }.AsQueryable();
+
+ [EnableQuery]
+ [ODataRoute, MapToApiVersion( 3.0 )]
+ public IQueryable GetV3() => new[]{ new Person() }.AsQueryable();
+}
+```
+
+{{#include ../../shared/how-to/version-by-url-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/version-interleaving.md b/wiki/src/aspnet/how-to/version-interleaving.md
new file mode 100644
index 000000000..d13825ee4
--- /dev/null
+++ b/wiki/src/aspnet/how-to/version-interleaving.md
@@ -0,0 +1,55 @@
+{{#include ../../shared/how-to/version-interleaving-pre.md}}
+
+### Web API
+
+```c#
+[ApiVersion( 1.0 )]
+[RoutePrefix( "api/helloworld" )]
+public class HelloWorldController : ApiController
+{
+ [Route]
+ public string Get() => "Hello world v1.0!";
+}
+
+[ApiVersion( 2.0 )]
+[ApiVersion( 3.0 )]
+[RoutePrefix( "api/helloworld" )]
+public class HelloWorld2Controller : ApiController
+{
+ [Route]
+ public string Get() => "Hello world v2.0!";
+
+ [Route, MapToApiVersion( 3.0 )]
+ public string GetV3() => "Hello world v3.0!";
+}
+```
+
+### OData
+
+```c#
+[ApiVersion( 1.0 )]
+[ODataRoutePrefix( "People" )]
+public class PeopleController : ODataController
+{
+ [ODataRoute]
+ public IHttpActionResult Get( ODataQueryOptions options ) =>
+ Ok( new[]{ new Person() } );
+}
+
+[ApiVersion( 2.0 )]
+[ApiVersion( 3.0 )]
+[ControllerName( "People" )]
+[ODataRoutePrefix( "People" )]
+public class People2Controller : ODataController
+{
+ [ODataRoute]
+ public IHttpActionResult Get( ODataQueryOptions options ) =>
+ Ok( new[]{ new Person() } );
+
+ [ODataRoute, MapToApiVersion( 3.0 )]
+ public IHttpActionResult GetV3( ODataQueryOptions options ) =>
+ Ok( new[]{ new Person() } );
+}
+```
+
+{{#include ../../shared/how-to/version-interleaving-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/how-to/version-neutral.md b/wiki/src/aspnet/how-to/version-neutral.md
new file mode 100644
index 000000000..e8dd7457a
--- /dev/null
+++ b/wiki/src/aspnet/how-to/version-neutral.md
@@ -0,0 +1,29 @@
+{{#include ../../shared/how-to/version-neutral-pre.md}}
+
+### Web API
+
+```c#
+[ApiVersionNeutral]
+[RoutePrefix( "api/health" )]
+public class HealthController : ApiController
+{
+ [HttpGet]
+ [Route( "ping" )]
+ public IHttpActionResult Ping() => Ok();
+}
+```
+
+{{#include ../../shared/how-to/version-neutral-post.md}}
+
+### Web API
+
+```c#
+[ApiVersionNeutral]
+[RoutePrefix( "api/v{version:apiVersion}/health" )]
+public class HealthController : ApiController
+{
+ [HttpGet]
+ [Route( "ping" )]
+ public IHttpActionResult Ping() => Ok();
+}
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet/limitations.md b/wiki/src/aspnet/limitations.md
new file mode 100644
index 000000000..b2a7483f9
--- /dev/null
+++ b/wiki/src/aspnet/limitations.md
@@ -0,0 +1,56 @@
+# Known Limitations
+
+## URL Path Segment
+
+API versioning does not fundamentally change how routing works in ASP.NET. When you elect to support API versioning via
+a URL path segment, the API version is part of the path considered in routing. There is currently no built-in method to
+match a route where the API version URL path segment has not be specified.
+
+The recommended method to enable this scenario is to use _Double Route Registration_ by providing multiple routes for
+the corresponding controller actions as follows:
+
+```c#
+[ApiVersion( 1.0)]
+[RoutePrefix( "api" )]
+public class ValuesController : ApiController
+{
+ // ~/api/values
+ // ~/api/v1/values
+ [Route( "values" )]
+ [Route( "v{version:apiVersion}/values" )]
+ public IHttpActionResult Get() => Ok();
+}
+
+[ApiVersion( 2.0 )]
+[RoutePrefix( "api" )]
+public class Values2Controller : ApiController
+{
+ // ~/api/v2/values
+ [Route( "v{version:apiVersion}/values" )]
+ public IHttpActionResult Get() => Ok();
+}
+```
+
+### Alternative
+
+Y&ou can also choose to implement a custom `IDirectRouteProvider` as suggested in [Issue #73].
+
+## Routing
+
+The _Direct Route_ routing mechanism (aka attribute routing) was bolted onto the existing routing infrastructure. The
+design of the out-of-the-box router does not account for nor support overlapping routes between convention-based and
+attribute-based routes. The result of this behavior is that for each given route, all of the versioned controllers must
+use convention-based or attribute-based routes. Mixing the two routing strategies for the same route is not guaranteed
+to resolve correctly.
+
+While it would be ideal to implement a router that could support both approaches, the level of effort to achieve this
+is high. Furthermore, it's significantly easier to rationalize about versioned controllers from a service author's
+perspective if all of the versioned routes follow the same routing strategy.
+
+### OData
+
+The OData support in ASP.NET Web API uses convention-based routing under the hood. If you want to support transitioning
+to, or from, OData using API versioning, your `ApiController` types that match the same routes must also use
+convention-based routing.
+
+[Issue #73]: https://github.com/dotnet/aspnet-api-versioning/issues/73
\ No newline at end of file
diff --git a/wiki/src/aspnet/odata/controllers.md b/wiki/src/aspnet/odata/controllers.md
new file mode 100644
index 000000000..7ecd626e4
--- /dev/null
+++ b/wiki/src/aspnet/odata/controllers.md
@@ -0,0 +1 @@
+{{#include ../../shared/odata/controllers.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/odata/metadata.md b/wiki/src/aspnet/odata/metadata.md
new file mode 100644
index 000000000..e5662e486
--- /dev/null
+++ b/wiki/src/aspnet/odata/metadata.md
@@ -0,0 +1 @@
+{{#include ../../shared/odata/metadata.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/odata/model-builder.md b/wiki/src/aspnet/odata/model-builder.md
new file mode 100644
index 000000000..4d93e2656
--- /dev/null
+++ b/wiki/src/aspnet/odata/model-builder.md
@@ -0,0 +1,3 @@
+{{#include ../../shared/odata/model-builder-pre.md}}
+
+{{#include ../../shared/odata/model-builder-post.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/odata/model-config.md b/wiki/src/aspnet/odata/model-config.md
new file mode 100644
index 000000000..facd3da5f
--- /dev/null
+++ b/wiki/src/aspnet/odata/model-config.md
@@ -0,0 +1 @@
+{{#include ../../shared/odata/model-config.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/odata/model-substitution.md b/wiki/src/aspnet/odata/model-substitution.md
new file mode 100644
index 000000000..b98dc00c9
--- /dev/null
+++ b/wiki/src/aspnet/odata/model-substitution.md
@@ -0,0 +1 @@
+{{#include ../../shared/odata/model-substitution.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/odata/overview.md b/wiki/src/aspnet/odata/overview.md
new file mode 100644
index 000000000..2027c7370
--- /dev/null
+++ b/wiki/src/aspnet/odata/overview.md
@@ -0,0 +1,25 @@
+{{#include ../../shared/odata/overview-pre.md}}
+
+```c#
+public class Startup
+{
+ public void Configuration( IAppBuilder appBuilder )
+ {
+ var configuration = new HttpConfiguration();
+ var httpServer = new HttpServer( configuration );
+
+ configuration.AddApiVersioning();
+
+ var modelBuilder = new VersionedODataModelBuilder( configuration )
+ {
+ ModelConfigurations =
+ {
+ new PersonModelConfiguration()
+ }
+ };
+
+ configuration.MapVersionedODataRoute( "odata", "api", modelBuilder );
+ appBuilder.UseWebApi( httpServer );
+ }
+}
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet/odata/protocol-transition.md b/wiki/src/aspnet/odata/protocol-transition.md
new file mode 100644
index 000000000..f2d794e36
--- /dev/null
+++ b/wiki/src/aspnet/odata/protocol-transition.md
@@ -0,0 +1,61 @@
+# Protocol Transitions
+
+One of the primary reasons to version a service is to facilitate changes in behavior and/or data exchange with the
+service. In the scope of OData, this can mean transitioning new versions of a service to use the OData protocol or it
+can mean existing OData services that are transitioning away from the OData protocol. The API versioning support for
+OData enables both of these scenarios.
+
+Consider the following partial controller implementations:
+
+```c#
+[ApiVersion( 1.0 )]
+public class OrdersController : ApiController
+{
+ public IHttpActionResult Get() => Ok();
+}
+
+[ApiVersion( 2.0 )]
+[ControllerName( "Orders" )]
+[ODataRoutePrefix( "Orders" )]
+public class Orders2Controller : ODataController
+{
+ [ODataRoute]
+ public IHttpActionResult Get() => Ok();
+}
+
+[ApiVersion( 3.0 )]
+[ControllerName( "Orders" )]
+public class Orders3Controller : ApiController
+{
+ public IHttpActionResult Get() => Ok();
+}
+```
+
+This set of controllers produce the following semantics for the **Orders** service:
+
+- Version 1.0 of the service uses basic REST semantics and convention-based routing
+- Version 2.0 of the service switches to the OData protocol and convention-based routing
+- Version 3.0 of the service switches back to basic REST semantics and convention-based routing
+
+>[!IMPORTANT]
+>Due to routing limitations in ASP.NET Web API, all versioned routes for a service must be either convention-based or
+>attribute-based. Since OData relies on convention-based routing, all routes for a controller with the same name must
+>also be convention-based in order for API versioning to function properly.
+
+The configuration required to support this type of scenario will be:
+
+```c#
+config.AddApiVersioning();
+
+var modelBuilder = new VersionedODataModelBuilder( config )
+{
+ ModelConfigurations = { new OrderModelConfiguration() }
+};
+
+config.MapVersionedODataRoutes( "odata", "api", modelBuilder );
+config.Routes.MapHttpRoute( "orders", "api/{controller}/{id}", new { id = Optional } );
+```
+
+You can see a complete end-to-end implementation of this scenario in the [advanced OData Web API sample].
+
+[advanced OData Web API sample]: https://github.com/dotnet/aspnet-api-versioning/tree/main/examples/AspNet/OData/AdvancedODataWebApiExample
\ No newline at end of file
diff --git a/wiki/src/aspnet/quick-starts/existing-services.md b/wiki/src/aspnet/quick-starts/existing-services.md
new file mode 100644
index 000000000..83d17c43a
--- /dev/null
+++ b/wiki/src/aspnet/quick-starts/existing-services.md
@@ -0,0 +1,84 @@
+{{#include ../../shared/quick-starts/existing-services.md}}
+
+### Web API
+
+```c#
+public static class WebApiConfig
+{
+ public static void Configuration( HttpConfiguration configuration )
+ {
+ // allow a client to call you without specifying an api version
+ // since we haven't configured it otherwise, the assumed api version will be 1.0
+ configuration.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true );
+
+ // remaining configuration omitted for brevity
+ }
+}
+
+[ApiVersion( 1.0 )] // ← this attribute isn't required, but it's easier to understand
+[RoutePrefix( "People" )]
+public class PeopleController : ApiController
+{
+ // GET ~/people
+ // GET ~/people?api-version=1.0
+ [Route]
+ public IHttpActionResult Get() => Ok( new[] { new Person() } );
+}
+
+[ApiVersion( 2.0 )]
+[RoutePrefix( "People" )]
+public class People2Controller : ApiController
+{
+ // GET ~/people?api-version=2.0
+ [Route]
+ public IHttpActionResult Get() => Ok( new[] { new Person() } );
+}
+```
+
+### OData
+
+```c#
+public static class WebApiConfig
+{
+ public static void Configuration( HttpConfiguration configuration )
+ {
+ // allow a client to call you without specifying an api version
+ // since we haven't configured it otherwise, the assumed api version will be 1.0
+ configuration.AddApiVersioning( options => options.AssumeDefaultVersionWhenUnspecified = true );
+
+ var modelBuilder = new VersionedODataModelBuilder( configuration )
+ {
+ DefaultModelConfiguration = ( builder, apiVersion, routePrefix ) =>
+ {
+ builder.EntitySet( "People" );
+ }
+ };
+
+ configuration.MapVersionedODataRoutes( "odata", null, modelBuilder );
+
+ // remaining configuration omitted for brevity
+ }
+}
+
+[ApiVersion( 1.0 )] // ← this attribute isn't required, but it's easier to understand
+[ODataRoutePrefix( "People" )]
+public class PeopleController : ODataController
+{
+ // GET ~/people
+ // GET ~/people?api-version=1.0
+ [EnableQuery]
+ [ODataRoute]
+ public IHttpActionResult Get() => Ok( new[] { new Person() } );
+}
+
+[ApiVersion( 2.0 )]
+[ControllerName( "People" )]
+[ODataRoutePrefix( "People" )]
+public class People2Controller : ODataController
+{
+ // GET ~/people?api-version=2.0
+ [EnableQuery]
+ [ODataRoute]
+ public IHttpActionResult Get() => Ok( new[] { new Person() } );
+}
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet/quick-starts/migration.md b/wiki/src/aspnet/quick-starts/migration.md
new file mode 100644
index 000000000..78da3f641
--- /dev/null
+++ b/wiki/src/aspnet/quick-starts/migration.md
@@ -0,0 +1,24 @@
+{{#include ../../shared/quick-starts/migration-overview.md}}
+
+## Package Identifiers
+
+The original `Microsoft.*` packages are now deprecated and will only undergo servicing:
+
+| Package | Version | TFM |
+| ---------------------------------------------- | -------- | --------------------- |
+| Microsoft.AspNet.WebApi.Versioning | <= 5.x.x | net45 |
+| Microsoft.AspNet.WebApi.Versioning.ApiExplorer | <= 5.x.x | net45 |
+| Microsoft.AspNet.OData.Versioning | <= 5.x.x | net45 |
+| Microsoft.AspNet.OData.Versioning.ApiExplorer | <= 5.x.x | net45 |
+
+All new features and platform support will use the `Asp.Versioning.*` prefix:
+
+| Package | Version | TFM |
+| ------------------------------------------ | ------- | --------------------------------------- |
+| Asp.Versioning.Abstractions | 6.0.0+ | net6.0+, netstandard1.0, netstandard2.0 |
+| Asp.Versioning.WebApi | 6.0.0+ | net45, net472 |
+| Asp.Versioning.WebApi.ApiExplorer | 6.0.0+ | net45, net472 |
+| Asp.Versioning.WebApi.OData | 6.0.0+ | net45, net472 |
+| Asp.Versioning.WebApi.OData.ApiExplorer | 6.0.0+ | net45, net472 |
+
+{{#include ../../shared/quick-starts/migration-common.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/quick-starts/new-services.md b/wiki/src/aspnet/quick-starts/new-services.md
new file mode 100644
index 000000000..c95167e0a
--- /dev/null
+++ b/wiki/src/aspnet/quick-starts/new-services.md
@@ -0,0 +1,58 @@
+{{#include ../../shared/quick-starts/new-services.md}}
+
+### Web API
+
+```c#
+public static class WebApiConfig
+{
+ public static void Configuration( HttpConfiguration configuration )
+ {
+ configuration.AddApiVersioning();
+ // remaining configuration omitted for brevity
+ }
+}
+```
+
+```c#
+[ApiVersion( 1.0 )]
+[RoutePrefix( "People" )]
+public class PeopleController : ApiController
+{
+ [Route]
+ public IHttpActionResult Get() => Ok( new[] { new Person() } );
+}
+```
+
+### OData
+
+```c#
+public static class WebApiConfig
+{
+ public static void Configuration( HttpConfiguration configuration )
+ {
+ configuration.AddApiVersioning();
+
+ var modelBuilder = new VersionedODataModelBuilder( configuration )
+ {
+ DefaultModelConfiguration = ( builder, apiVersion, routePrefix ) =>
+ {
+ builder.EntitySet( "People" );
+ }
+ };
+
+ configuration.MapVersionedODataRoutes( "odata", null, modelBuilder );
+ // remaining configuration omitted for brevity
+ }
+}
+```
+
+```c#
+[ApiVersion( 1.0 )]
+[ODataRoutePrefix( "People" )]
+public class PeopleController : ODataController
+{
+ [EnableQuery]
+ [ODataRoute]
+ public IHttpActionResult Get() => Ok( new[] { new Person() } );
+}
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet/version-discovery.md b/wiki/src/aspnet/version-discovery.md
new file mode 100644
index 000000000..97a4160bf
--- /dev/null
+++ b/wiki/src/aspnet/version-discovery.md
@@ -0,0 +1,22 @@
+{{#include ../shared/version-discovery.md}}
+
+### Web API
+
+```c#
+// OPTIONS ~/api/myservice?api-version=[1.0|2.0|3.0]
+[HttpOptions]
+public IHttpActionResult Options()
+{
+ var response = new HttpResponseMessage( HttpStatusCode.OK );
+ response.Content = new StringContent( string.Empty );
+ response.Content.Headers.Add( "Allow", new[] { "GET", "POST", "OPTIONS" } );
+ response.Content.Headers.ContentType = null;
+ return ResponseMessage( response );
+}
+```
+
+```http
+HTTP/1.1 200 OK
+allow: GET, POST, OPTIONS
+api-supported-versions: 1.0, 2.0, 3.0
+```
\ No newline at end of file
diff --git a/wiki/src/aspnet/version-format.md b/wiki/src/aspnet/version-format.md
new file mode 100644
index 000000000..2d1f44d66
--- /dev/null
+++ b/wiki/src/aspnet/version-format.md
@@ -0,0 +1 @@
+{{#include ../shared/how-to/define-service-version.md}}
\ No newline at end of file
diff --git a/wiki/src/aspnet/version-policies.md b/wiki/src/aspnet/version-policies.md
new file mode 100644
index 000000000..ca044b942
--- /dev/null
+++ b/wiki/src/aspnet/version-policies.md
@@ -0,0 +1,2 @@
+
+{{#include ../shared/version-policies.md}}
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0001.md b/wiki/src/diagnostic/av0001.md
new file mode 100644
index 000000000..0c48c25fe
--- /dev/null
+++ b/wiki/src/diagnostic/av0001.md
@@ -0,0 +1,51 @@
+# AV0001: Invalid API version
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0001 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+An API version expressed as literal text is invalid.
+
+## Rule Description
+
+Some call sites allow specifying an API version as a string. If the format of the API version is invalid, it is not
+uncovered until runtime when the text is parsed.
+
+Consider the following code:
+
+```c#
+[ApiController]
+[ApiVersion("abc")]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+The text `"abc"` is not a valid API version. This would not detected until runtime.
+
+## How to Fix Violations
+
+Update the API version to be well-formed. In addition, consider using one of the typed value forms that allow
+specifying literal numerics to avoid common mistakes.
+
+```c#
+[ApiController]
+[ApiVersion("1.0")]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule because a runtime exception will be thrown when the text is parsed.
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0002.md b/wiki/src/diagnostic/av0002.md
new file mode 100644
index 000000000..b15f5ac92
--- /dev/null
+++ b/wiki/src/diagnostic/av0002.md
@@ -0,0 +1,57 @@
+# AV0002: Invalid API version range
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0002 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+The specific API version range is invalid.
+
+## Rule Description
+
+An API version range must express a valid [interval notation].
+
+Consider the following code:
+
+```c#
+public class Person
+{
+ public int Id { get; set; }
+
+ public string FirstName { get; set; }
+
+ [VisibleInApiVersion( ")2.0,]" )]
+ public string MiddleName { get; set; }
+
+ public string LastName { get; set; }
+}
+```
+
+The text `")2.0,]"` is not a valid API version range. This would not detected until runtime.
+
+## How to Fix Violations
+
+Update the API version range to be well-formed.
+
+```c#
+public class Person
+{
+ public int Id { get; set; }
+
+ public string FirstName { get; set; }
+
+ [VisibleInApiVersion( "(2.0,]" )]
+ public string MiddleName { get; set; }
+
+ public string LastName { get; set; }
+}
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule because a runtime exception will be thrown when the text is parsed.
+
+[interval notation]: ../aspnet-core/how-to/versioned-models.md#notation
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0003.md b/wiki/src/diagnostic/av0003.md
new file mode 100644
index 000000000..d17eb8619
--- /dev/null
+++ b/wiki/src/diagnostic/av0003.md
@@ -0,0 +1,50 @@
+# AV0003: Invalid API version status
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0003 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+The specific API version status is invalid.
+
+## Rule Description
+
+An API version status must start with a letter and may contain letters, digits, and periods. The status not end with
+a period.
+
+Consider the following code:
+
+```c#
+[ApiController]
+[ApiVersion(2.0, "preview-1")]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+The text `"preview-1"` is not a valid API version status. This would not detected until runtime.
+
+## How to Fix Violations
+
+Update the API version status to be well-formed.
+
+```c#
+[ApiController]
+[ApiVersion(2.0, "preview.1")]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule because a runtime exception will be thrown when the text is parsed.
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0004.md b/wiki/src/diagnostic/av0004.md
new file mode 100644
index 000000000..ecfb8b906
--- /dev/null
+++ b/wiki/src/diagnostic/av0004.md
@@ -0,0 +1,50 @@
+# AV0004: Invalid API version number
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0004 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+An API version specified a negative number.
+
+## Rule Description
+
+Specifying an API version as a number removes a class of issues, but the number can still be specified as a negative
+value.
+
+Consider the following code:
+
+```c#
+[ApiController]
+[ApiVersion(-2.0)]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+The text `-2.0` is not a valid API version. This would not detected until runtime.
+
+## How to Fix Violations
+
+An API version should **always** be greater than or equal to `0.1`. Update the API version to be well-formed.
+
+```c#
+[ApiController]
+[ApiVersion(2.0)]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule because a runtime exception will be thrown when the attribute is initialized.
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0005.md b/wiki/src/diagnostic/av0005.md
new file mode 100644
index 000000000..e4d587a93
--- /dev/null
+++ b/wiki/src/diagnostic/av0005.md
@@ -0,0 +1,50 @@
+# AV0005: Invalid API version year
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0005 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+An API version expressed an invalid year.
+
+## Rule Description
+
+When an API version is expressed as a date, the specified year must be valid.
+
+Consider the following code:
+
+```c#
+[ApiController]
+[ApiVersion(10_000, 1, 1)]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+The year `10_000` is not a valid API version. The year must be between 1 and 9999. This would not detected until
+runtime.
+
+## How to Fix Violations
+
+Update the API version to be well-formed.
+
+```c#
+[ApiController]
+[ApiVersion(2026, 1, 1)]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule because a runtime exception will be thrown when the attribute is initialized.
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0006.md b/wiki/src/diagnostic/av0006.md
new file mode 100644
index 000000000..182dd3c8e
--- /dev/null
+++ b/wiki/src/diagnostic/av0006.md
@@ -0,0 +1,49 @@
+# AV0006: Invalid API version month
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0006 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+An API version expressed an invalid month.
+
+## Rule Description
+
+When an API version is expressed as a date, the specified month must be valid.
+
+Consider the following code:
+
+```c#
+[ApiController]
+[ApiVersion(2026, 13, 1)]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+The month `13` is not a valid API version. The month must be between 1 and 12. This would not detected until runtime.
+
+## How to Fix Violations
+
+Update the API version to be well-formed.
+
+```c#
+[ApiController]
+[ApiVersion(2026, 1, 1)]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule because a runtime exception will be thrown when the attribute is initialized.
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0007.md b/wiki/src/diagnostic/av0007.md
new file mode 100644
index 000000000..397581f79
--- /dev/null
+++ b/wiki/src/diagnostic/av0007.md
@@ -0,0 +1,49 @@
+# AV0007: Invalid API version day
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0007 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+An API version expressed an invalid day.
+
+## Rule Description
+
+When an API version is expressed as a date, the specified day must be valid.
+
+Consider the following code:
+
+```c#
+[ApiController]
+[ApiVersion(2026, 1, 32)]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+The day `32` is not a valid API version. The day must be between 1 and 31. This would not detected until runtime.
+
+## How to Fix Violations
+
+Update the API version to be well-formed.
+
+```c#
+[ApiController]
+[ApiVersion(2026, 1, 1)]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule because a runtime exception will be thrown when the attribute is initialized.
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0008.md b/wiki/src/diagnostic/av0008.md
new file mode 100644
index 000000000..e5d3f37d7
--- /dev/null
+++ b/wiki/src/diagnostic/av0008.md
@@ -0,0 +1,50 @@
+# AV0008: Invalid API version date
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0008 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+An API version expressed an invalid date.
+
+## Rule Description
+
+When an API version is expressed as a date, the specified date must be valid.
+
+Consider the following code:
+
+```c#
+[ApiController]
+[ApiVersion(2026, 2, 29)]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+The date `2026-02-29` is not a valid API version because 2026 is not a leap year. This would not detected until
+runtime.
+
+## How to Fix Violations
+
+Update the API version to be well-formed.
+
+```c#
+[ApiController]
+[ApiVersion(2026, 2, 28)]
+[Route("[controller]")]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule because a runtime exception will be thrown when the attribute is initialized.
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0009.md b/wiki/src/diagnostic/av0009.md
new file mode 100644
index 000000000..c0f027418
--- /dev/null
+++ b/wiki/src/diagnostic/av0009.md
@@ -0,0 +1,35 @@
+# AV0009: Invalid API version format specifier
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0009 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+An API version format specifier is invalid.
+
+## Rule Description
+
+When an API version is formatted, it must use a valid format specifier.
+
+Consider the following code:
+
+```c#
+Console.WriteLine(new ApiVersion(2026, 1, 1).ToString("'unterminated"));
+```
+
+The format specifier `'unterminated` is not a valid API version format. This would not be detected until runtime.
+
+## How to Fix Violations
+
+Update the API version format.
+
+```c#
+Console.WriteLine(new ApiVersion(2026, 1, 1).ToString("'v'VV"));
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule because a runtime exception will be thrown when the value is formatted.
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0010.md b/wiki/src/diagnostic/av0010.md
new file mode 100644
index 000000000..c347c10a6
--- /dev/null
+++ b/wiki/src/diagnostic/av0010.md
@@ -0,0 +1,36 @@
+# AV0010: Unexpected API version format
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0010 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+An API version format specifier is unexpected.
+
+## Rule Description
+
+When an API version is formatted, it must use a valid format specifier.
+
+Consider the following code:
+
+```c#
+Console.WriteLine(new ApiVersion(2026, 1, 1).ToString("VVVVV"));
+```
+
+The format specifier `V` is only meaningful up to 4 times. Repeating it 5 times does not produce the expected result;
+the extra specifier is silently reinterpreted rather than reported. This would not be detected until runtime.
+
+## How to Fix Violations
+
+Update the API version format so that the specifier is not repeated beyond its maximum.
+
+```c#
+Console.WriteLine(new ApiVersion(2026, 1, 1).ToString("VVVV"));
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule. Incorrect or unexpected output will be generated.
\ No newline at end of file
diff --git a/wiki/src/diagnostic/av0011.md b/wiki/src/diagnostic/av0011.md
new file mode 100644
index 000000000..1620f8d43
--- /dev/null
+++ b/wiki/src/diagnostic/av0011.md
@@ -0,0 +1,42 @@
+# AV0011: Remove unnecessary default API version
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0011 |
+| Category | Style |
+| Fix is | Non-breaking |
+
+## Cause
+
+The default API version is assigned the value it already has.
+
+## Rule Description
+
+The default API version is `1.0` unless it is configured otherwise. Assigning that same version restates what the
+options were already given.
+
+Consider the following code:
+
+```c#
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.DefaultApiVersion = ApiVersion.Default;
+ } );
+```
+
+`ApiVersion.Default` is `1.0`, which is what the options start with. Writing `new ApiVersion( 1, 0 )` or
+`new ApiVersion( 1.0 )` states the same version a different way and is equally unnecessary.
+
+## How to Fix Violations
+
+Remove the assignment.
+
+```c#
+builder.Services.AddApiVersioning();
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if you prefer the default API version to be stated explicitly so that a future change
+to it is deliberate rather than inherited.
diff --git a/wiki/src/diagnostic/av0012.md b/wiki/src/diagnostic/av0012.md
new file mode 100644
index 000000000..98ec620c8
--- /dev/null
+++ b/wiki/src/diagnostic/av0012.md
@@ -0,0 +1,49 @@
+# AV0012: Invalid default API version
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0012 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+The default API version is version-neutral.
+
+## Rule Description
+
+The default API version is the version applied to a request that did not ask for one. Version-neutral is not a version;
+it is the absence of one. A request cannot be resolved to it, so it can never serve as a default.
+
+Consider the following code:
+
+```c#
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.DefaultApiVersion = ApiVersion.Neutral;
+ } );
+```
+
+`ApiVersion.Neutral` cannot be the default for either the API versioning options or the API explorer options.
+
+## How to Fix Violations
+
+Assign a real API version, or remove the assignment and let the default of `1.0` stand.
+
+```c#
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.DefaultApiVersion = new ApiVersion( 2.0 );
+ } );
+```
+
+If the intent was for endpoints to be reachable without naming a version, declare those endpoints
+[version-neutral][version-neutral] instead of making the default neutral.
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule. No request can ever resolve to a version-neutral default.
+
+[version-neutral]: ../aspnet-core/how-to/version-neutral.md
diff --git a/wiki/src/diagnostic/av0013.md b/wiki/src/diagnostic/av0013.md
new file mode 100644
index 000000000..be0259dfb
--- /dev/null
+++ b/wiki/src/diagnostic/av0013.md
@@ -0,0 +1,49 @@
+# AV0013: Missing AddMvc
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0013 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+An application uses MVC controllers and API versioning, but never opted into versioning MVC.
+
+## Rule Description
+
+API versioning covers minimal APIs on its own. Controllers are discovered and routed by MVC, which requires an explicit
+opt in so that the versioning metadata declared on a controller is applied to the actions it defines. Without it, the
+attributes are still compiled but nothing ever reads them.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers();
+builder.Services.AddApiVersioning();
+```
+
+`AddControllers()` opts into MVC and `AddApiVersioning()` opts into API versioning, but neither versions the other.
+Every controller in the application is routed as if it declared no API version at all.
+
+## How to Fix Violations
+
+Call `AddMvc()` on the builder returned by `AddApiVersioning()`.
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers();
+builder.Services.AddApiVersioning().AddMvc();
+```
+
+`AddMvcCore()` opts into MVC the same way `AddControllers()` does and requires the same call.
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule only if the controllers in the application are deliberately left unversioned; for
+example, when API versioning was added for Minimal APIs and the controllers serve something else. Note that
+`AddApiVersioning().AddMvc()` changes how requests are routed to controllers, so applying the fix to an existing service
+is a breaking change for clients that do not send a version.
diff --git a/wiki/src/diagnostic/av0014.md b/wiki/src/diagnostic/av0014.md
new file mode 100644
index 000000000..c3589237b
--- /dev/null
+++ b/wiki/src/diagnostic/av0014.md
@@ -0,0 +1,61 @@
+# AV0014: Missing API behavior
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0014 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+A controller that serves an API has not opted into API behavior.
+
+## Rule Description
+
+A controller derived from `ControllerBase` may serve an API or something else entirely. `[ApiController]` is what
+resolves that ambiguity. It also turns on the conventions an API is expected to follow, such as automatic model
+validation and problem details for error responses, which is how a versioning error is reported in the shape a client
+can read.
+
+A controller derived from `Controller` is assumed to serve a user interface rather than an API and is never reported.
+
+Consider the following code:
+
+```c#
+[ApiVersion( 2.0 )]
+[Route( "[controller]" )]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+The controller declares an API version but never states that it is an API.
+
+## How to Fix Violations
+
+Add `[ApiController]` to the controller.
+
+```c#
+[ApiController]
+[ApiVersion( 2.0 )]
+[Route( "[controller]" )]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+The attribute can also be applied to the assembly, in which case it covers every controller and nothing is reported:
+
+```c#
+[assembly: ApiController]
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if the controller derives from `ControllerBase` but does not serve an API. Applying
+`[ApiController]` changes model binding and error responses, so adding it to an existing service is a breaking change
+for clients that depend on the current behavior.
diff --git a/wiki/src/diagnostic/av0015.md b/wiki/src/diagnostic/av0015.md
new file mode 100644
index 000000000..e25a5a749
--- /dev/null
+++ b/wiki/src/diagnostic/av0015.md
@@ -0,0 +1,69 @@
+# AV0015: Use a specific API version reader
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0015 |
+| Category | Performance |
+| Fix is | Breaking |
+
+## Cause
+
+An API reads its version one way, but the reader was left to accept more than one.
+
+## Rule Description
+
+Without an explicit reader, an API version is looked for in both the query string and the URL segment. Every route in
+the application is examined to decide which of the two is actually used. When they all agree, the other reader is asked
+for a version on every request and never finds one.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning();
+
+var app = builder.Build();
+
+app.MapGet( "/v{version:apiVersion}/order", () => Results.Ok() ).HasApiVersion( 1.0 );
+app.MapGet( "/v{version:apiVersion}/customer", () => Results.Ok() ).HasApiVersion( 1.0 );
+
+app.Run();
+```
+
+Every route carries the API version constraint, so the version is only ever read from the URL segment. The query string
+is searched on every request for a value that is never there.
+
+Any mixture of the two styles, or a route that cannot be followed back to its origin, leaves the default in place and is
+not reported; narrowing the reader would then break a form the application relies on.
+
+## How to Fix Violations
+
+Configure the [reader][reader] the application actually uses.
+
+```c#
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.ApiVersionReader = new UrlSegmentApiVersionReader();
+ } );
+```
+
+An application whose routes never carry the constraint reads its version from the query string instead:
+
+```c#
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.ApiVersionReader = new QueryStringApiVersionReader();
+ } );
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if the additional reader is intended; for example, when the routes visible to the
+compiler are only part of the application, or when a client is knowingly allowed to name a version either way.
+Configuring a specific reader stops the other form from being accepted, so applying the fix to an existing service is a
+breaking change for any client using it.
+
+[reader]: ../aspnet-core/config/reader.md
diff --git a/wiki/src/diagnostic/av0016.md b/wiki/src/diagnostic/av0016.md
new file mode 100644
index 000000000..8c6eeb406
--- /dev/null
+++ b/wiki/src/diagnostic/av0016.md
@@ -0,0 +1,59 @@
+# AV0016: Do not assume default API version
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0016 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+A default API version is assumed where none can ever apply.
+
+## Rule Description
+
+A default API version is only applied to an endpoint that carries no versioning metadata at all. The setting exists to
+grandfather the clients of a service that was not versioned before. Declaring any version, even a neutral one, takes an
+endpoint out of that arrangement, as does a route that can only be reached by naming a version in the URL. Once every
+endpoint is in one of those states, the setting does nothing.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ } );
+
+var app = builder.Build();
+
+app.MapGet( "/order", () => Results.Ok() ).HasApiVersion( 1.0 );
+app.MapGet( "/customer", () => Results.Ok() ).HasApiVersion( 1.0 );
+
+app.Run();
+```
+
+Every endpoint declares its own version, so there is nothing left for the default to be applied to.
+
+Reading the version from the media type is the exception and is never reported. A client asking for
+`application/json` has named no version and never will, whereas every version after the first is asked for as something
+like `application/json; v=2.0`. Assuming a default is what keeps the original clients working, however the endpoints are
+declared.
+
+## How to Fix Violations
+
+Remove the assignment.
+
+```c#
+builder.Services.AddApiVersioning();
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if endpoints that rely on the default are declared outside the compilation, such as in
+a referenced library. See [existing services][existing-services] for when assuming a default is the right arrangement.
+
+[existing-services]: ../aspnet-core/how-to/existing-services.md
diff --git a/wiki/src/diagnostic/av0017.md b/wiki/src/diagnostic/av0017.md
new file mode 100644
index 000000000..ff00b8bf3
--- /dev/null
+++ b/wiki/src/diagnostic/av0017.md
@@ -0,0 +1,47 @@
+# AV0017: Remove unnecessary default value
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0017 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+An option is assigned the value it already has.
+
+## Rule Description
+
+Most options already hold a usable value before any configuration runs. Assigning that same value restates the default
+without changing anything.
+
+Consider the following code:
+
+```c#
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.ReportApiVersions = false;
+ options.RouteConstraintName = "apiVersion";
+ } );
+```
+
+Both assignments state what the options already hold.
+
+The default of a property is matched by the type declaring it rather than by name alone, because the same name can carry
+a different default on a different set of options. The default API version is reported by [AV0011](av0011.md) instead,
+because it can be spelled more than one way, and a value the API explorer takes from the API versioning options is
+reported by [AV0024](av0024.md).
+
+## How to Fix Violations
+
+Remove the assignment.
+
+```c#
+builder.Services.AddApiVersioning();
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if you prefer options to be stated explicitly so that a future change to a default is
+deliberate rather than inherited.
diff --git a/wiki/src/diagnostic/av0018.md b/wiki/src/diagnostic/av0018.md
new file mode 100644
index 000000000..5c1ca216a
--- /dev/null
+++ b/wiki/src/diagnostic/av0018.md
@@ -0,0 +1,57 @@
+# AV0018: All endpoints are version-neutral
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0018 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+Every endpoint in the application is version-neutral, so no API version is ever defined.
+
+## Rule Description
+
+A version-neutral endpoint belongs to every API version that has been defined. Requests still route when nothing else is
+defined, which is why this can go unnoticed, but the API explorer describes an endpoint once per explicitly defined
+version. With none defined, it describes nothing at all and the generated documentation is empty.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning().AddApiExplorer();
+
+var app = builder.Build();
+
+app.MapGet( "/order", () => Results.Ok() ).IsApiVersionNeutral();
+app.MapGet( "/customer", () => Results.Ok() ).IsApiVersionNeutral();
+
+app.Run();
+```
+
+Neither endpoint defines an API version, so there is no version for the neutral endpoints to belong to.
+
+An endpoint that declares nothing at all is a separate problem and is not reported here.
+
+## How to Fix Violations
+
+Declare an explicit API version on at least one endpoint.
+
+```c#
+var app = builder.Build();
+
+app.MapGet( "/order", () => Results.Ok() ).HasApiVersion( 1.0 );
+app.MapGet( "/customer", () => Results.Ok() ).IsApiVersionNeutral();
+
+app.Run();
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if the endpoints that define the API versions are declared outside the compilation,
+such as in a referenced library. See [version neutrality][version-neutral] for what neutrality means and when it
+applies.
+
+[version-neutral]: ../aspnet-core/how-to/version-neutral.md
diff --git a/wiki/src/diagnostic/av0019.md b/wiki/src/diagnostic/av0019.md
new file mode 100644
index 000000000..e3c21ce3c
--- /dev/null
+++ b/wiki/src/diagnostic/av0019.md
@@ -0,0 +1,76 @@
+# AV0019: An API cannot be versioned and version-neutral at the same time
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0019 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+An API is declared both versioned and version-neutral.
+
+## Rule Description
+
+Versioning metadata is inherited from a controller or an endpoint group as a convenience, and an action may state
+something more explicit in its place. Neutrality is the exception. It applies to the whole API, and an action cannot
+meaningfully claim a version of an API that has none.
+
+Consider the following code:
+
+```c#
+[ApiController]
+[ApiVersionNeutral]
+[Route( "[controller]" )]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ [ApiVersion( 2.0 )]
+ public IActionResult Get() => Ok();
+}
+```
+
+The controller states that the API has no versions while the action claims one of them.
+
+The same conflict occurs with minimal APIs when a version is declared under a neutral group, or when both are declared
+together at the same level:
+
+```c#
+var orders = app.MapGroup( "/order" ).IsApiVersionNeutral();
+
+orders.MapGet( "/", () => Results.Ok() ).HasApiVersion( 2.0 );
+```
+
+Controllers are collated by logical name, so a neutral declaration on one controller can silence versions declared on
+another that collates alongside it:
+
+```c#
+[ApiController]
+[ApiVersionNeutral]
+[Route( "example" )]
+public class Example2Controller : ControllerBase { }
+
+[ApiController]
+[ApiVersion( 3.0 )]
+[Route( "example" )]
+public class Example3Controller : ControllerBase { }
+```
+
+## How to Fix Violations
+
+Decide whether the API is versioned or neutral and declare only that.
+
+```c#
+[ApiController]
+[ApiVersion( 2.0 )]
+[Route( "[controller]" )]
+public class ExampleController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule. The two declarations contradict each other and one of them will not be honored.
diff --git a/wiki/src/diagnostic/av0020.md b/wiki/src/diagnostic/av0020.md
new file mode 100644
index 000000000..9d0c50428
--- /dev/null
+++ b/wiki/src/diagnostic/av0020.md
@@ -0,0 +1,41 @@
+# AV0020: Remove unnecessary API explorer
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0020 |
+| Category | Style |
+| Fix is | Non-breaking |
+
+## Cause
+
+The endpoints API explorer is added alongside the versioned API explorer, which already adds it.
+
+## Rule Description
+
+`AddApiExplorer()` adds the endpoints API explorer itself, as do the OData and OpenAPI variants on their way to their
+own. Calling `AddEndpointsApiExplorer()` next to any of them repeats a registration that has already been made.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddApiVersioning().AddApiExplorer();
+```
+
+`AddApiExplorer()` covers the call above it.
+
+## How to Fix Violations
+
+Remove the call to `AddEndpointsApiExplorer()`.
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning().AddApiExplorer();
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule. The extra call is redundant rather than wrong.
diff --git a/wiki/src/diagnostic/av0021.md b/wiki/src/diagnostic/av0021.md
new file mode 100644
index 000000000..0548f6bc8
--- /dev/null
+++ b/wiki/src/diagnostic/av0021.md
@@ -0,0 +1,61 @@
+# AV0021: Use the versioned API explorer
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0021 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+An application versions its APIs but describes them with an API explorer that is unaware of API versions.
+
+## Rule Description
+
+`AddEndpointsApiExplorer()` describes endpoints without their versions. Once API versioning is in use, the generated
+documentation shows a single, version-less view of an API that actually has more than one version.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddApiVersioning();
+```
+
+API versioning is configured, but nothing was told to describe the versions.
+
+## How to Fix Violations
+
+Replace the call with the versioned API explorer, which adds the endpoints API explorer itself.
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning().AddApiExplorer();
+```
+
+An application using OData, gRPC, and/or OpenAPI calls the corresponding variant instead:
+
+```c#
+builder.Services.AddApiVersioning()
+ .AddOData()
+ .AddODataApiExplorer()
+ .AddOpenApi();
+```
+
+```c#
+builder.Services.AddApiVersioning()
+ .AddGrpc()
+ .AddGrpcApiExplorer()
+ .AddOpenApi();
+```
+
+See [API explorer options][options] for what the versioned explorer can be configured to describe.
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if the documentation is deliberately generated without API versions.
+
+[options]: ../aspnet-core/docs/options.md
diff --git a/wiki/src/diagnostic/av0022.md b/wiki/src/diagnostic/av0022.md
new file mode 100644
index 000000000..22b081fcb
--- /dev/null
+++ b/wiki/src/diagnostic/av0022.md
@@ -0,0 +1,48 @@
+# AV0022: Missing AddOData
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0022 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+An application uses OData and API versioning, but never opted into versioning OData.
+
+## Rule Description
+
+OData routes by its own conventions rather than by the routes API versioning otherwise observes, so versioning an OData
+API takes an explicit opt in. Without it, the versioning metadata declared on an OData controller is never applied.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers().AddOData();
+builder.Services.AddApiVersioning();
+```
+
+The `AddOData()` above belongs to OData itself and opts into OData. It does not version it.
+
+## How to Fix Violations
+
+Call `AddOData()` on the builder returned by `AddApiVersioning()`.
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers().AddOData();
+builder.Services.AddApiVersioning().AddOData(
+ options => options.AddRouteComponents( "api" ) );
+```
+
+`AddODataApiExplorer()` registers the versioned OData services it needs on its own, which is a supported way to describe
+a versioned OData API without taking on the rest of them.
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule only if the OData APIs in the application are deliberately left unversioned. Versioning
+OData changes how its routes are resolved, so applying the fix to an existing service is a breaking change for clients
+that do not send a version.
diff --git a/wiki/src/diagnostic/av0023.md b/wiki/src/diagnostic/av0023.md
new file mode 100644
index 000000000..94611f487
--- /dev/null
+++ b/wiki/src/diagnostic/av0023.md
@@ -0,0 +1,51 @@
+# AV0023: Route components are ignored
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0023 |
+| Category | Usage |
+| Fix is | Breaking |
+
+## Cause
+
+OData route components are added to the options that versioned OData replaces.
+
+## Rule Description
+
+Versioned OData resolves the options for the API version of the current request. The options configured for OData itself
+are not part of that resolution. Route components added without saying which API version they belong to are left behind
+when the options are resolved, and a prefix stated in both places collides once they are.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers().AddOData(
+ options => options.AddRouteComponents( "api", model ) );
+
+builder.Services.AddApiVersioning().AddOData();
+```
+
+The route components are added to `ODataOptions` rather than to the versioned options, so they are never applied.
+
+## How to Fix Violations
+
+Add the route components through the options given to the versioned `AddOData()`, which applies them per API version.
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddControllers().AddOData();
+
+builder.Services.AddApiVersioning().AddOData(
+ options => options.AddRouteComponents( "api" ) );
+```
+
+See [OData options][odata-options] for how route components are applied per API version.
+
+## When to Suppress Warnings
+
+It is never safe to suppress this rule. The route components are either ignored or collide with the versioned ones.
+
+[odata-options]: ../aspnet-core/docs/odata-options.md
diff --git a/wiki/src/diagnostic/av0024.md b/wiki/src/diagnostic/av0024.md
new file mode 100644
index 000000000..3bd54c4a9
--- /dev/null
+++ b/wiki/src/diagnostic/av0024.md
@@ -0,0 +1,56 @@
+# AV0024: Remove unnecessary API explorer option
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0024 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+An API explorer option restates a value it already inherits from the API versioning options.
+
+## Rule Description
+
+The API explorer takes the options it shares with API versioning before its own configuration runs. Stating one of those
+shared values again only repeats what it was already given.
+
+The shared values are `AssumeDefaultVersionWhenUnspecified`, `DefaultApiVersion`, `RouteConstraintName`,
+`ApiVersionSelector`, and `ApiVersionParameterSource`, which the API explorer takes from `ApiVersionReader`.
+
+Consider the following code:
+
+```c#
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.DefaultApiVersion = new ApiVersion( 2.0 );
+ } )
+ .AddApiExplorer(
+ options =>
+ {
+ options.DefaultApiVersion = new ApiVersion( 2.0 );
+ } );
+```
+
+The API explorer was already given `2.0` from the API versioning options.
+
+A value that differs from the one configured for API versioning is a deliberate departure and is not reported.
+
+## How to Fix Violations
+
+Remove the assignment and let the value be inherited.
+
+```c#
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.DefaultApiVersion = new ApiVersion( 2.0 );
+ } )
+ .AddApiExplorer();
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if you prefer the API explorer options to be stated in full so that a later change to
+the API versioning options does not silently change what is described.
diff --git a/wiki/src/diagnostic/av0025.md b/wiki/src/diagnostic/av0025.md
new file mode 100644
index 000000000..b0d6a18b8
--- /dev/null
+++ b/wiki/src/diagnostic/av0025.md
@@ -0,0 +1,52 @@
+# AV0025: Missing OpenAPI document description
+
+| | Value |
+| -------- | ------------- |
+| Rule ID | AV0025 |
+| Category | Documentation |
+| Fix is | Non-breaking |
+
+## Cause
+
+An OpenAPI document is generated without the description that documents it.
+
+## Rule Description
+
+What an OpenAPI document says about itself is taken from the assembly it is generated for. The title is supplied by the
+project whether it was asked for or not, but the description is only there if it was stated.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning().AddApiExplorer().AddOpenApi();
+```
+
+Nothing in the project describes what the generated document is for, so the `description` of every document is left
+empty.
+
+The description is taken from the assembly the application was started from, so this is only reported for a project that
+produces an application. A library configuring OpenAPI on an application's behalf has nothing to give and is not
+reported.
+
+## How to Fix Violations
+
+Set `Description` in the project file.
+
+```xml
+
+ Order management APIs.
+
+```
+
+The attribute the project generates from that property can also be written by hand:
+
+```c#
+[assembly: AssemblyDescription( "Order management APIs." )]
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if the generated documents are not published, or if the description is supplied when
+the document is transformed rather than by the assembly.
diff --git a/wiki/src/diagnostic/av0026.md b/wiki/src/diagnostic/av0026.md
new file mode 100644
index 000000000..270962315
--- /dev/null
+++ b/wiki/src/diagnostic/av0026.md
@@ -0,0 +1,67 @@
+# AV0026: Remove unnecessary group name format
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0026 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+A group name format is configured for an application where no API has a group name.
+
+## Rule Description
+
+`FormatGroupName` is only reached for an API that has a group name. An API without one is described by its API version
+alone, so the callback is never invoked and the format has no effect.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning().AddApiExplorer(
+ options =>
+ {
+ options.FormatGroupName = ( group, version ) => $"{group}-{version}";
+ } );
+
+var app = builder.Build();
+
+app.MapGet( "/order", () => Results.Ok() ).HasApiVersion( 1.0 );
+
+app.Run();
+```
+
+No API in the application states a group name, so nothing is ever formatted.
+
+## How to Fix Violations
+
+Either remove the format, or give the APIs it is meant for a group name.
+
+```c#
+var app = builder.Build();
+
+app.MapGet( "/order", () => Results.Ok() ).HasApiVersion( 1.0 ).WithGroupName( "orders" );
+
+app.Run();
+```
+
+A controller states its group name with `[ApiExplorerSettings]`:
+
+```c#
+[ApiController]
+[ApiVersion( 1.0 )]
+[ApiExplorerSettings( GroupName = "orders" )]
+[Route( "[controller]" )]
+public class OrderController : ControllerBase
+{
+ [HttpGet]
+ public IActionResult Get() => Ok();
+}
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if the APIs carrying group names are declared outside the compilation, such as in a
+referenced library.
diff --git a/wiki/src/diagnostic/av0027.md b/wiki/src/diagnostic/av0027.md
new file mode 100644
index 000000000..0f33180fb
--- /dev/null
+++ b/wiki/src/diagnostic/av0027.md
@@ -0,0 +1,55 @@
+# AV0027: Use DescribeApiVersions
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0027 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+API version descriptions are resolved from the services before every API has been mapped.
+
+## Rule Description
+
+An `IApiVersionDescriptionProvider` resolved from the services describes the APIs that were known when the services were
+built. Minimal APIs are mapped onto the application afterward, so they are not among them. Describing the versions from
+the application itself waits until every API has been mapped, which is why there was nothing to choose between before
+minimal APIs existed.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning().AddApiExplorer();
+
+var app = builder.Build();
+
+app.MapGet( "/order", () => Results.Ok() ).HasApiVersion( 1.0 );
+
+var descriptions = app.Services.GetRequiredService().ApiVersionDescriptions;
+
+app.Run();
+```
+
+The provider was resolved from the services, so the endpoint mapped above it is not described.
+
+## How to Fix Violations
+
+Describe the versions from the application, which waits until every API has been mapped.
+
+```c#
+var app = builder.Build();
+
+app.MapGet( "/order", () => Results.Ok() ).HasApiVersion( 1.0 );
+
+var descriptions = app.DescribeApiVersions();
+
+app.Run();
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if the descriptions are deliberately limited to the APIs known when the services were
+built.
diff --git a/wiki/src/diagnostic/av0028.md b/wiki/src/diagnostic/av0028.md
new file mode 100644
index 000000000..af72947e8
--- /dev/null
+++ b/wiki/src/diagnostic/av0028.md
@@ -0,0 +1,54 @@
+# AV0028: Sunset policy takes effect before deprecation
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0028 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+An API is sunset before it is deprecated.
+
+## Rule Description
+
+Deprecation announces that an API is going away and sunset is when it does, so the two are only in order when
+deprecation comes first. Taking effect on the same day is allowed.
+
+Consider the following code:
+
+```c#
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.Policies.Deprecate( 0.9 ).Effective( 2024, 6, 1 );
+ options.Policies.Sunset( 0.9 ).Effective( 2024, 1, 1 );
+ } );
+```
+
+The API is retired five months before its clients are told it is going away.
+
+Only policies that some API reaches together are compared and only when both state a date that can be read as written.
+A date that comes from somewhere else is left alone because what it will be is not decided here.
+
+## How to Fix Violations
+
+Move the sunset date on or after the deprecation date.
+
+```c#
+builder.Services.AddApiVersioning(
+ options =>
+ {
+ options.Policies.Deprecate( 0.9 ).Effective( 2024, 1, 1 );
+ options.Policies.Sunset( 0.9 ).Effective( 2024, 6, 1 );
+ } );
+```
+
+See [version policies][policies] for how the dates are advertised to clients.
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if the ordering is deliberate; for example, when an API is being retired without the
+usual notice and the deprecation is recorded after the fact.
+
+[policies]: ../aspnet-core/version-policies.md
diff --git a/wiki/src/diagnostic/av0029.md b/wiki/src/diagnostic/av0029.md
new file mode 100644
index 000000000..8ffc685a9
--- /dev/null
+++ b/wiki/src/diagnostic/av0029.md
@@ -0,0 +1,47 @@
+# AV0029: Remove unnecessary OpenAPI services
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0029 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+The OpenAPI services are registered alongside the versioned ones that replace them.
+
+## Rule Description
+
+`AddApiVersioning().AddOpenApi()` registers services of its own in place of the ones OpenAPI registers for itself, which
+describe a single document that knows nothing about API versions. Calling `AddOpenApi()` on the service collection as
+well registers services that are then replaced.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddOpenApi();
+builder.Services.AddApiVersioning().AddOpenApi();
+```
+
+The `AddOpenApi()` above belongs to OpenAPI itself and is superseded by the versioned one. Any of `AddApiExplorer()`,
+`AddODataApiExplorer()`, `AddGrpcApiExplorer()`, or `AddOpenApi()` on the API versioning builder has the same effect.
+
+## How to Fix Violations
+
+Remove the call to `AddOpenApi()` on the service collection.
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning().AddOpenApi();
+```
+
+See [OpenAPI options][openapi-options] for how the versioned documents are configured.
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule. The call is redundant rather than wrong.
+
+[openapi-options]: ../aspnet-core/docs/openapi-options.md
diff --git a/wiki/src/diagnostic/av0030.md b/wiki/src/diagnostic/av0030.md
new file mode 100644
index 000000000..54c603842
--- /dev/null
+++ b/wiki/src/diagnostic/av0030.md
@@ -0,0 +1,49 @@
+# AV0030: Missing WithDocumentPerVersion
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0030 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+The endpoint serving OpenAPI documents was not told to serve one per API version.
+
+## Rule Description
+
+The endpoint serving the documents resolves them from the services of the request it is answering, which is only where
+the versioned documents are to be found once the endpoint has been told to look there. Without that, the endpoint serves
+the single, version-less document it would have served before API versioning was configured.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning().AddApiExplorer().AddOpenApi();
+
+var app = builder.Build();
+
+app.MapOpenApi();
+
+app.Run();
+```
+
+The versioned documents are generated but never served.
+
+## How to Fix Violations
+
+Continue the expression that mapped the endpoint with `WithDocumentPerVersion()`.
+
+```c#
+var app = builder.Build();
+
+app.MapOpenApi().WithDocumentPerVersion();
+
+app.Run();
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if a single document describing every API version is intended.
diff --git a/wiki/src/diagnostic/av0031.md b/wiki/src/diagnostic/av0031.md
new file mode 100644
index 000000000..67aec8803
--- /dev/null
+++ b/wiki/src/diagnostic/av0031.md
@@ -0,0 +1,57 @@
+# AV0031: Missing API explorer
+
+| | Value |
+| -------- | ------------ |
+| Rule ID | AV0031 |
+| Category | Usage |
+| Fix is | Non-breaking |
+
+## Cause
+
+An OpenAPI document is generated without the API explorer that describes the APIs it is generated for.
+
+## Rule Description
+
+An OpenAPI document is generated from what the API explorer discovered, and what it discovers depends on how the APIs
+were built. OData and gRPC are each described by an explorer of their own, which nothing else registers on their behalf.
+
+Consider the following code:
+
+```c#
+var builder = WebApplication.CreateBuilder( args );
+
+builder.Services.AddApiVersioning().AddOpenApi();
+```
+
+Nothing describes the APIs, so the generated documents are empty.
+
+An application that versions OData or gRPC needs the matching explorer as well:
+
+```c#
+builder.Services.AddApiVersioning().AddOData().AddOpenApi();
+builder.Services.AddApiVersioning().AddGrpc().AddOpenApi();
+```
+
+An API built any other way is described by the explorer the rest of them build on, so a specialized explorer on its own
+satisfies the rule for the APIs it specializes in.
+
+## How to Fix Violations
+
+Add the API explorer that matches how the APIs were built.
+
+```c#
+builder.Services.AddApiVersioning().AddApiExplorer().AddOpenApi();
+```
+
+```c#
+builder.Services.AddApiVersioning().AddOData().AddODataApiExplorer().AddOpenApi();
+```
+
+```c#
+builder.Services.AddApiVersioning().AddGrpc().AddGrpcApiExplorer().AddOpenApi();
+```
+
+## When to Suppress Warnings
+
+It is safe to suppress this rule if the API explorer is registered outside the compilation, such as by a referenced
+library that configures the services on the application's behalf.
diff --git a/wiki/src/diagnostic/overview.md b/wiki/src/diagnostic/overview.md
new file mode 100644
index 000000000..bfa2daa9d
--- /dev/null
+++ b/wiki/src/diagnostic/overview.md
@@ -0,0 +1,85 @@
+# Diagnostic Code Analysis for ASP.NET API Versioning
+
+.NET compiler platform analyzers inspect application code for code quality and style issues using ASP.NET API
+Versioning.
+
+| | ID | Category | Description |
+| -------------------------------- | ------------------- | ------------- | ----------------------------------------------- |
+| {{#include ../icons/error.md}} | [AV0001](av0001.md) | Usage | Invalid API version |
+| {{#include ../icons/error.md}} | [AV0002](av0002.md) | Usage | Invalid API version range |
+| {{#include ../icons/error.md}} | [AV0003](av0003.md) | Usage | Invalid API version status |
+| {{#include ../icons/error.md}} | [AV0004](av0004.md) | Usage | Invalid API version number |
+| {{#include ../icons/error.md}} | [AV0005](av0005.md) | Usage | Invalid API version year |
+| {{#include ../icons/error.md}} | [AV0006](av0006.md) | Usage | Invalid API version month |
+| {{#include ../icons/error.md}} | [AV0007](av0007.md) | Usage | Invalid API version day |
+| {{#include ../icons/error.md}} | [AV0008](av0008.md) | Usage | Invalid API version date |
+| {{#include ../icons/error.md}} | [AV0009](av0009.md) | Usage | Invalid API version format specifier |
+| {{#include ../icons/warning.md}} | [AV0010](av0010.md) | Usage | Unexpected API version format |
+| {{#include ../icons/info.md}} | [AV0011](av0011.md) | Style | Remove unnecessary default API version |
+| {{#include ../icons/error.md}} | [AV0012](av0012.md) | Usage | Invalid default API version |
+| {{#include ../icons/warning.md}} | [AV0013](av0013.md) | Usage | Missing AddMvc |
+| {{#include ../icons/warning.md}} | [AV0014](av0014.md) | Usage | Missing API behavior |
+| {{#include ../icons/warning.md}} | [AV0015](av0015.md) | Performance | Use a specific API version reader |
+| {{#include ../icons/warning.md}} | [AV0016](av0016.md) | Usage | Do not assume default API version |
+| {{#include ../icons/info.md}} | [AV0017](av0017.md) | Usage | Remove unnecessary default value |
+| {{#include ../icons/error.md}} | [AV0018](av0018.md) | Usage | All endpoints are version-neutral |
+| {{#include ../icons/error.md}} | [AV0019](av0019.md) | Usage | Versioned and version-neutral |
+| {{#include ../icons/info.md}} | [AV0020](av0020.md) | Style | Remove unnecessary API explorer |
+| {{#include ../icons/warning.md}} | [AV0021](av0021.md) | Usage | Use the versioned API explorer |
+| {{#include ../icons/warning.md}} | [AV0022](av0022.md) | Usage | Missing AddOData |
+| {{#include ../icons/warning.md}} | [AV0023](av0023.md) | Usage | Route components are ignored |
+| {{#include ../icons/info.md}} | [AV0024](av0024.md) | Usage | Remove unnecessary API explorer option |
+| {{#include ../icons/info.md}} | [AV0025](av0025.md) | Documentation | Missing OpenAPI document description |
+| {{#include ../icons/info.md}} | [AV0026](av0026.md) | Usage | Remove unnecessary group name format |
+| {{#include ../icons/warning.md}} | [AV0027](av0027.md) | Usage | Use DescribeApiVersions |
+| {{#include ../icons/warning.md}} | [AV0028](av0028.md) | Usage | Sunset policy takes effect before deprecation |
+| {{#include ../icons/warning.md}} | [AV0029](av0029.md) | Usage | Remove unnecessary OpenAPI services |
+| {{#include ../icons/warning.md}} | [AV0030](av0030.md) | Usage | Missing WithDocumentPerVersion |
+| {{#include ../icons/warning.md}} | [AV0031](av0031.md) | Usage | Missing API explorer |
+
+## Reporting
+
+Most rules report as you type, but some report only when the project is built.
+
+A rule that judges a single expression decides as soon as that expression is written. AV0017, for example, sees an
+assignment and has everything it needs. A rule that compares one call against another cannot decide until every file
+has been read, because the call it is looking for may be in a file that is not open. AV0028 cannot report a sunset
+until it has seen every deprecation, and AV0027 reports because a call is missing, which is only known once there is
+nothing left to read.
+
+The rules that report only on build are AV0013, AV0015, AV0016, AV0018, AV0019, AV0020, AV0021, AV0022, AV0023,
+AV0024, AV0026, AV0027, AV0028, AV0029, AV0030, and AV0031. The rest report live in the editor.
+
+These rules also report live in an editor configured to analyze the whole solution rather than only the documents
+that are open:
+
+- **Visual Studio**: Tools → Options → Text Editor → C# → Advanced → *Run background code analysis for* →
+ **Entire solution**
+- **Rider**: enable *Solution-Wide Analysis*
+- **Visual Studio Code**: `"dotnet.backgroundAnalysis.analyzerDiagnosticsScope": "fullSolution"`
+
+## Suppression
+
+A single rule is configured the same way as any other analyzer, by severity in an `.editorconfig` file:
+
+```ini
+[*.cs]
+dotnet_diagnostic.AV0028.severity = none
+```
+
+All of the rules are turned off at once with a property, which removes the analyzers instead of silencing each rule:
+
+```xml
+
+ false
+
+```
+
+Set it in `Directory.Build.props` to apply it to every project in a solution. The rules are enabled unless the
+property is `false`.
+
+>[!IMPORTANT]
+>`ExcludeAssets="analyzers"` on a package reference does not turn the rules off. The packages that ship the
+>analyzers are also reached through the dependencies of other packages, and NuGet combines the assets from every
+>path that reaches a package, so an exclusion on one path is undone by another that has none. Use the property
+>above instead.
diff --git a/wiki/src/getting-started.md b/wiki/src/getting-started.md
new file mode 100644
index 000000000..069388779
--- /dev/null
+++ b/wiki/src/getting-started.md
@@ -0,0 +1,50 @@
+# Getting Started
+
+The simplest way to get started is to install the library.
+
+```bash
+dotnet add package Asp.Versioning.Http
+```
+
+## Example
+
+The following example sets up the ubiquitous "Hello World" service with two versions of the same endpoint. The `version`
+parameter resolves to the request API version and echoes it back to illustrate different endpoints were reached.
+
+```c#
+using Asp.Versioning;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddProblemDetails();
+builder.Services.AddApiVersioning();
+
+var app = builder.Build();
+var helloworld = app.NewVersionedApi().MapGroup("/helloworld");
+var v1 = helloworld.MapGroup("/").HasApiVersion(1.0);
+var v2 = helloworld.MapGroup("/").HasApiVersion(2.0);
+
+// GET /helloworld?api-version=1.0
+v1.MapGet("/", (ApiVersion version) => $"Hello World! (v{version})");
+
+// GET /helloworld?api-version=2.0
+v2.MapGet("/", (ApiVersion version) => $"Hello World! (v{version})");
+
+app.Run();
+```
+
+To run the example, use:
+
+```bash
+dotnet run
+```
+
+and then navigate to the endpoint or use:
+
+```bash
+curl https://localhost:5001/helloworld?api-version=1.0
+```
+
+```bash
+curl https://localhost:5001/helloworld?api-version21.0
+```
\ No newline at end of file
diff --git a/wiki/src/icons/error.md b/wiki/src/icons/error.md
new file mode 100644
index 000000000..a108947d0
--- /dev/null
+++ b/wiki/src/icons/error.md
@@ -0,0 +1 @@
+
diff --git a/wiki/src/icons/info.md b/wiki/src/icons/info.md
new file mode 100644
index 000000000..8a3c6ba6f
--- /dev/null
+++ b/wiki/src/icons/info.md
@@ -0,0 +1 @@
+
diff --git a/wiki/src/icons/warning.md b/wiki/src/icons/warning.md
new file mode 100644
index 000000000..1ca5ddf40
--- /dev/null
+++ b/wiki/src/icons/warning.md
@@ -0,0 +1 @@
+
diff --git a/wiki/src/logo-dark.svg b/wiki/src/logo-dark.svg
new file mode 100644
index 000000000..bccb6a278
--- /dev/null
+++ b/wiki/src/logo-dark.svg
@@ -0,0 +1,133 @@
+
+
+
+
diff --git a/wiki/src/logo.svg b/wiki/src/logo.svg
new file mode 100644
index 000000000..4b3a08ad4
--- /dev/null
+++ b/wiki/src/logo.svg
@@ -0,0 +1,133 @@
+
+
+
+
diff --git a/wiki/src/shared/config/conventions-post.md b/wiki/src/shared/config/conventions-post.md
new file mode 100644
index 000000000..caf3d063f
--- /dev/null
+++ b/wiki/src/shared/config/conventions-post.md
@@ -0,0 +1,78 @@
+### Namespace
+
+This built-in convention allows you to version your controllers by the .NET namespace they reside in when applied.
+
+```c#
+options.Conventions.Add( new VersionByNamespaceConvention() );
+```
+
+The defined namespace name must conform to the API version format so that it can be parsed. The language-neutral syntax
+is:
+
+```ebnf
+letter = "A" | "B" | "C" | "D" | "E" | "F" | "G"
+ | "H" | "I" | "J" | "K" | "L" | "M" | "N"
+ | "O" | "P" | "Q" | "R" | "S" | "T" | "U"
+ | "V" | "W" | "X" | "Y" | "Z" | "a" | "b"
+ | "c" | "d" | "e" | "f" | "g" | "h" | "i"
+ | "j" | "k" | "l" | "m" | "n" | "o" | "p"
+ | "q" | "r" | "s" | "t" | "u" | "v" | "w"
+ | "x" | "y" | "z" ;
+
+prefix = "v" | "V" ;
+
+positive = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
+
+digit = "0" | positive ;
+
+day = ( [ "0" ] positive ) | ( "1" | "2" ) digit | ( "3" ( "0" | "1" ) ) ;
+
+month = ( [ "0" ] positive ) | ( "1" ( "0" | "1" | "2" ) ) ;
+
+year = 4 * digit ;
+
+api-version = prefix ( ( year "_" month "_" day ) | ( digit [ "_" digit ] ) ) [ "_" { letter } ] ;
+
+```
+
+The `.` character is considered a namespace delimiter in many programming languages. This character must be changed to
+`_` so that newly added files have the correct format. In addition, most languages do not allow the name of a namespace
+to start with a number. Since a leading character is required, the first character **must** be `v` or `V`. There is no
+requirement as to where the API version must appear in the namespace.
+
+By default, API versions derived from a namespace will be considered supported. If the controller is decorated with the
+`ObsoleteAttribute`, then the API version inferred from the containing namespace will be considered deprecated.
+
+**Examples**
+
+- `Contoso.Api.v1.Controllers` → 1.0
+- `Contoso.Api.v1_1.Controllers` → 1.1
+- `Contoso.Api.v0_9_Beta.Controllers` → 0.9-Beta
+- `Contoso.Api.v20180401.Controllers` → 2018-04-01
+- `Contoso.Api.v2018_04_01.Controllers` → 2018-04-01
+- `Contoso.Api.v2018_04_01_Beta.Controllers` → 2018-04-01-Beta
+- `Contoso.Api.v2018_04_01_1_0_Beta.Controllers` → 2018-04-01.1.0-Beta
+
+```
+Contoso
+└ Api
+ ├─ v1
+ │ └ Controllers
+ ├─ v2
+ │ └ Controllers
+ └─ v2_5
+ └ Controllers
+```
+> _Figure 1:_ Sample folder layout with numeric API versions
+
+```
+Contoso
+└ Api
+ ├─ v2018_07_01
+ │ └ Controllers
+ ├─ v2018_08_01
+ │ └ Controllers
+ └─ v2018_09_01
+ └ Controllers
+```
+> _Figure 2:_ Sample folder layout with date API versions
\ No newline at end of file
diff --git a/wiki/src/shared/config/conventions-pre.md b/wiki/src/shared/config/conventions-pre.md
new file mode 100644
index 000000000..71d4209de
--- /dev/null
+++ b/wiki/src/shared/config/conventions-pre.md
@@ -0,0 +1,11 @@
+# API Version Conventions
+
+API version conventions allow you to specify API version information for your services without having to use .NET
+attributes. There are a number of reasons why you might choose this option. The most common reasons are:
+
+- Centralized management and application of all service API versions
+- Apply API versions to services defined by controllers in external .NET assemblies
+- Dynamically apply API versions from external sources; for example, from configuration
+
+Instead of applying `[ApiVersion]` to the controller, we can instead choose to define a convention in the
+API versioning options.
\ No newline at end of file
diff --git a/wiki/src/shared/config/options.md b/wiki/src/shared/config/options.md
new file mode 100644
index 000000000..b558e530a
--- /dev/null
+++ b/wiki/src/shared/config/options.md
@@ -0,0 +1,94 @@
+# API Versioning Options
+
+The API Versioning options allows you to configure, customize, and extend the default behaviors when you add API
+versioning to your application.
+
+`ApiVersioningOptions` has the following configuration settings:
+
+- [ApiVersionReader](reader.md)
+- [ApiVersionSelector][IApiVersionSelector]
+- [DefaultApiVersion](#default-api-version)
+- [AssumeDefaultVersionWhenUnspecified](#assume-default-version-when-unspecified)
+- [ReportApiVersions](#report-api-versions)
+- [Policies]
+- [Conventions]
+- [RouteConstraintName](#route-constraint-name)
+- [UnsupportedApiVersionStatusCode](#unsupported-api-version-status-code)
+
+### Assume Default Version When Unspecified
+
+This option enables support for clients to make requests with implicit API versioning. This option is disabled by
+default, which means that all clients must send requests with an explicit API version. Services will respond to client
+requests that do not specify an API version with either HTTP status code `400` (Bad Request) or HTTP status code `404`
+(Not Found), depending whether the requested route exists.
+
+This option should only be enabled when supporting legacy services that did not previously support API versioning.
+Forcing existing clients to specify an explicit API version for an existing service introduces a breaking change.
+Conceptually, clients in this situation are bound to some API version of a service, but they don't know what it is and
+never explicit request it.
+
+When this option is enabled, clients will be able to make a request without specifying a specific API version. The API
+version of the service that is selected will be based on the configured [IApiVersionSelector].
+
+### Default API Version
+
+This option defines what the default `ApiVersion` will be for a service without explicit API version information. This
+is useful for services that use implicit API versioning in their initial release. This value can also be used for
+services that may be defined in external assemblies that are not decorated with any API version information. The
+configured, default value is `1.0`.
+
+```c#
+AddApiVersioning( options => options.DefaultApiVersion = new ApiVersion( 2.0 ) );
+```
+
+### Report API Versions
+
+This option enables sending the `api-supported-versions` and `api-deprecated-versions` HTTP header in responses. When
+this option is enabled, it will add the `ReportApiVersionsAttribute` as a global action filter to the application
+configuration. If there are any deprecation or sunset [policies](#policies) defined, they will also be included in the
+response headers. This option is disabled by default.
+
+```c#
+AddApiVersioning( options => options.ReportApiVersions = true );
+```
+
+### Conventions
+
+This option allows you to construct API version conventions for each of your services as opposed to using .NET
+attributes. You can also choose to additionally use .NET attributes and the union of both sets of defined API version
+information will be applied. The default convention builders can be extended and/or replaced in this option. For more
+information on using conventions see the [API version conventions][Conventions] topic.
+
+### Route Constraint Name
+
+This option allows you to change the name of the API version route constraint. The default name is `"apiVersion"`.
+
+### Policies
+
+This option allows you to define [API versioning policies][Policies]. This is primarily used to define _deprecation_ and
+_sunset_ policies about when an API. Related links, such as to a public policy web page, can also be reported that may
+be useful to clients for more information about your API policies.
+
+### Unsupported API Version Status Code
+
+This option allows you to configure the HTTP status code used when an unsupported API version is requested. The default
+value is `400` (Bad Request).
+
+While any HTTP status code can be used, the following are the most sensible:
+
+| Status Code | Meaning | Description |
+| ----------- | -------------- | ----------- |
+| 400 | Bad Request | The API doesn't support this version |
+| 404 | Not Found | The API doesn't exist |
+| 501 | Not Implemented | The API isn't implemented |
+
+#### Remarks
+
+Regardless of the configured option, when versioning by:
+
+- URL segment, `404` is always returned
+- media type, `406` or `415` is always returned
+
+[IApiVersionSelector]: selector.md
+[Conventions]: conventions.md
+[Policies]: ../how-to/version-policies.md
\ No newline at end of file
diff --git a/wiki/src/shared/config/reader.md b/wiki/src/shared/config/reader.md
new file mode 100644
index 000000000..4794771b5
--- /dev/null
+++ b/wiki/src/shared/config/reader.md
@@ -0,0 +1,115 @@
+# API Version Reader
+
+The `IApiVersionReader` interface defines the behavior of how an API version is read in its raw, unparsed form from the
+current HTTP request. There are multiple methods for reading an API version provided out-of-the-box or you can implement
+your own. The default, configured API version reader is a composed instance `QueryStringApiVersionReader` and
+`UrlSegmentApiVersionReader`.
+
+## Query String
+
+The `QueryStringApiVersionReader` reads the requested API version from the requested query string. The default query
+string parameter name is **api-version**. The constructor for this class accepts the name of a query string parameter
+so that an alternate query string parameter can be used.
+
+```c#
+// svc?api-version=2.0
+AddApiVersioning( options => options.ApiVersionReader = new QueryStringApiVersionReader() );
+```
+
+```c#
+// svc?v=2.0
+AddApiVersioning( options => options.ApiVersionReader = new QueryStringApiVersionReader( "v" ) );
+```
+
+## Media Type
+
+The `MediaTypeApiVersionReader` reads the requested API version from a HTTP media type request header. The supported
+headers are **Content-Type** and **Accept**. If both headers are present, then **Content-Type** is preferred. If the
+**Accept** header specifies qualities, then the API version associated with the highest quality is selected. This
+behavior is independent of media type negotiation. The default media type parameter is `"v"`, but you may specify an
+alternate name. This method of API versioning does not conform to the [Microsoft REST Guidelines]; however, it is
+generally accepted as a fully REST-compliant method of versioning.
+
+```c#
+// Content-Type: application/json;v=2.0
+AddApiVersioning( options => options.ApiVersionReader = new MediaTypeApiVersionReader() );
+```
+
+```c#
+// Content-Type: application/json;version=2.0
+AddApiVersioning( options => options.ApiVersionReader = new MediaTypeApiVersionReader( "version" ) );
+```
+
+The `MediaTypeApiVersionReaderBuilder` is also available with additional features that allow:
+
+- Define multiple media type parameters
+- Mutually include specific media types
+- Mutually exclude specific media types
+- Match media types by template
+- Match media types by pattern
+- Disambiguate between multiple API versions
+
+```c#
+// Accept: application/json;v=2.0
+AddApiVersioning(
+ options =>
+ {
+ var builder = new MediaTypeApiVersionReaderBuilder();
+
+ options.ApiVersionReader = builder.Parameter( "v" )
+ .Include( "application/json" )
+ .Build();
+ } );
+```
+
+```c#
+// Accept: application/vnd.my.company.v1+json
+AddApiVersioning(
+ options =>
+ {
+ var builder = new MediaTypeApiVersionReaderBuilder();
+
+ options.ApiVersionReader = builder.Template( "application/vnd.my.company.v{version}+json" )
+ .Build();
+ } );
+```
+
+## Header
+
+The `HeaderApiVersionReader` reads the requested API version from a HTTP request header. There is no default or standard
+HTTP header. You must define which HTTP header name or names contain the API version information. This method of API
+versioning does not conform to the [Microsoft REST Guidelines].
+
+```c#
+AddApiVersioning( options => options.ApiVersionReader = new HeaderApiVersionReader( "api-version" ) );
+```
+
+## URL Path Segment
+
+The `UrlSegmentApiVersionReader` reads the requested API version from a URL path segment. Extraction of the value is
+dependent upon the `ApiVersionRouteConstraint` which is matched by the `ApiVersioningOptions.RouteConstraintName`
+property.
+
+```c#
+AddApiVersioning( options => options.ApiVersionReader = new UrlSegmentApiVersionReader() );
+```
+
+>[!WARNING]
+>This method of API versioning violates the REST _Uniform Interface_ constraint and is the slowest of all versioning
+>methods because the requested value cannot always easily be extracted from the URL path segment. If you're creating a
+>new API, consider using query string or media type versioning instead.
+
+## Composition
+
+Multiple `IApiVersionReader` implementations can be combined using composition instead of inheritance. For convenience,
+you can use `ApiVersionReader.Combine` to compose multiple API version reading styles.
+
+```c#
+AddApiVersioning(
+ options => options.ApiVersionReader = ApiVersionReader.Combine(
+ new QueryStringApiVersionReader(),
+ new HeaderApiVersionReader() { HeaderNames = { "x-ms-api-version" } } ) );
+```
+
+
+[Microsoft REST Guidelines]: https://github.com/Microsoft/api-guidelines/blob/master/Guidelines.md#12-versioning
\ No newline at end of file
diff --git a/wiki/src/shared/config/selector.md b/wiki/src/shared/config/selector.md
new file mode 100644
index 000000000..7fd8190e6
--- /dev/null
+++ b/wiki/src/shared/config/selector.md
@@ -0,0 +1,57 @@
+# API Version Selector
+
+The `IApiVersionSelector` interface defines the behavior of how an API version is selected for a given request context.
+This service is typically only used when a client has not requested an explicit API version and the
+`AssumeDefaultVersionWhenUnspecified` option is enabled. The role of the API version selector is to select the
+appropriate API version given the current request and a model of available API versions.
+
+>[!NOTE]
+>Although the `IApiVersionSelector` can be used for other scenarios, it is currently only utilized when no API version
+>is requested by a client and the server allows this behavior. The selector provides the rules that selects the most
+>appropriate API version according to the server. There is no built-in capability to ignore an API version explicitly
+>requested by a client.
+
+There are four API version selectors provided out-of-the-box or you can implement your own. The default, configured API
+version selector is `DefaultApiVersionSelector`.
+
+## Default
+
+The `DefaultApiVersionSelector` always selects the configured `DefaultApiVersion`, regardless of the request or
+available API version information.
+
+## Constant
+
+The `ConstantApiVersionSelector` always selects a user-defined API version, regardless of the request or available API
+version information.
+
+```c#
+AddApiVersioning(
+ options => options.ApiVersionSelector =
+ new ConstantApiVersionSelector(
+ new ApiVersion( new( 2016, 7, 1 ) ) );
+```
+
+## Current
+The `CurrentImplementationApiVersionSelector` selects the maximum API version available which does not have a version
+status. If no match is found, it falls back to the configured `DefaultApiVersion`. An an example, if the versions `1.0`,
+`2.0`, and `3.0-alpha` are available, then `2.0` will be selected because it's the highest, implemented or released API
+version.
+
+```c#
+AddApiVersioning(
+ options => options.ApiVersionSelector =
+ new CurrentImplementationApiVersionSelector( options ) );
+```
+
+## Lowest
+The `LowestImplementedApiVersionSelector` selects the minimum API version available which does not have a version
+status. If no match is found, it falls back to the configured `DefaultApiVersion`. As an example, if the versions
+`0.9-beta`, `1.0`, `2.0`, and `3.0-alpha` are available, then `1.0` will be selected because it's the lowest,
+implemented or released API version. Your services must be decorated with one or more API versions for the selector to
+work effectively or it will always select the configured `DefaultApiVersion`.
+
+```c#
+AddApiVersioning(
+ options => options.ApiVersionSelector =
+ new LowestImplementedApiVersionSelector( options ) );
+```
\ No newline at end of file
diff --git a/wiki/src/shared/docs/odata-options-attributes.md b/wiki/src/shared/docs/odata-options-attributes.md
new file mode 100644
index 000000000..24a63a39f
--- /dev/null
+++ b/wiki/src/shared/docs/odata-options-attributes.md
@@ -0,0 +1,22 @@
+## Attribute Model
+
+The attribute model relies on _Model Bound_ settings attributes and the `EnableQueryAttribute`. The
+`EnableQueryAttribute` indicates API-specific options that might be too restrictive or not applicable to specific
+models. Consider the following model and controller definitions.
+
+```c#
+using System;
+using Microsoft.AspNet.OData.Query;
+using static Microsoft.AspNet.OData.Query.SelectExpandType;
+
+[Select]
+[Select( "effectiveDate", SelectType = Disabled )]
+public class Order
+{
+ public int Id { get; set; }
+ public DateTime CreatedDate { get; set; } = DateTime.Now;
+ public DateTime EffectiveDate { get; set; } = DateTime.Now;
+ public string Customer { get; set; }
+ public string Description { get; set; }
+}
+````
\ No newline at end of file
diff --git a/wiki/src/shared/docs/odata-options-mid.md b/wiki/src/shared/docs/odata-options-mid.md
new file mode 100644
index 000000000..2257f66dd
--- /dev/null
+++ b/wiki/src/shared/docs/odata-options-mid.md
@@ -0,0 +1,71 @@
+### Conventions
+
+If you only define OData query options imperatively using `ODataQuerySettings` and `ODataValidationSettings`, then
+there are no attributes or Entity Data Model (EDM) data annotations to explore the query options from. In this scenario,
+you can use the conventions in the API Explorer extensions to document any query option setting that can be defined by
+`ODataQuerySettings` or `ODataValidationSettings`.
+
+```c#
+.AddODataApiExplorer( options =>
+{
+ var queryOptions = options.QueryOptions;
+
+ queryOptions.Controller()
+ .Action( c => c.Get( default( ODataQueryOptions ) ) )
+ .Allow( Skip | Count )
+ .AllowTop( 100 );
+
+ queryOptions.Controller()
+ .Action( c => c.Get( default( ODataQueryOptions ) ) )
+ .Allow( Skip | Count )
+ .AllowTop( 100 );
+} );
+```
+
+The OData API Explorer will discover and add the following parameters for an entity set query:
+
+| Name | Parameter Type | Data Type | Description |
+| ---------- | --------------- | --------- | ----------- |
+| `$select` | query | string | Limits the properties returned in the result. |
+| `$orderby` | query | string | Specifies the order in which results are returned. The allowed properties are: firstName, lastName. |
+| `$top` | query | integer | Limits the number of items returned from a collection. The maximum value is 100. |
+| `$skip` | query | integer | Excludes the specified number of items of the queried collection from the result. |
+
+### Parameters
+
+While each OData query option has a default provided description, the description can be changed by providing a custom
+description. Descriptions are generated by the `IODataQueryOptionDescriptionProvider`:
+
+```c#
+public interface IODataQueryOptionDescriptionProvider
+{
+ string Describe(
+ AllowedQueryOptions queryOption,
+ ODataQueryOptionDescriptionContext context );
+}
+```
+
+>[!NOTE]
+>Although `AllowedQueryOptions` is a bitwise enumeration, only a single query option value is ever passed
+
+You can change the default description by implementing your own `IODataQueryOptionDescriptionProvider` or extending the
+built-in `DefaultODataQueryOptionDescriptionProvider`. The implementation is updated in the OData API Explorer options using:
+
+```c#
+AddODataApiExplorer( options => options.QueryOptions.DescriptionProvider = new MyQueryOptionDescriptor() );
+```
+
+### Custom Conventions
+
+You can also define custom conventions via the `IODataQueryOptionsConvention` interface and add them to the builder:
+
+```c#
+public interface IODataQueryOptionsConvention
+{
+ void ApplyTo( ApiDescription apiDescription );
+}
+```
+
+```c#
+AddODataApiExplorer( options => options.QueryOptions.Add( new MyODataQueryOptionsConvention() ) );
+```
\ No newline at end of file
diff --git a/wiki/src/shared/docs/odata-options-model-bound.md b/wiki/src/shared/docs/odata-options-model-bound.md
new file mode 100644
index 000000000..819437b6f
--- /dev/null
+++ b/wiki/src/shared/docs/odata-options-model-bound.md
@@ -0,0 +1,52 @@
+## Convention Model
+
+The convention model relies on _Model Bound_ settings via the fluent API of the `ODataModelBuilder`and the
+`EnableQueryAttribute`. The `EnableQueryAttribute` indicates API-specific options that might be too restrictive or
+nonapplicable to specific models. Consider the following model and controller definitions.
+
+```c#
+public class Person
+{
+ public int Id { get; set; }
+ public string FirstName { get; set; }
+ public string LastName { get; set; }
+ public string Email { get; set; }
+ public string Phone { get; set; }
+}
+
+public class PersonModelConfiguration : IModelConfiguration
+{
+ public void Apply( ODataModelBuilder builder, ApiVersion apiVersion, string routePrefix )
+ {
+ var person = builder.EntitySet( "People" ).EntityType;
+
+ person.HasKey( p => p.Id );
+
+ // configure model bound conventions
+ person.Select().OrderBy( "firstName", "lastName" );
+
+ if ( apiVersion < ApiVersions.V3 )
+ {
+ person.Ignore( p => p.Phone );
+ }
+
+ if ( apiVersion <= ApiVersions.V1 )
+ {
+ person.Ignore( p => p.Email );
+ }
+
+ if ( apiVersion > ApiVersions.V1 )
+ {
+ var function = person.Collection.Function( "NewHires" );
+
+ function.Parameter( "Since" );
+ function.ReturnsFromEntitySet( "People" );
+ }
+
+ if ( apiVersion > ApiVersions.V2 )
+ {
+ person.Action( "Promote" ).Parameter( "title" );
+ }
+ }
+}
+````
\ No newline at end of file
diff --git a/wiki/src/shared/docs/odata-options-partial-post.md b/wiki/src/shared/docs/odata-options-partial-post.md
new file mode 100644
index 000000000..1ddf5e4d8
--- /dev/null
+++ b/wiki/src/shared/docs/odata-options-partial-post.md
@@ -0,0 +1,72 @@
+```c#
+[ApiVersion( 1.0 )]
+[ApiController]
+[Route( "[controller]" )]
+public class BooksController : ControllerBase
+{
+ [HttpGet]
+ [Produces( "application/json" )]
+ [ProducesResponseType( typeof( IEnumerable ), 200 )]
+ public IActionResult Get( ODataQueryOptions options ) =>
+ Ok( options.ApplyTo( books.AsQueryable() ) );
+}
+```
+
+When OData query capabilities are used this way, query options can be discovered via `EnableQueryAttribute` or via the
+API Explorer extensions. Unfortunately, these are both ultimately limited to what can be expressed via
+`ODataQuerySettings` and `ODataValidationSettings`, which does not cover the gambit of all possible OData query options;
+for example, the allowable `$filter` properties. These other properties can be configured via _Model Bound_ settings,
+but without using the full OData stack there is no Entity Data Model (EDM) to retrieve these annotations from.
+
+To address this limitation, OData query options can now also be explored using an ad hoc EDM. This EDM only exists for
+the purposes of query option exploration. Using an ad hoc EDM does not opt into other OData feature and only exists
+during exploration. Applying _Model Bound_ settings to an ad hoc model is almost identical to the normal method. If you
+want to use attributes, just apply them to your model.
+
+```c#
+[Filter( "author", "published" )]
+public class Book
+{
+ public string Id { get; set; }
+ public string Author { get; set; }
+ public string Title { get; set; }
+ public int Published { get; set; }
+}
+```
+
+Every action that appears to be _OData-like_ will automatically be discovered and its model explored. Discovered models
+are registered as a complex type by default. If you prefer to use entities or need additional control over the applied
+settings, you can use conventions as well.
+
+```c#
+AddODataApiExplorer(
+ options =>
+ {
+ options.AdHocModelBuilder.DefaultModelConfiguration = (builder, version, prefix) =>
+ {
+ builder.ComplexType().Filter( "author", "published" );
+ };
+ }
+)
+```
+
+The **AdHocModelBuilder** is part of the `ODataApiExplorerOptions` as opposed to `ODataApiVersioningOptions`. If you
+have numerous models and would like to break the settings into different configurations, you can still use
+`IModelConfiguration`. `IModelConfiguration` instances are automatically discovered and injected the same way as they
+are when using the full OData stack.
+
+```c#
+public class BookConfiguration : IModelConfiguration
+{
+ public void Apply( ODataModelBuilder builder, ApiVersion apiVersion, string? routePrefix )
+ {
+ builder.EntitySet( "Books" ).EntityType.Filter( "author", "published" );
+ }
+}
+```
+_Model configuration for an ad hoc model; the `routePrefix` will always be `null`._
+
+There is no distinction between an `IModelConfiguration` that is used for ad hoc EDM exploration versus normal model
+registration. It is unlikely that you would be mixing the full and partial OData stack. If you are mixing use cases,
+then you can tell the difference between models from the provided API version. There should be no scenario where a
+model is registered two different ways for the same API version.
\ No newline at end of file
diff --git a/wiki/src/shared/docs/odata-options-partial-pre.md b/wiki/src/shared/docs/odata-options-partial-pre.md
new file mode 100644
index 000000000..9a9565621
--- /dev/null
+++ b/wiki/src/shared/docs/odata-options-partial-pre.md
@@ -0,0 +1,4 @@
+## Partial OData
+
+OData supports query capabilities without using the full OData stack. Consider the following controller, which is not
+an OData controller, but uses OData query options:
\ No newline at end of file
diff --git a/wiki/src/shared/docs/odata-options-post.md b/wiki/src/shared/docs/odata-options-post.md
new file mode 100644
index 000000000..046a77edb
--- /dev/null
+++ b/wiki/src/shared/docs/odata-options-post.md
@@ -0,0 +1,33 @@
+### Use Qualified Names
+
+The OData API Explorer is responsible for building URLs that refer to your entity sets, functions, and actions. This
+property determines whether the constructed URLs use qualified names. The default value is `false`. The
+`ODataUriResolver` instance configured for your application must be configured to match the generated URLs
+(ex: `UnqualifiedCallAndEnumPrefixFreeResolver`).
+
+### Query Options
+
+This option allows you to configure OData query options. The configuration for query options can be expressed purely by
+convention, through the use of supported OData query attribute, or both. The default behavior will always apply
+conventions from OData query attributes without additional configuration. For more information see the
+[OData query options](odata-query-options.md) topic.
+
+### Metadata Options
+
+This option allows you to determine whether the OData metadata (`$metadata`) and service document (`/`) are explored as
+available endpoints. The available options are: `None`, `ServiceDocument`, `Metadata`, or `All`. The default value is
+`None`.
+
+### Ad Hoc Model Builder
+
+This property returns an `VersionedODataModelBuilder` that can be used for building ad hoc Entity Data Models (EDMs)
+that are used when defining the query options for APIs that do **not** use the full OData stack. Some OData query
+options can **only** be set via _Model Bound_ settings. This builder constructs an ad hoc EDM that will contain those
+settings solely for the purposes of API exploration and without opting into any other OData-specific features. For more
+information see the [OData query options](odata-query-options.md) topic.
+
+### Related Entity Id Parameter Description
+
+This option enables you to specify the description for OData related entity links. The default value is
+`"The identifier of the related entity."` OData related entity links appear in `$ref` requests. This description is
+used to describe dynamic parameters such as the `$id` query parameter.
\ No newline at end of file
diff --git a/wiki/src/shared/docs/odata-options-pre.md b/wiki/src/shared/docs/odata-options-pre.md
new file mode 100644
index 000000000..d07b4e7d1
--- /dev/null
+++ b/wiki/src/shared/docs/odata-options-pre.md
@@ -0,0 +1,9 @@
+# OData Options
+
+The `ODataApiExplorerOptions` extends the above options with the following additional settings:
+
+- [UseQualifiedNames](#use-qualified-names)
+- [QueryOptions](#query-options)
+- [RelatedEntityIdParameterDescription](#related-entity-id-parameter-description)
+- [MetadataOptions](#metadata-options)
+- [AdHocModelBuilder](#ad-hoc-model-builder)
\ No newline at end of file
diff --git a/wiki/src/shared/docs/odata-options-query.md b/wiki/src/shared/docs/odata-options-query.md
new file mode 100644
index 000000000..15f4a0497
--- /dev/null
+++ b/wiki/src/shared/docs/odata-options-query.md
@@ -0,0 +1,12 @@
+# Query Options
+
+OData query option conventions allow you to specify information for your OData services without having to rely solely
+on .NET attributes. There are a number of reasons why you might uses these conventions. The most common reasons are:
+
+- Centralized management and application of all OData query options
+- Define OData query options that cannot be expressed with any OData query attributes
+- Apply OData query options to services defined by controllers in external .NET assemblies
+
+The parameter names generated are based on the name of the OData query option and the configuration of the
+`ODataUriResolver`. OData supports query options without the system `$` prefix. This is enabled or disabled by the
+`ODataUriResolver.EnableNoDollarQueryOptions` property.
\ No newline at end of file
diff --git a/wiki/src/shared/docs/options-post.md b/wiki/src/shared/docs/options-post.md
new file mode 100644
index 000000000..bd0c935ab
--- /dev/null
+++ b/wiki/src/shared/docs/options-post.md
@@ -0,0 +1,60 @@
+### Group Name Format
+
+The group name format is the format string that is applied to the current API version being explored. This resultant,
+formatted string is used as the group name for the explored API. The group name is often used in tools such as OpenAPI
+to logically group APIs together. For more information and examples on the format specifiers for an API version, see
+the [custom API version format strings][version-format] topic.
+
+### Substitute in URL
+
+This option will instruct the API explorer to substitute API version parameters that are in the route template with the
+corresponding API version value. When an API version parameter value is substituted, that parameter is also removed
+from the parameters associated with the API description. This option is useful for service authors that version by URL
+segment and want the API version value automatically populated. For example, the route template
+`api/v{version}/resource` for API version 1.0 will become `api/v1/resource` and the API version parameter will be
+removed. The default value is `false`.
+
+### Substitution Format
+
+This option is meant to be paired with the **SubstituteApiVersionInUrl** option. This affords service authors control
+over how the API version value is formatted before being substituted into route templates. The default value is `VVV`,
+but it can be any value according to the available [formatting options][version-format].
+
+### Default API Version
+
+This option defines what the default `ApiVersion` will be for a service without explicit API version information. The
+default value is derived from [ApiVersioningOptions.DefaultApiVersion] and should not be changed.
+
+### Default API Version Parameter Description
+
+This option defines what the default description for API version parameters will be. The default value is:
+`"The requested API version"`.
+
+### Assume Default Version When Unspecified
+
+This option enables support for clients to make requests with implicit API versioning. This option is used during API
+exploration to determine whether the API version parameter is required. The default value is derived from
+[ApiVersioningOptions.AssumeDefaultVersionWhenUnspecified] and should not be changed.
+
+### Parameter Source
+
+This option configures how the API exploration process discovers API version parameters. The default value derives from
+[ApiVersioningOptions.ApiVersionReader] and should not be changed.
+
+### Add Parameter When Version-Neutral
+
+This options let's you define whether an API version parameter is generated for version-neutral APIs. A version-neutral
+API does not require an API version; however, you may not want a client to know the API is version-neutral. By setting
+`AddApiVersionParametersWhenVersionNeutral = true`, an API version parameter will be explored, even though it is not
+required. The default value is `false`.
+
+### Route Constraint Name
+
+This option defines the name of the route constraint used in route templates. The default value derives from
+[ApiVersioningOptions.RouteConstraintName] and should not be changed.
+
+[version-format]: ../version-format.md#custom
+[ApiVersioningOptions.DefaultApiVersion]: ../config/options.md#default-api-version
+[ApiVersioningOptions.AssumeDefaultVersionWhenUnspecified]: ../config/options.md#assume-default-version-when-unspecified
+[ApiVersioningOptions.ApiVersionReader]: ../config/options.md
+[ApiVersioningOptions.RouteConstraintName]: ../config/options.md#route-constraint-name
\ No newline at end of file
diff --git a/wiki/src/shared/docs/options-pre.md b/wiki/src/shared/docs/options-pre.md
new file mode 100644
index 000000000..06bedd4fc
--- /dev/null
+++ b/wiki/src/shared/docs/options-pre.md
@@ -0,0 +1,17 @@
+# API Explorer Options
+
+The API Explorer options allows you to configure, customize, and extend the default behaviors when you add API
+exploration support. The configuration options are specified by providing a callback to the appropriate extension
+method:
+
+The `ApiExplorerOptions` have the following configuration settings:
+
+- [GroupNameFormat](#group-name-format)
+- [SubstituteApiVersionInUrl](#substitute-api-version-in-url)
+- [SubstitutionFormat](#substitution-format)
+- [DefaultApiVersion](#default-api-version)
+- [DefaultApiVersionParameterDescription](#default-api-version-parameter-description)
+- [AssumeDefaultVersionWhenUnspecified](#assume-default-version-when-unspecified)
+- [ApiVersionParameterSource](#api-version-parameter-source)
+- [AddApiVersionParametersWhenVersionNeutral](#add-api-version-parameters-when-version-neutral)
+- [RouteConstraintName](#route-constraint-name)
\ No newline at end of file
diff --git a/wiki/src/shared/docs/overview-pre.md b/wiki/src/shared/docs/overview-pre.md
new file mode 100644
index 000000000..68543c619
--- /dev/null
+++ b/wiki/src/shared/docs/overview-pre.md
@@ -0,0 +1,10 @@
+# API Documentation
+
+Adding documentation is often the final, pivotal step in making your versioned services available to clients and fosters
+their utilization. While there are many approaches to documenting your services, OpenAPI (formerly Swagger) has quickly
+become the de facto method for describing REST services.
+
+The ASP.NET API versioning project provides several new API explorer implementations that make it easy to add versioning
+into your OpenAPI configurations. Each of these API explorers do all of the heavy lifting to discover and collate your
+REST services by API version. They do not directly rely on nor use any external OpenAPI libraries so that you can use
+them for other scenarios as well.
\ No newline at end of file
diff --git a/wiki/src/shared/docs/swashbuckle-pre.md b/wiki/src/shared/docs/swashbuckle-pre.md
new file mode 100644
index 000000000..41a231496
--- /dev/null
+++ b/wiki/src/shared/docs/swashbuckle-pre.md
@@ -0,0 +1,8 @@
+# Swashbuckle Integration
+
+Although the API explorers for API versioning provide all of the necessary information, there is select information
+that OpenAPI (formerly Swagger) and Swashbuckle will not wire up for you. This includes iterating through all the
+available API versions so that they don't have to be imperatively declared and changed one at a time. Fortunately,
+bridging this gap is really easy to achieve using Swashbuckle's extensibility model. The following are simple
+`IOperationFilter` implementations that leverage the metadata provided by the corresponding API explorer to fill in
+these gaps.
\ No newline at end of file
diff --git a/wiki/src/shared/errors-pre.md b/wiki/src/shared/errors-pre.md
new file mode 100644
index 000000000..e51342c39
--- /dev/null
+++ b/wiki/src/shared/errors-pre.md
@@ -0,0 +1,102 @@
+# Error Responses
+
+There are several built-in error responses. The body of each error response complies with [RFC 7807: Problem Details].
+
+>[!NOTE]
+>In earlier versions, the error responses bodies complied with the [Microsoft REST Guidelines error response format],
+which is itself the error response format used by the OData protocol (see [OData JSON Format §21.1]). There wasn't a
+broad standard at that time, which made any common error response format sensible.
+
+Each problem detail also contains a `code` extension to retain a level of backward compatibility for clients that may
+have relied on that value. If you need to retain the old functionality, refer to
+[backward compatibility](#backward-compatibility) below.
+
+### Unspecified
+
+All versioned services require that an API version be specified. When a client makes a request without providing an API
+version, then the server will respond with a bad request. This behavior is typically not exhibited when the API is
+version-neutral or the `AssumeDefaultVersionWhenUnspecified` option is configured to true.
+
+| | |
+| - | - |
+| **Title** | Unspecified API version |
+| **Type** | https://docs.api-versioning.org/problems#unspecified |
+| **Status** | 400 |
+| **Detail** | An API version is required, but was not specified |
+| **Code** | ApiVersionUnspecified |
+
+### Unsupported
+
+When a client requested API version does not match any of the available controllers or their actions, then the server
+will respond with a problem. If the `ReportApiVersions` option is true, then the supported versions will be returned
+to the client in the `api-supported-versions` HTTP header.
+
+| | |
+| - | - |
+| **Title** | Unsupported API version |
+| **Type** | https://docs.api-versioning.org/problems#unsupported |
+| **Status** | 40012 |
+| **Detail** | The specified API version is not supported |
+| **Code** | UnsupportedApiVersion |
+
+>1: Defined by `ApiVersioningOptions.UnsupportedApiVersionStatusCode`
+2: The value is always `404` when versioning by URL segment
+
+### Invalid
+
+When a client makes a request with an API version, but the value is malformed or cannot be parsed, then the server will
+respond with a bad request. This typically occurs where the value contains incomplete version components or the
+date-only form is invalid (ex: 2016-02-30).
+
+| | |
+| - | - |
+| **Title** | Invalid API version |
+| **Type** | https://docs.api-versioning.org/problems#invalid |
+| **Status** | 400 |
+| **Detail** | An API version was specified, but it is invalid |
+| **Code** | InvalidApiVersion |
+
+### Ambiguous
+
+When a client requests a specific API version, the specified API version must be unambiguous to the server. A client is
+allowed to specify an API version more than once, but if the values are not identical, then the server will respond
+with a bad request.
+
+| | |
+| - | - |
+| **Title** | Ambiguous API version |
+| **Type** | https://docs.api-versioning.org/problems#ambiguous |
+| **Status** | 400 |
+| **Detail** | An API version was specified multiple times with different values |
+| **Code** | AmbiguousApiVersion |
+
+
+#### Examples
+
+```http
+GET /resource?api-version=1.0 HTTP/1.1
+host: localhost
+api-version: 1.0
+```
+_Figure 1: Multiple, unambiguous API versions requested_
+
+```http
+GET /resource?api-version=1.0 HTTP/1.1
+host: localhost
+api-version: 2.0
+```
+_Figure 2: Ambiguous API versions requested between in query string and headers_
+
+```http
+GET /resource?api-version=1.0&api-version=2.0 HTTP/1.1
+host: localhost
+```
+_Figure 3: Ambiguous API versions requested in the query string_
+
+```http
+GET /resource HTTP/1.1
+host: localhost
+api-version: 1.0
+api-version: 2.0
+```
+_Figure 4: Ambiguous API versions requested in the headers_
\ No newline at end of file
diff --git a/wiki/src/shared/ext/clients.md b/wiki/src/shared/ext/clients.md
new file mode 100644
index 000000000..141822c50
--- /dev/null
+++ b/wiki/src/shared/ext/clients.md
@@ -0,0 +1,198 @@
+# Versioned Clients
+
+
+The [Asp.Versioning.Http.Client] package brings client-side extensions that make your `HttpClient` instances
+_API version-aware_.
+
+## API Version Writer
+
+The reciprocal to [IApiVersionReader] is `IApiVersionWriter`. As the name implies, the `IApiVersionWriter` is
+responsible for writing the configured API version into outgoing requests. The default configured writer is the
+`QueryStringApiVersionWriter` using the query parameter name `"api-version"`.
+
+Adding API versions to your `HttpClient` instances can easily be configured using the `IHttpClientFactory` dependency
+injection extensions.
+
+```c#
+var services = new ServiceCollection();
+
+services.AddHttpClient(
+ "MyApi",
+ client => client.BaseAddress = new Uri( "https://my.api.com") )
+ .AddApiVersion( 1.0 );
+
+var provider = services.BuildServiceProvider();
+var factory = provider.GetRequiredService();
+var client = factory.CreateClient( "MyApi" );
+
+// GET https://my.api.com/data?api-version=1.0
+var response = await client.GetAsync( "data" );
+```
+
+You can add or replace the default `IApiVersionWriter` with:
+
+```c#
+var services = new ServiceCollection();
+
+services.AddSingleton( new UrlSegmentApiVersionWriter( "{ver}" ) );
+services.AddHttpClient(
+ "MyApi",
+ client => client.BaseAddress = new Uri( "https://my.api.com/v{ver}") )
+ .AddApiVersion( 1 );
+
+var provider = services.BuildServiceProvider();
+var factory = provider.GetRequiredService();
+var client = factory.CreateClient( "MyApi" );
+
+// GET https://my.api.com/v1/data
+var response = await client.GetAsync( "data" );
+```
+
+The following implementations are provided out-of-the-box:
+
+- `QueryStringApiVersionWriter`
+- `HeaderApiVersionWriter`
+- `MediaTypeApiVersionWriter`
+- `UrlSegmentApiVersionWriter`
+
+Specifying multiple API versions is typically unnecessary; however, if this is a capability you need or want, multiple
+writers can be composed together:
+
+```c#
+var writer = ApiVersionWriter.Combine(
+ new QueryApiVersionWriter( "api-version" ),
+ new HeaderApiVersionWriter( "x-ms-api-version" ) );
+```
+
+Your application might have multiple clients that communicate to services which use different API versioning methods.
+To accommodate these differences, you can specify a specific writer per client.
+
+```c#
+var services = new ServiceCollection();
+
+services.AddHttpClient(
+ "SomeApi",
+ client => client.BaseAddress = new Uri( "https://some.api.com/") )
+ .AddApiVersion( 1.0, new QueryApiVersionWriter() );
+
+services.AddHttpClient(
+ "OtherApi",
+ client => client.BaseAddress = new Uri( "https://other.api.com/v{ver}/") )
+ .AddApiVersion( 2, new UrlSegmentApiVersionWriter( "{ver}" ) );
+```
+
+If you're not using dependency injection or the `IHttpClientFactory`, you can still configure writers by explicitly
+configuring the `ApiVersionHandler`:
+
+```c#
+using var client = new HttpClient(
+ new ApiVersionHandler(
+ new QueryApiVersionWriter(),
+ new ApiVersion( 1, 0 ) )
+ {
+ InnerHandler = new HttpClientHandler(),
+ } );
+```
+
+## Notifications
+
+API clients always have a few common questions:
+
+- _"How do I know when an API version is deprecated?"_
+- _"How do I know when an API version will be sunset?"_
+- _"How do I know when a new API version is available?"_
+
+These questions can now be answered via:
+
+```c#
+public interface IApiNotification
+{
+ Task OnApiDeprecatedAsync( ApiNotificationContext context, CancellationToken cancellationToken );
+ Task OnNewApiAvailableAsync( ApiNotificationContext context, CancellationToken cancellationToken );
+}
+```
+
+Where the notification information provided is:
+
+```c#
+public class ApiNotificationContext
+{
+ public HttpResponseMessage Response { get; }
+ public ApiVersion ApiVersion { get; }
+ public SunsetPolicy SunsetPolicy { get; }
+}
+```
+
+If the API reports its versions, then the `ApiVersionHandler` will detect when these events occur and invoke the
+appropriate notification. The `ApiVersionHandler` will look for the `api-supported-versions` and
+`api-deprecated-versions` HTTP headers by default, but alternate headers may be configured. If a deprecation or sunset
+policy is specified by the API, then the deprecation date will be read from the `deprecation` HTTP header and the sunset
+date will be read from the `sunset` HTTP header. Any `link` HTTP headers where the relation type is
+`rel="deprecation"` or `rel="sunset"` will also be read.
+
+No notifications or actions occur by default. The most logical action to perform when a notification occurs is to log
+it. The `ApiVersionHandlerLogger` implements an `IApiNotification` that is paired with an `ILogger` that will:
+
+1. Log a warning message when an API reports that the version requested is deprecated.
+2. Log an informational message when an API reports that a newer version than the one requested is available.
+
+Logged messages can be connected to alerts to notify developers when these events occur in an automated fashion.
+
+If you configuration uses dependency injection and `ILogger` is a resolvable service,
+`ApiVersionHandlerLogger` will be used as the default `IApiNotification` implementation unless
+configured otherwise.
+
+## API Information
+
+Using API information provided in responses is useful, but not always provided for every request. Furthermore, if
+you're onboarding to an API, how do you know which API versions are available or deprecated? How do you know the
+policies around these APIs? Detailed information might be provided by OpenAPI, but how do you know where the OpenAPI
+documents are?
+
+The most logical way for an API to expose this information is to provide an `OPTIONS` method, which may be
+version-specific or version-neutral, that returns all of the available API information. This information is useful for
+automation and client tooling.
+
+The `GetApiInformationAsync` extension method for the `HttpClient` provides a prescribed implementation to make the
+appropriate `OPTIONS` request and parse its response into:
+
+```c#
+using var client = new HttpClient()
+{
+ BaseAddress = new Uri( "https://my.api.com" ),
+};
+var info = await client.GeApiInformationAsync( "/?api-version=1.0" );
+```
+_Request API information_
+
+```http
+OPTIONS /?api-version=1.0 HTTP/2
+host: my.api.com
+```
+_HTTP request sent_
+
+```http
+HTTP/2 200
+api-supported-versions: 2.0
+api-deprecated-versions: 1.0
+deprecation: @1688169600
+sunset: Mon, 01 Jan 2024 00:00:00 GMT
+link: ; rel="deprecation"; type="text/html"
+link: ; rel="sunset"; type="text/html"
+link: ; rel="openapi"; type="application/json"; api-version="1.0"
+```
+_HTTP response received_
+
+```c#
+public class ApiInformation
+{
+ public IReadOnlyList SupportedApiVersions { get; }
+ public IReadOnlyList DeprecatedApiVersions { get; }
+ public SunsetPolicy SunsetPolicy { get; }
+ public IReadOnlyDictionary OpenApiDocumentUrls { get; }
+}
+```
+_Parsed API information_
+
+[Asp.Versioning.Http.Client]: https://www.nuget.org/packages/Asp.Versioning.Http.Client
+[IApiVersionReader]: ../../config/reader.md
\ No newline at end of file
diff --git a/wiki/src/shared/ext/custom-attributes-post.md b/wiki/src/shared/ext/custom-attributes-post.md
new file mode 100644
index 000000000..66fd8a229
--- /dev/null
+++ b/wiki/src/shared/ext/custom-attributes-post.md
@@ -0,0 +1,4 @@
+This approach can help centralize API version management and avoid developer typographical errors when implementing a
+set of services that all use the same API version.
+
+[API versioning options]: ../../config/options.md)
\ No newline at end of file
diff --git a/wiki/src/shared/ext/custom-attributes-pre.md b/wiki/src/shared/ext/custom-attributes-pre.md
new file mode 100644
index 000000000..c3bd27df4
--- /dev/null
+++ b/wiki/src/shared/ext/custom-attributes-pre.md
@@ -0,0 +1,31 @@
+# Attributes
+
+In addition to the [API versioning options], there are few other customization and extension points. Attributes are the
+primary mechanism used to decorate the API version metadata with a specific controller type, but the attributes used
+can be any `IApiVersionProvider`.
+
+```c#
+public interface IApiVersionProvider
+{
+ ApiVersionProviderOptions Options { get; }
+ IReadOnlyList Versions { get; }
+}
+```
+
+There are several API version provider attributes defined out-of-the-box:
+
+- `ApiVersionsBaseAttribute`
+- `ApiVersionAttribute`
+- `MapToApiVersionAttribute`
+- `AdvertiseApiVersionsAttribute`
+
+These attributes are themselves extensible. For example, you might choose to have your own attributes that are
+unambiguously a specific version:
+
+```c#
+[AttributeUsage( AttributeUsage.Class, AllowMultiple = true, Inherited = false )]
+public sealed class V1Attribute : ApiVersionAttribute
+{
+ public V1Attribute() : base( new ApiVersion( new( 2016, 7, 1 ) ) ) { }
+}
+```
\ No newline at end of file
diff --git a/wiki/src/shared/ext/custom-format.md b/wiki/src/shared/ext/custom-format.md
new file mode 100644
index 000000000..428516030
--- /dev/null
+++ b/wiki/src/shared/ext/custom-format.md
@@ -0,0 +1,19 @@
+# Version Format
+
+It is possible to extend or change the provided API version format, but that capability comes with several rules:
+
+1. You must extend `ApiVersion`
+2. You must override:
+ - `GetHashCode`
+ - `CompareTo`
+ - `ToString(string,IFormatProvider)`
+3. You must implement `IApiVersionParser`
+ - It may be possible to extend `ApiVersionParser` depending on your requirements
+
+You will likely need to extend `ApiVersionFormatProvider` or implement a custom `IFormatProvider`. Although not
+strictly required, you may want to implement operator overloads for your custom type to retain functional parity with
+`ApiVersion`. The custom parser will need to be passed to components that accept `IApiVersionParser` and/or replace the
+default implementation registered for dependency injection.
+
+You should consider the impact that a custom API version may have on clients. Your custom format and parsing logic may
+need to be distributed to them for to use.
\ No newline at end of file
diff --git a/wiki/src/shared/faq.md b/wiki/src/shared/faq.md
new file mode 100644
index 000000000..cf11328a0
--- /dev/null
+++ b/wiki/src/shared/faq.md
@@ -0,0 +1,41 @@
+# FAQ
+
+## What is the difference between the DefaultApiVersion and ApiVersionSelector options?
+
+There are subtle differences between these two [options]. Typically, you only need to configure one or the other, but
+not both.
+
+The `DefaultApiVersion` has the following uses:
+
+- The API version defined for a controller that does not have any explicit attribution or conventions
+- The fallback API version used when no other API version can be resolved
+
+It's important to understand that once you opt into API versioning, **every** controller has an API version, even if you
+do not apply an explicit definition via attributes or conventions. This behavior can also be thought of as the
+_initial_ API Version.
+
+The `DefaultApiVersion` value is `1.0`, but that may not be your starting API version. For example, you might use the
+date-only API versioning scheme. This configuration option prevents the value from being hard-coded and makes it easy
+to change the API version for your initial set of services.
+
+The `ApiVersionSelector` option has a familiar, but different purpose. Any implementation of the [IApiVersionSelector]
+is used to select the best API version given the current HTTP request and API version model. The provided API version
+model will already be aggregated across all known service versions.
+
+While this component could be used for a number of different purposes, it is currently only used to select the API
+version that should be used when a client does not provide an API version. This option is thus only used when the
+`AssumeDefaultVersionWhenUnspecified` option is also `true`. The default, configured value for this option is a
+instance of the `DefaultApiVersionSelector`, which always returns the value of `DefaultApiVersion`. Most of the built-in
+[IApiVersionSelector] implementations accept the `ApiVersioningOptions` in their constructors so that they can use the
+`DefaultApiVersion` as the final fallback value.
+
+It's recommended that the [IApiVersionSelector] implementation you use provides stable, deterministic results. This is
+particularly important for existing clients that may not be aware that you have introduced API versioning. Contrary to
+this guidance, a number of service authors have requested granular control over how the API versions should be selected.
+As an example, a service author might want to allow a client to never specify an API version and use an internal
+_client-to-version_ mapping that is maintained on the server after the first client connects. How this is implemented in
+an [IApiVersionSelector] is up to the service author, but it likely requires information from the current HTTP request
+and the available API versions for a service.
+
+[options]: config/options.md
+[IApiVersionSelector]: config/selector.md
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/define-service-version.md b/wiki/src/shared/how-to/define-service-version.md
new file mode 100644
index 000000000..67ce1c626
--- /dev/null
+++ b/wiki/src/shared/how-to/define-service-version.md
@@ -0,0 +1,11 @@
+# Defining a Service Version
+
+There are four out-of-the-box supported approaches for versioning a service:
+
+- By query string parameter
+- By media type parameter
+- By HTTP header
+- By URL path segment
+
+The default method is to use a query string parameter named **api-version**. You can also combine API versioning
+approaches together or define your own custom method of API versioning.
diff --git a/wiki/src/shared/how-to/deprecate-version-post.md b/wiki/src/shared/how-to/deprecate-version-post.md
new file mode 100644
index 000000000..da6142723
--- /dev/null
+++ b/wiki/src/shared/how-to/deprecate-version-post.md
@@ -0,0 +1,5 @@
+## Removing a Service
+
+To permanently sunset a service, simply remove that controller or API version from your implementation. The route will
+no longer be matched. When one or more specific API versions cannot be matched, clients will receive HTTP status code
+`400` (Bad Request). If no candidate routes match at all, clients will receive HTTP status code `404` (Not Found).
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/deprecate-version-pre.md b/wiki/src/shared/how-to/deprecate-version-pre.md
new file mode 100644
index 000000000..7baab5c6c
--- /dev/null
+++ b/wiki/src/shared/how-to/deprecate-version-pre.md
@@ -0,0 +1,9 @@
+# Deprecating Versions
+
+When a service supports multiple API versions, some versions will eventually be deprecated over time. To advertise that
+one or more API versions have been deprecated, simply decorate your controller with the deprecated API versions. A
+deprecated API version does not mean the API version is not supported. A deprecated API version means that the version
+will become unsupported after six months or more.
+
+The following examples illustrate how to specify deprecated API versions depending on which service API versioning
+approach you selected.
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/existing-services-mid.md b/wiki/src/shared/how-to/existing-services-mid.md
new file mode 100644
index 000000000..36eb4564b
--- /dev/null
+++ b/wiki/src/shared/how-to/existing-services-mid.md
@@ -0,0 +1,3 @@
+The assumed API version is `1.0` by default. From a client's perspective, the default API version is inconsequential. As
+a service author, however, you may want to choose a different default API version so that it aligns with your overall
+API versioning scheme and instrumentation requirements.
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/existing-services-post.md b/wiki/src/shared/how-to/existing-services-post.md
new file mode 100644
index 000000000..aea40afc1
--- /dev/null
+++ b/wiki/src/shared/how-to/existing-services-post.md
@@ -0,0 +1,5 @@
+If these basic configuration settings are still insufficient for your needs, then you will need to use or create an
+[API version selector] and register it in the [API versioning options].
+
+[API versioning options]: ../config/api-versioning-options.md
+[API version selector]: ../config/api-version-selector.md
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/existing-services-pre.md b/wiki/src/shared/how-to/existing-services-pre.md
new file mode 100644
index 000000000..dd3807226
--- /dev/null
+++ b/wiki/src/shared/how-to/existing-services-pre.md
@@ -0,0 +1,23 @@
+# Existing Services
+
+It's a fairly common scenario that services are released to production and, at some point in the future, it becomes
+evident that service versioning is needed. The question now becomes, _"How do I add API versioning without breaking
+existing clients?"_
+
+Before API versioning was applied to your service, clients were already bound to some version of the service; they just
+don't know which version. A client in this situation doesn't have any flexibility to go backward. If the service
+changes, hopefully that carries forward without breaking any clients. When you're ready to introduce formal API
+versioning semantics into your service, then any previously unversioned services snap to a single, default API version.
+
+## Enable Backward Compatibility
+
+The default API versioning semantics require that all clients explicitly request an API version for a service. This
+would break backward compatibility with existing clients, so we need a way to address this. The [API versioning options]
+provide a way to change the default behaviors that will enable supporting services that don't explicitly declare API
+versions.
+
+The bare minimum requirement to enable backward compatibility is to assume the default API version when a client does
+not explicitly request an API version. This will allow a client to continue making requests to existing services without
+providing API version information. Your existing controller implementations that back these services do not require any
+attribution or configuration to enable this behavior. Clients wishing to upgrade to new versions of a service must begin
+explicitly specifying an API version.
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/naming-conventions-post.md b/wiki/src/shared/how-to/naming-conventions-post.md
new file mode 100644
index 000000000..89d055c45
--- /dev/null
+++ b/wiki/src/shared/how-to/naming-conventions-post.md
@@ -0,0 +1,35 @@
+To address name collisions and provide control over how collation happens, API Versioning provides the following
+service:
+
+```c#
+public interface IControllerNameConvention
+{
+ string NormalizeName( string controllerName );
+ string GroupName( string controllerName );
+}
+```
+
+`NormalizeName` controls how or whether a controller name is _normalized_. `GroupName` provides the name used to group
+and collate on, which may not necessarily be the same as the _normalized_ name. `ControllerNameConvention` provides
+three implementations out-of-the-box.
+
+### Default
+
+`ControllerNameConvention.Default` provides the default configuration which extends the original convention to have the
+form: `[#]Controller`. This means that if you already have a `HelloWorldController`, you can now have a
+`HelloWorld2Controller` and `HelloWorld3Controller`. Each type name removes the `Controller` suffix as well as any
+trailing numbers. All of these controllers would end up named and grouped `HelloWorld`.
+
+### Original
+
+`ControllerNameConvention.Original` provides an alternate configuration that retains the original naming convention.
+Consider that you have a type named `S3Controller`. In this scenario, you do **not** want the `3` to be stripped away.
+If you have multiple versions of a such a controller, you would need your own implementation that understands this
+behavior or separate the types into different .NET namespaces.
+
+### Grouped
+
+`ControllerNameConvention.Grouped` is a hybrid configuration the combines the **Default** and **Original** conventions.
+For the purposes of the name, the original convention is used. For the purposes of grouping, the default convention is
+used. A controller type of `S3Controller` would have the name `S3`, but the group name `S`. The group name is only used
+for collation and is never displayed anywhere, so this behavior is acceptable.
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/naming-conventions-pre.md b/wiki/src/shared/how-to/naming-conventions-pre.md
new file mode 100644
index 000000000..a6065dfa3
--- /dev/null
+++ b/wiki/src/shared/how-to/naming-conventions-pre.md
@@ -0,0 +1,23 @@
+# Controller Naming Conventions
+
+There are a few implicit conventions to be aware of.
+
+## Always Versioned
+
+Once you opt into API versioning, every API controller has an API version. This is true even if the controller does not
+have an explicit attribute or configured convention. When otherwise unspecified, the version applied to a controller
+derives from [ApiVersioningOptions.DefaultApiVersion].
+
+## Naming
+
+ASP.NET provides a built-in convention for controller names that use the form `Controller` where `Controller` will
+be trimmed off when exactly that text. API Versioning slightly expands this convention. It will honor the convention of
+`[#]Controller`. This allows you to have two controller types in the same namespace for different API versions,
+but for the same resource; for example, `ValuesController` and `Values2Controller` will both have the name `Values`.
+Naming is important for grouping controllers together.
+
+Unfortunately, this can cause an issue for service API versioning if you want to split the implementation across
+different types. If the defining type is in a different .NET namespace, then there is no issue; however, if they are in
+the same namespace there would be a name collision. For example:
+
+[ApiVersioningOptions.DefaultApiVersion]: ../config/api-versioning-options.md#default-api-version
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/overview-post.md b/wiki/src/shared/how-to/overview-post.md
new file mode 100644
index 000000000..c5d4f59dc
--- /dev/null
+++ b/wiki/src/shared/how-to/overview-post.md
@@ -0,0 +1,14 @@
+## Versioning Methods
+
+Several API versioning methods are supported out-of-the-box:
+
+- [By Query String](how-to/version-by-query-string.md) (default)
+- [By Media Type](how-to/version-by-media-type.md)
+- [By Header](how-to/version-by-header.md)
+- [By URL Segment](how-to/version-by-url.md)
+
+Multiple methods of API versioning can be supported simultaneously. Use the `ApiVersionReader.Combine` method to compose
+two or more [IApiVersionReader] instances together. You can also implement your own method of extracting the requested
+API version using a custom [IApiVersionReader].
+
+[IApiVersionReader]: config/api-version-reader.md
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/overview-pre.md b/wiki/src/shared/how-to/overview-pre.md
new file mode 100644
index 000000000..6592eb870
--- /dev/null
+++ b/wiki/src/shared/how-to/overview-pre.md
@@ -0,0 +1,36 @@
+# How to Version Your Service
+
+REST services are implemented in ASP.NET as an endpoint. To version your service, you simply need to decorate your
+endpoints with the appropriate API version information. The method of decoration will vary depending on whether you are
+using controllers or Minimal APIs as well as whether you want to use attributes or conventions.
+
+## How It Works
+
+The way that you create and define routes remains unchanged. The key difference is that routes may now overlap depending
+on whether you are using convention-based routing, attribute-based routing, or both. In the case of attribute routing,
+multiple controllers will define the same route. The default services in each flavor of ASP.NET assumes a one-to-one
+mapping between routes and endpoints and, therefore, considers duplicate routes to be ambiguous. The API versioning
+services replace the default implementations and allow endpoints to also be disambiguated by API version. Although
+multiple routes may match a request, they are expected to be distinguishable by API version. If the routes cannot be
+disambiguated, this is likely a developer mistake and the behavior is the same as the default implementation.
+
+## Naming and Collation
+
+While it might seem more intuitive that similar route templates are collated together, that is simply not the case.
+Consider that `order/{id}` and `order/{id:int}` are different, but semantically identical. API Versioning makes no
+attempt understand this difference. Although it is possible to have an API with a single endpoint, most APIs consist of
+a collection of endpoints; for example the _Orders_ API. What if we saw the route template `order/{id}/items`? Is this
+part of the _Orders_ API or some other API? For this reason, API Versioning collates on the logical name of an API and
+not individual route templates. For more information see: [Controller Conventions].
+
+[Controller Conventions]: how-to/controller-conventions.md
+
+## Routing Methods
+
+The following table outlines the various supported routing methods:
+
+| Routing Method | Supported |
+|:-----------------------------------------------|:---------:|
+| Attribute-based routing | Yes |
+| Convention-based routing | Yes |
+| Attribute and convention-based routing (mixed) | Yes |
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/requested-version-pre.md b/wiki/src/shared/how-to/requested-version-pre.md
new file mode 100644
index 000000000..88aa87894
--- /dev/null
+++ b/wiki/src/shared/how-to/requested-version-pre.md
@@ -0,0 +1,8 @@
+# Requested API Version
+
+All of the service API version information is accessible via extension methods and properties. Beginning in version
+`3.0`, _Model Binding_ is also supported. These features allow you to determine which API version was requested by a
+client as well as determine which versions are supported and deprecated. The API versions provided are automatically
+aggregated across all service implementations.
+
+The most common usage is the current, client requested API version:
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-advertisement-post.md b/wiki/src/shared/how-to/version-advertisement-post.md
new file mode 100644
index 000000000..577b66047
--- /dev/null
+++ b/wiki/src/shared/how-to/version-advertisement-post.md
@@ -0,0 +1,12 @@
+This service implementation will now advertise that API version `1.0` and `2.0` are supported through the
+`api-supported-versions` HTTP header even though it has no knowledge about where API version `1.0` is. In a similar
+fashion, a service can also advertise deprecated API versions. Note that the [ApiVersioningOptions.ReportApiVersions]
+must be enabled for the HTTP headers to be returned in responses.
+
+The only drawback to this approach is that each implementation needs to be updated with the supported and deprecated API
+versions when new API versions are released. One possible solution to this limitation is to create an
+`IApiVersionProvider` attribute that reads the advertised API versions from a configuration source such as a file or
+database. If this is still undesirable, then there is still the option of using HTTP header injection by the host server
+or another mechanism to send the supported and deprecated API version information.
+
+[ApiVersioningOptions.ReportApiVersions]: ../configuring-your-application/api-versioning-options.md
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-advertisement-pre.md b/wiki/src/shared/how-to/version-advertisement-pre.md
new file mode 100644
index 000000000..6f19cc26a
--- /dev/null
+++ b/wiki/src/shared/how-to/version-advertisement-pre.md
@@ -0,0 +1,33 @@
+# Version Advertisement
+
+Splitting implemented service API versions across hosted applications or endpoints is a fairly common scenario. There
+are several reasons why you might choose to split hosted endpoints, such as different run-time versions or traffic load
+balancing.
+
+When service API versions are split across deployments, two issues arise:
+
+1. The correct service API version cannot be selected across deployments.
+2. The set of implemented service API versions cannot be aggregated across deployments.
+
+## Service Gateway
+
+The first issue can be remedied by a using a service gateway. The gateway becomes responsible for obfuscating which
+endpoints host which API versions. The exact method in which gateways implement this functionality is at the discretion
+of service authors.
+
+Future consideration is being investigated to support [YARP](https://github.com/microsoft/reverse-proxy).
+
+## Service API Version Advertisement
+
+Since there is no direct way to know or interrogate the available API version information at runtime in a performant
+manner when services are deployed separately, an alternate approach is required. This concept is referred to as
+*service API version advertisement*. Each service will advertise the supported and deprecated API versions it knows
+about.
+
+A service can advertise its supported and deprecated API versions using the `AdvertiseApiVersionsAttribute`. This
+attribute functions almost identically to the `ApiVersionAttribute`, except that it is never considered for controller
+resolution and cannot be applied to an action. The advertised and implemented API versions are always aggregated
+together.
+
+The following is an example of a service with API version `2.0` hosted at another endpoint that knows that API version
+`1.0` is a supported version somewhere else:
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-by-header-post.md b/wiki/src/shared/how-to/version-by-header-post.md
new file mode 100644
index 000000000..93c89997a
--- /dev/null
+++ b/wiki/src/shared/how-to/version-by-header-post.md
@@ -0,0 +1,24 @@
+### Configuration
+
+The configuration will then change the default API version reader as follows:
+
+```c#
+.AddApiVersioning( options => options.ApiVersionReader = new HeaderApiVersionReader( "x-ms-version" ) );
+```
+
+This will allow clients to request a specific API version by the custom HTTP header `x-ms-version`. For example:
+
+```http
+GET api/helloworld HTTP/2
+host: localhost
+x-ms-version: 1.0
+```
+
+```http
+HTTP/2 200
+host: localhost
+content-type: text/plain
+content-length: 12
+
+Hello world!
+```
diff --git a/wiki/src/shared/how-to/version-by-header-pre.md b/wiki/src/shared/how-to/version-by-header-pre.md
new file mode 100644
index 000000000..7eeb53674
--- /dev/null
+++ b/wiki/src/shared/how-to/version-by-header-pre.md
@@ -0,0 +1,5 @@
+# Header Versioning
+
+While media type negotiation is the defined method in REST for reasoning about the content expectations between a client and server, any arbitrary HTTP header can also be used to drive API versioning.
+
+Let's assume the following controllers are defined:
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-by-media-type-post.md b/wiki/src/shared/how-to/version-by-media-type-post.md
new file mode 100644
index 000000000..0790bfcf3
--- /dev/null
+++ b/wiki/src/shared/how-to/version-by-media-type-post.md
@@ -0,0 +1,169 @@
+### Configuration
+
+The configuration will then change the default API version reader as follows:
+
+```c#
+.AddApiVersioning( options => options.ApiVersionReader = new MediaTypeApiVersionReader() );
+```
+
+The parameterless constructor uses the media type parameter name `v`, but you can specify any name you like. The default
+behavior will require that clients always specify an API version, so service authors will likely want their
+configuration to be:
+
+```c#
+.AddApiVersioning(
+ options =>
+ {
+ options.ApiVersionReader = new MediaTypeApiVersionReader();
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ApiVersionSelector = new CurrentImplementationApiVersionSelector( options );
+ } );
+```
+
+This will allow clients to request a specific API version by media type, but if they don't specify anything, they will
+receive the current implementation (e.g. API version). For example:
+
+```http
+GET api/helloworld HTTP/2
+host: localhost
+```
+_Figure 1: returns the result from API version 2.0 because it's the current version_
+
+```http
+GET api/helloworld HTTP/2
+host: localhost
+accept: text/plain;v=1.0
+```
+_Figure 2: returns the result from API version 1.0_
+
+```http
+POST api/helloworld HTTP/2
+host: localhost
+content-type: text/plain;v=2.0
+content-length: 12
+
+Hello there!
+```
+_Figure 3: explicitly posts the content to API version 2.0, even though it would be implicitly matched_
+
+## Multiple Media Types
+
+The `MediaTypeApiVersionReader` matches the configured media type parameter of **any** incoming request. This might be
+undesirable if you support multiple media types or there is ambiguity in matching a media type.
+
+Consider the following request:
+
+```http
+GET api/helloworld HTTP/2
+host: localhost
+accept: application/json;v=1.0;q=0.8,application/signed-exchange;v=b3;q=0.9
+```
+
+In this scenario, a client has specified multiple media types and they both have the media type parameter `v`. The
+`MediaTypeApiVersionReader` will honor quality (e.g. `q`) when specified. If multiple media types have the same quality,
+the first one is selected. In this example `application/signed-exchange` is selected because it has the highest quality.
+When the `v` parameter is parsed, the value is `b3` is not a valid API version and will return HTTP status code `406`
+(Not Acceptable).
+
+The `MediaTypeApiVersionReaderBuilder` provides a number of additional capabilities to build media type matching rules
+that enable to you configure how you would like things to match. You can specify and combine any of the following
+behaviors:
+
+- Define multiple media type parameters
+- Mutually include specific media types
+- Mutually exclude specific media types
+- Match media types by template
+- Match media types by pattern
+- Disambiguate between multiple API versions
+
+To configure that only JSON be matched, you might use a configuration similar to the following:
+
+```c#
+.AddApiVersioning(
+ options =>
+ {
+ var builder = new MediaTypeApiVersionReaderBuilder();
+
+ options.ApiVersionReader = builder.Parameter( "v" )
+ .Include( "application/json" )
+ .Build();
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ApiVersionSelector = new CurrentImplementationApiVersionSelector( options );
+ } );
+```
+
+An important difference between `MediaTypeApiVersionReaderBuilder` and `MediaTypeApiVersionReader` is that
+`MediaTypeApiVersionReader` expects there to be exactly one API version and selects the first one with the highest
+_quality_. The `MediaTypeApiVersionReaderBuilder`, on the other hand, makes no such assumption and returns all matched
+API versions in descending order of _quality_. You can use the `SelectFirstOrDefault` or `SelectLastOrDefault` extension
+methods to have the `MediaTypeApiVersionReaderBuilder` choose the first or last API version respectively. If neither of
+these approaches meet your requirements, you can provide you own callback to determine how to disambiguate multiple
+choices via `MediaTypeApiVersionReaderBuilder.Select`.
+
+## Custom Media Types
+
+Defining new, custom media types (ex: `application/vnd.my.company.1+json`) to drive API versioning is another variant of
+this approach that is compliant with the constraints of REST. There is no specific `IApiVersionReader` meant to address
+this scenario, however, the `MediaTypeApiVersionReaderBuilder` provides two approaches that can be used.
+
+### Templates
+
+The most natural approach is to a use a template to match an API version in the media type. The specified template uses
+the same syntax and matching as a route template. For example,
+
+```c#
+.AddApiVersioning(
+ options =>
+ {
+ var builder = new MediaTypeApiVersionReaderBuilder();
+
+ options.ApiVersionReader = builder.Template( "application/vnd.my.company.{version}+json" )
+ .Build();
+ } );
+```
+
+This allows matching the API version the same way as if it were in a URL segment. All of the same format and parsing
+rules apply. In most cases, this is sufficient; however, the template expects **exactly one** parameter and that will be
+assumed to the API version parameter. If there are multiple route parameters, for whatever reason, the expected name
+must be provided as the second, optional parameter:
+
+```c#
+Template( "application/vnd.{tenant}.{version}+json", "version" );
+```
+
+### Patterns
+
+If a template will not suffice, then a regular expression pattern can be used.
+
+```c#
+.AddApiVersioning(
+ options =>
+ {
+ var builder = new MediaTypeApiVersionReaderBuilder();
+
+ options.ApiVersionReader = builder.Match( @"-v(\d+(\.\d+)?)\+" ).Build();
+ } );
+```
+
+`MediaTypeApiVersionReaderBuilder.Match` will **only** consider the first match. The match may optionally use grouping,
+but only the first regular expression group will be considered. If a requested media type does not match the pattern,
+then it is ignored.
+
+It is assumed that your pattern matching requirements will fall under the date (e.g. group) or numeric version formats;
+however, if you have something more complex, the following pattern will match all forms of a valid API version:
+
+```regex
+^(\d{4}-\d{2}-\d{2})?\.?(\d{0,9})\.?(\d{0,9})\.?-?(.*)$
+```
+
+API Versioning no longer uses regular expressions to parse API versions; however, if you need to know how this can be
+used from previous implementations, you can review the [old code].
+
+### Additional Considerations
+
+While using a template or pattern can be used to match and extract an API version from an incoming request, it does not
+currently provide any additional support that may be need to implement a full solution. These should be known issues and
+exist even without API Versioning. You should simply beware that API Versioning isn't providing any additional features
+beyond matching the API version from the media type in the incoming request.
+
+[old code]: https://github.com/dotnet/aspnet-api-versioning/blob/0612bbd32f39b2607cf64e86fc8892d19e39dce7/src/Common/ApiVersion.cs#L182
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-by-media-type-pre.md b/wiki/src/shared/how-to/version-by-media-type-pre.md
new file mode 100644
index 000000000..0594928d8
--- /dev/null
+++ b/wiki/src/shared/how-to/version-by-media-type-pre.md
@@ -0,0 +1,7 @@
+# Media Type Versioning
+
+Content negotiation is the defined method in REST for reasoning about the content expectations between a client and
+server. The parameters used in media types for content negotiation can contain custom input that can be used to drive
+API versioning.
+
+Let's assume the following controllers are defined:
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-by-query-string-post.md b/wiki/src/shared/how-to/version-by-query-string-post.md
new file mode 100644
index 000000000..1a45732f2
--- /dev/null
+++ b/wiki/src/shared/how-to/version-by-query-string-post.md
@@ -0,0 +1,12 @@
+The effect of this attribution is that the following requests match different controller implementations:
+
+| Request URL | Matched Controller |
+|:--------------------------------|:----------------------|
+| /api/helloworld?api-version=1.0 | HelloWorldController |
+| /api/helloworld?api-version=2.0 | HelloWorld2Controller |
+| /api/People?api-version=1.0 | PeopleController |
+| /api/People?api-version=2.0 | People2Controller |
+
+It’s important to note that only an undecorated controller will be inferred as the configured, default API version. Once
+a controller has any API version attribution, it will never be considered as the default API version again unless the
+API version attribute includes the default API version. This allows you permanently remove API versions over time.
diff --git a/wiki/src/shared/how-to/version-by-query-string-pre.md b/wiki/src/shared/how-to/version-by-query-string-pre.md
new file mode 100644
index 000000000..d135d6560
--- /dev/null
+++ b/wiki/src/shared/how-to/version-by-query-string-pre.md
@@ -0,0 +1,4 @@
+# Query String Versioning
+
+The initial version of a controller may not have any API version attribution and will implicitly become the configured
+default API version. The default configuration uses the value `1.0`.
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-by-url-post.md b/wiki/src/shared/how-to/version-by-url-post.md
new file mode 100644
index 000000000..1173c1db0
--- /dev/null
+++ b/wiki/src/shared/how-to/version-by-url-post.md
@@ -0,0 +1,10 @@
+The effect of the API version attribution is that the following requests match different controller implementations:
+
+| Request URL | Matched Controller | Matched Action |
+|:-------------------|:----------------------|----------------|
+| /api/v1/helloworld | HelloWorldController | Get |
+| /api/v2/helloworld | HelloWorld2Controller | Get |
+| /api/v3/helloworld | HelloWorld2Controller | GetV3 |
+| /api/v1/People | PeopleController | Get |
+| /api/v2/People | People2Controller | Get |
+| /api/v3/People | People2Controller | GetV3 |
diff --git a/wiki/src/shared/how-to/version-by-url-pre.md b/wiki/src/shared/how-to/version-by-url-pre.md
new file mode 100644
index 000000000..cddd6db76
--- /dev/null
+++ b/wiki/src/shared/how-to/version-by-url-pre.md
@@ -0,0 +1,14 @@
+# URL Path Versioning
+
+An alternate, but common, method of API versioning is to use a URL path segment. This approach does not allow implicitly
+matching the initial, default API version of a service; therefore, all API versions must be explicitly declared. In
+addition, the API version value specified for the URL segment must still conform to the [version format]. The `v` prefix
+is **not** part of the API version, but may be included in route templates if you so desire.
+
+>[!IMPORTANT]
+>It is not possible to have a default API version for a URL path segment. This means that setting
+`ApiVersioningOptions.AssumedDefaultVersionWhenUnspecified` is unlikely to have any affect when you use this method of
+versioning. For more information and possible solutions to address this scenario, refer to the [known limitations].
+
+[version format]: ../version-format.md
+[known limitations]: ../known-limitations.md#url-path-segment-routing-with-a-default-api-version
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-interleaving-post.md b/wiki/src/shared/how-to/version-interleaving-post.md
new file mode 100644
index 000000000..b727c41ca
--- /dev/null
+++ b/wiki/src/shared/how-to/version-interleaving-post.md
@@ -0,0 +1,16 @@
+Although not illustrated in these examples, it’s important to note that different versions of a service action might
+have different return values. The effect of the API versioning attribution is that the following requests match
+different controller and action implementations:
+
+| Request URL | Matched Controller | Matched Action |
+|:--------------------------------|-----------------------|----------------|
+| /api/helloworld?api-version=1.0 | HelloWorldController | Get |
+| /api/helloworld?api-version=2.0 | HelloWorld2Controller | Get |
+| /api/helloworld?api-version=3.0 | HelloWorld2Controller | GetV3 |
+| /api/People?api-version=1.0 | PeopleController | Get |
+| /api/People?api-version=2.0 | People2Controller | Get |
+| /api/People?api-version=3.0 | People2Controller | GetV3 |
+
+It should be reiterated that the defined API version, even for an action, never directly influences routing. When the
+action matched for a route is ambiguous, the selection process will look for an explicit API version that matches the
+requested API version. If an explicit match is not found, then the action will be implicitly matched. If two actions are ambiguous by route and API version, then this is a developer mistake and the default behavior is unchanged.
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-interleaving-pre.md b/wiki/src/shared/how-to/version-interleaving-pre.md
new file mode 100644
index 000000000..fe388d6e7
--- /dev/null
+++ b/wiki/src/shared/how-to/version-interleaving-pre.md
@@ -0,0 +1,6 @@
+# Version Interleaving
+
+API versions do not have to be split across different controller classes. A service author might choose to have a
+controller implement multiple API versions simultaneously. Controller actions can subsequently be mapped to specific
+API versions. This approach is useful for small version differences but should be used sparingly to prevent developer
+confusion and complicate code maintenance. For example:
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-neutral-post.md b/wiki/src/shared/how-to/version-neutral-post.md
new file mode 100644
index 000000000..8f6009b9a
--- /dev/null
+++ b/wiki/src/shared/how-to/version-neutral-post.md
@@ -0,0 +1,2 @@
+A version-neutral controller using the query string method will not require that a client specify an API version. A
+version-neutral controller using the URL path method will match any well-formed API version in the URL path segment.
\ No newline at end of file
diff --git a/wiki/src/shared/how-to/version-neutral-pre.md b/wiki/src/shared/how-to/version-neutral-pre.md
new file mode 100644
index 000000000..6ac402b53
--- /dev/null
+++ b/wiki/src/shared/how-to/version-neutral-pre.md
@@ -0,0 +1,20 @@
+# Version-Neutral
+
+All services should be explicitly versioned. In rare cases, however, you may have a service that is _version-neutral_.
+A common scenario is a health check service that behaves in the exact same way, regardless of API version. This might
+also apply to a legacy service that doesn't support API versioning. To effectively _opt out_ individual services from
+API versioning, a service must indicate that it is _version-neutral_.
+
+Technically, it's not a supported scenario to completely _opt out_ of API versioning. A _version-neutral_ service has
+the following characteristics:
+
+* Accepts **any** valid API version
+* Accepts no API version at all (e.g. unspecified)
+
+This is an important distinction and why the term _version-neutral_ is used. A _version-neutral_ service accepts any and
+all versions, including none. This behavior can be used to define a service that accepts all API versions or service
+that simply does not care about specific API versions.
+
+It is not possible to have some versions of a controller that are API version-neutral and other versions of the same
+controller require an explicit API version. If the route of an API version-neutral service matches any other service,
+it will result in an ambiguous match (e.g. server error).
\ No newline at end of file
diff --git a/wiki/src/shared/odata/controllers.md b/wiki/src/shared/odata/controllers.md
new file mode 100644
index 000000000..e019ce35b
--- /dev/null
+++ b/wiki/src/shared/odata/controllers.md
@@ -0,0 +1,101 @@
+# Versioned Controllers
+
+Creating an OData controller that supports API versioning isn't much different from creating a regular OData controller.
+The following controller depicts a service that support API version `1.0` and `2.0`.
+
+```c#
+[ApiVersion( 1.0 )]
+[ApiVersion( 2.0 )]
+public class PeopleController : ODataController
+{
+ // GET ~/people?api-version=[1.0|2.0]
+ public IQueryable Get() => new[] { new Person() }.AsQueryable();
+
+ // GET ~/people/1?api-version=[1.0|2.0]
+ public SingleResult Get( int key ) => SingleResult.Create( new Person() );
+
+ // PATCH ~/people/1?api-version=2.0
+ [MapToApiVersion( 2.0 )]
+ public UpdatedODataResult Patch( int key, Delta delta )
+ {
+ if ( !ModelState.IsValid )
+ {
+ return BadRequest( ModelState );
+ }
+
+ var person = new Person();
+ delta.Patch( person );
+ return Updated( person );
+ }
+}
+```
+
+The `PATCH` method is only supported in API version `2.0` of the service. To be truly OData compliant, this service
+should define an action mapped to API version `1.0` that always returns HTTP status code `501` (Not Implemented) instead
+of falling back to HTTP status code `400` (Bad Request) or `404` (Not Found).
+
+If you reviewed the `Person` model and configuration example for the [IModelConfiguration], you'll know what we
+configured a single `Person` model with different properties available in different API versions. The default OData
+model validation does some automatic heavy lifting for us using the defined EDM model. In addition to the other normal
+validation you might have from **Data Annotations**, the current EDM model will provide further validation. For example,
+even though the `Person` class has a `Phone` property, it was not defined until API version `3.0`. If you try to send
+a `PATCH` request like this:
+
+```http
+PATCH /people/1?api-version=2.0 HTTP/2
+content-type: application/json
+content-length: 27
+
+{ "phone": "555-555-5555" }
+```
+
+the built-in OData model validation will fail. The response will end up being HTTP status code `400` (Bad Request) with
+an error message that indicates the `phone` property does not exist. In version `2.0` of the service, that is true and
+the correct behavior.
+
+## Split Implementation
+
+Service authors can choose to split service API versions across multiple controller types. In fact, for all but the
+simplest of version variations, this is the recommended approach. You may, however, notice something extra and a little
+unusual about the attribution for this controller.
+
+Under the hood, the OData implementation still uses convention-based routing. When we split services across multiple
+controller types, the new service implementation cannot have the same name. The only exception to this rule is if you
+create version-specific namespaces for each version of the service. If the name of the controller cannot be the same as
+the original controller type and we're stuck with convention-based routing, how to do indicate what the name of the
+controller should be? Enter the `ControllerNameAttribute`.
+
+The `ControllerNameAttribute` allows you to specify an arbitrary name for a controller. In the strictest sense, this is
+not convention-based; however, short of using different namespaces, there isn't a way to define the correct name of the
+controller. Without the `ControllerNameAttribute`, this controller would be named **People2**, which won't match any
+routes or, more specifically, any defined entity set. In OData, the controller route is paired with the corresponding
+entity set name. The API version services honor this attribute and will use the controller name defined by the attribute
+over the default convention name when present.
+
+```c#
+[ApiVersion( 3.0 )]
+[ControllerName( "People" )]
+public class People2Controller : ODataController
+{
+ // GET ~/people?api-version=3.0
+ public IQueryable Get() => new[] { new Person() }.AsQueryable();
+
+ // GET ~/people/1?api-version=3.0
+ public SingleResult Get( int key ) => SingleResult.Create( new Person() );
+
+ // PATCH ~/people/1?api-version=3.0
+ public UpdatedODataResult Patch( int key, Delta delta )
+ {
+ if ( !ModelState.IsValid )
+ {
+ return BadRequest( ModelState );
+ }
+
+ var person = new Person();
+ delta.Patch( person );
+ return Updated( person );
+ }
+}
+```
+
+[IModelConfiguration]: model-config.md
\ No newline at end of file
diff --git a/wiki/src/shared/odata/metadata.md b/wiki/src/shared/odata/metadata.md
new file mode 100644
index 000000000..24f672abc
--- /dev/null
+++ b/wiki/src/shared/odata/metadata.md
@@ -0,0 +1,68 @@
+# Versioned Metadata
+
+In order to support API versioning, the default `MetadataController` is replaced with a `VersionedMetadataController`
+implementation. The main difference between the two is that the `VersionedMetadataController` will return service
+document and entity data model (EDM) information for each defined API version.
+
+```c#
+[ReportApiVersions]
+public class VersionedMetadataController : MetadataController
+{
+ // omitted for brevity
+}
+```
+
+Clients can now build proxies that have an affinity to a specific API version.
+
+- `~/$metadata`
+- `~/$metadata?api-version=1.0`
+- `~/$metadata?api-version=2.0`
+- `~/$metadata?api-version=3.0`
+
+If a client does not specify an API version, the assumed value will be the [configured default API version]. When a
+client is ready to adopt a new version of the service, they can update their tooling to point to the appropriate API
+version of the metadata endpoint and generate a new proxy based on the version-specific EDMX.
+
+## Tooling Support
+
+The `VersionedMetadataController` also supports the HTTP `OPTIONS` method. This allows tools to query the service
+document (`~/`) or `$metadata` endpoints and provide a client with choices as to which API version they would like to
+create an OData client for.
+
+For example, a tool can query the metadata endpoint:
+
+```http
+OPTIONS /$metadata HTTP/2
+host: my.api.com
+```
+
+which will produce a response that looks like:
+
+```http
+HTTP/2 200
+allow: GET, OPTIONS
+odata-version: 4.0
+api-supported-versions: 1.0, 2.0, 3.0
+api-deprecated-versions: 0.9
+deprecation: @1640995200
+sunset: Thu, 01 Apr 2022 00:00:00 GMT
+link: ; rel="deprecation"; title="API Policy"; type="text/html"
+link: ; rel="sunset"; title="API Policy"; type="text/html"
+link: ; rel="openapi"; title="OpenAPI"; type="application/json"
+```
+
+A tool can choose to use this information is several ways. Any supported or deprecated API version is allowable. User
+interface tools should filter out deprecated API versions by default, but it could alternatively provide warning if a
+deprecated version is selected or will sunset in the near future. Tools that do not afford user interaction will
+likely select the highest supported API version. Tools should also consider that the `api-supported-versions` and
+`api-deprecated-versions` HTTP headers can be reported multiple times as defined in [RFC 2616 §4.2].
+
+An OData service which does not support API versioning should return with HTTP `501` (Not Implemented) as defined in
+[OData: Protocol §9.3.1] of the OData v4.0 specification. However, given that API versioning behaviors of an OData
+service are not explicitly defined in the OData protocol, a client may also respond with HTTP `405` (Method Not
+Allowed). Tools should graceful fallback to the standard metadata query operations when API versioning information is
+unavailable.
+
+[configured default API version]: ../config/options.md
+[RFC 2616 §4.2]: https://tools.ietf.org/html/rfc2616#section-4.2
+[OData: Protocol §9.3.1]: http://docs.oasis-open.org/odata/odata/v4.0/os/part1-protocol/odata-v4.0-os-part1-protocol.html#_Toc372793653
\ No newline at end of file
diff --git a/wiki/src/shared/odata/model-builder-post.md b/wiki/src/shared/odata/model-builder-post.md
new file mode 100644
index 000000000..d122db793
--- /dev/null
+++ b/wiki/src/shared/odata/model-builder-post.md
@@ -0,0 +1,36 @@
+## Default Model Configuration
+
+The `DefaultModelConfiguration` property defines a callback that can be used to apply a default model configuration.
+Specifying a callback is useful if you have a configuration that applies to all models or if you want to have a single,
+inline model configuration.
+
+```c#
+var modelBuilder = new VersionedODataModelBuilder( configuration )
+{
+ DefaultModelConfiguration = ( builder, apiVersion, routePrefix )
+ {
+ // TODO: default configuration for all models
+ }
+};
+```
+
+## On Model Created
+
+The `OnModelCreated` property is a callback that serves the same purpose as
+`ODataConventionModelBuilder.OnModelCreated`. This callback can be used to perform any additional setup or configuration
+required after each EDM model is created.
+
+## Get EDM Models
+
+The `GetEdmModels` method behavior is similar to the `ODataModelBuilder.GetEdmModel` method. This method performs the
+following actions:
+
+- Discover and enumerate each service API version
+- For each service API version:
+ - Create an `ODataModelBuilder` via the **ModelBuilderFactory**
+ - Invoke [IModelConfiguration.Apply][model-config] for each item defined in `ModelConfigurations`, including the `DefaultModelConfiguration`, with the current model builder and API version
+ - Invoke `ODataModelBuilder.GetEdmModel` to generate the current EDM model
+ - Apply the `ApiVersionAnnotation` with the current API version to the generated EDM model
+ - Invoke `OnModelCreated` with the current model builder and generated EDM model, if defined
+
+[model-config]: model-config.md
\ No newline at end of file
diff --git a/wiki/src/shared/odata/model-builder-pre.md b/wiki/src/shared/odata/model-builder-pre.md
new file mode 100644
index 000000000..3f980ec1c
--- /dev/null
+++ b/wiki/src/shared/odata/model-builder-pre.md
@@ -0,0 +1,48 @@
+# Versioned Model Builder
+
+The `VersionedODataModelBuilder` is a builder of builders, which enables creating an Entity Data Model (EDM) for each
+service API version.
+
+```c#
+public class VersionedODataModelBuilder
+{
+ public Func ModelBuilderFactory { get; set; }
+ public Action DefaultModelConfiguration { get; set; }
+ public IList ModelConfigurations { get; }
+ public Action OnModelCreated { get; set; }
+ public IEnumerable GetEdmModels();
+ public virtual IEnumerable GetEdmModels(string routePrefix);
+}
+```
+
+## Model Builder Factory
+
+The `ModelBuilderFactory` property defines a factory function used to initialize a new `ODataModelBuilder` for each
+service API version. The default value creates a new instance of the `ODataConventionModelBuilder`. You can update
+this property to substitute your own `ODataModelBuilder` or provide a custom initialization setup.
+
+```c#
+var modelBuilder = new VersionedODataModelBuilder( configuration )
+{
+ ModelBuilderFactory = () => new ODataConventionModelBuilder().EnableLowerCamelCase()
+};
+```
+
+>[!NOTE]
+> Using camel-casing for JSON documents is very common. Beginning 3.0, `EnableLowerCamelCase()` is automatically called.
+
+## Model Configurations
+
+The `ModelConfigurations` property is a collection of [IModelConfiguration][model-config] objects which define the
+configuration of one or more models to be applied for each API version. Although it's not required, it's recommended
+that you create one [IModelConfiguration][model-config] per entity model.
+
+```c#
+var modelBuilder = new VersionedODataModelBuilder( configuration )
+{
+ ModelConfigurations =
+ {
+ new PersonModelConfiguration()
+ }
+};
+```
\ No newline at end of file
diff --git a/wiki/src/shared/odata/model-config.md b/wiki/src/shared/odata/model-config.md
new file mode 100644
index 000000000..f2994203f
--- /dev/null
+++ b/wiki/src/shared/odata/model-config.md
@@ -0,0 +1,104 @@
+# Model Configurations
+
+A model configuration enables OData service authors to apply model setups that are specific to a service API version.
+The [VersionedODataModelBuilder] will call `Apply` for each discovered API version with the current `ODataModelBuilder`.
+
+ ```c#
+public interface IModelConfiguration
+{
+ void Apply( ODataModelBuilder builder, ApiVersion apiVersion, string routePrefix );
+}
+ ```
+
+The implementation of a model configuration can provide all variations of a model or they can be spit across multiple
+implementations. The applied model does not have to be same across API versions.
+
+Consider the following model:
+
+```c#
+public class Person
+{
+ public int Id { get; set; }
+ public string FirstName { get; set; }
+ public string LastName { get; set; }
+ public string Email { get; set; }
+ public string Phone { get; set; }
+}
+```
+
+Let us assume that the OData service for this model has three versions: `1.0`, `2.0`, and `3.0`. In API version `1.0`,
+a person had the properties `Id`, `FirstName`, and `LastName`. In API version `2.0` we introduced the `Email` property.
+In API version `3.0` we introduced the `Phone` property. If we implement the entire model configuration in a single
+class, it might look like:
+
+```c#
+public class PersonModelConfiguration : IModelConfiguration
+{
+ private void ConfigureV1( ODataModelBuilder builder ) =>
+ ConfigureCurrent( builder ).Ignore( p => p.Email ).Ignore( p => p.Phone );
+
+ private void ConfigureV2( ODataModelBuilder builder ) =>
+ ConfigureCurrent( builder ).Ignore( p => p.Phone );
+
+ private EntityTypeConfiguration ConfigureCurrent( ODataModelBuilder builder )
+ {
+ var person = builder.EntitySet( "People" ).EntityType;
+ person.HasKey( p => p.Id );
+ return person;
+ }
+
+ public void Apply( ODataModelBuilder builder, ApiVersion apiVersion, string routePrefix )
+ {
+ switch ( apiVersion.MajorVersion )
+ {
+ case 1:
+ ConfigureV1( builder );
+ break;
+ case 2:
+ ConfigureV2( builder );
+ break;
+ default:
+ ConfigureCurrent( builder );
+ break;
+ }
+ }
+}
+```
+
+Even through we have a single `Person` class, the EDM associated with the service API version will render the model
+according the requested API version.
+
+ **~/people(1)?api-version=1.0**
+
+ ```json
+ {
+ "id": 1,
+ "firstName": "John",
+ "lastName": "Doe"
+ }
+ ```
+
+ **~/people(1)?api-version=2.0**
+
+ ```json
+ {
+ "id": 1,
+ "firstName": "John",
+ "lastName": "Doe",
+ "email": "john.doe@somewhere.com"
+ }
+ ```
+
+ **~/people(1)?api-version=3.0**
+
+ ```json
+ {
+ "id": 1,
+ "firstName": "John",
+ "lastName": "Doe",
+ "email": "john.doe@somewhere.com",
+ "phone": "555-555-5555"
+ }
+ ```
+
+[VersionedODataModelBuilder]: model-builder.md
\ No newline at end of file
diff --git a/wiki/src/shared/odata/model-substitution.md b/wiki/src/shared/odata/model-substitution.md
new file mode 100644
index 000000000..c30b00e69
--- /dev/null
+++ b/wiki/src/shared/odata/model-substitution.md
@@ -0,0 +1,67 @@
+# Model Substitution
+
+The Entity Data Model (EDM) does not have a one-to-one correlation with the corresponding .NET type. As a result, it's
+common and quite plausible that a single .NET type for a model will be used in different EDMs. This is already supported
+by defining [model configurations].
+
+The challenge is representing this same model in the OData API Explorer. API Explorer consumers, such as OpenAPI/Swagger
+document generators, rely on using Reflection to enumerate the members of a model. These consumers have no intrinsic
+understanding of an EDM and do not know that the response type may be a subset of the discovered .NET type. To address
+this, the OData API Explorer supports _Model Substitution_.
+
+Model substitution takes effect whenever a .NET type does not exactly match the definition of the corresponding EDM
+type. When this occurs, the API Explorer will generate a new .NET type that is a subset of the original type, but
+exactly matches the definition of the EDM type. When consumers use Reflection on the substituted type, it will only be
+a subset of the original .NET type. A similar scenario occurs for OData actions because the action parameters are
+modeled as a dictionary of key/value pairs. A substitution type will be generated which matches the definition of the
+OData action parameters.
+
+There is no configuration or additional setup required to enable _Model Substitution_. As the OData API Explorer would
+otherwise report incorrect response types, this feature is automatically enabled and cannot be disabled out-of-the-box.
+
+Model substitution supports the following features:
+
+- Entity Types
+- Complex Types
+- Structured Type Properties
+ - Self-Referencing
+ - Parent-Child collections
+- Attributes (ex: Model Bound Attributes, Data Annotations, etc)
+ - Defined on the original .NET type (ex: class or structure)
+ - Defined on the original .NET type property
+- Action parameters
+- `IEnumerable` response types
+- `SingleResult` response types
+- `ODataValue` response types
+- `Delta` parameters
+
+The OData API Explorer generates substitution types using the `IModelTypeBuilder`.
+
+```c#
+public interface IModelTypeBuilder
+{
+ Type NewStructuredType(
+ IEdmStructuredType structuredType,
+ Type clrType,
+ ApiVersion apiVersion,
+ IEdmModel edmModel );
+
+ Type NewActionParameters(
+ IServiceProvider services,
+ IEdmAction action,
+ ApiVersion apiVersion,
+ string controllerName );
+}
+```
+
+## Partial OData
+
+The `DefaultModelTypeBuilder` does **not** enable support for ad hoc models using only part of the OData stack. This is
+the default behavior because without an EDM and the OData response writers, no filtering of model members is performed.
+This mostly likely means that you have a different model per API version, which would negate the usefulness of model
+substitution.
+
+If you have a way to filter you models to match what you have configured in an ad hoc EDM, you can re-enable model
+substitution by re-registering `IModelTypeBuilder` with `new DefaultModelTypeBuilder(includeAdHocModels: true)`.
+
+[model configurations]: model-config.md
\ No newline at end of file
diff --git a/wiki/src/shared/odata/overview-pre.md b/wiki/src/shared/odata/overview-pre.md
new file mode 100644
index 000000000..7a8e63cb2
--- /dev/null
+++ b/wiki/src/shared/odata/overview-pre.md
@@ -0,0 +1,9 @@
+# API Versioning with OData
+
+Service API versioning using OData is similar to the normal configuration with a few slight variations. Each implemented
+OData controller has an associated entity set and each entity set is defined in an Entity Data Model (EDM). Once we
+introduce API versioning, each versioned OData controller now needs an EDM per API version. To satisfy this requirement,
+we'll use the new [VersionedODataModelBuilder], build a collection of EDMs for each API version, and then map a set of
+routes for them.
+
+[VersionedODataModelBuilder]: model-builder.md
\ No newline at end of file
diff --git a/wiki/src/shared/quick-starts/existing-services.md b/wiki/src/shared/quick-starts/existing-services.md
new file mode 100644
index 000000000..ba3540070
--- /dev/null
+++ b/wiki/src/shared/quick-starts/existing-services.md
@@ -0,0 +1,17 @@
+# Existing Services
+
+While it's great to plan for an API versioning story for your services upfront, it's all too common to need API
+versioning after your services are in production. The ASP.NET versioning libraries provide features to help you retrofit
+existing services and integrate formal API versioning without breaking your existing clients.
+
+Unless a service is API version-neutral, existing services have some logical, yet undefined, API version that is not
+formally declared by the service or known to a client. In order to prevent existing clients from breaking, they must be
+able to make requests to the original URL without specifying any API version information.
+
+When API versioning is applied, all of the existing services now have an explicit API version on the service side. The
+initial, default API version is `1.0`, but that can be configured to be a different API version. All existing controller
+definitions that do not have explicit API version definitions will now be implicitly bound to the default API version.
+Once a controller has any API version attribution or conventions, it will never be implicitly matched. This enables
+service authors to permanently sunset API versions over time. Controllers that have an implicit API version can be
+confusing to service authors; especially, in a team environment. It is recommended that you explicitly apply API
+versions to all of your existing services when you introduce formal API versioning.
\ No newline at end of file
diff --git a/wiki/src/shared/quick-starts/migration-common.md b/wiki/src/shared/quick-starts/migration-common.md
new file mode 100644
index 000000000..70fd7df8c
--- /dev/null
+++ b/wiki/src/shared/quick-starts/migration-common.md
@@ -0,0 +1,54 @@
+## Namespaces
+
+As the project is no longer part of Microsoft, all namespaces have become `Asp.Versioning.*`. It didn't make sense to
+keep using `Microsoft.*` when things don't line up. Furthermore, what namespace should all new code live under?
+Continuing to use the `Microsoft` namespace seemed _wrong_. An interesting benefit, however, is that using
+`Api.Versioning.*` allows for more consistency across the ASP.NET Web API and Core implementations. The existing
+differences in library namespaces for shared code often led to conditional compiler directives. For ease of use,
+extension methods will continue to live in the namespace they correspond to.
+
+## API Version
+
+The format and default implementation has not changed, but parsing has been broken apart. The new `IApiVersionParser`
+service has been introduced to support this capability. `ApiVersion.Parse` and `ApiVersion.TryParse` have been removed,
+but are replaced by `ApiVersionParser.Default`, which will provide a default implementation.
+
+`ApiVersion.GroupVersion` in .NET 6.0 and beyond is now represented as `DateOnly`. `DateOnly` accurately represents how
+a group or date version was always meant to be, but couldn't be represented without introducing its own type due to the
+design of `DateTime`. The .NET Standard and .NET Framework representations will continue to use `DateTime`.
+
+## API Version Reader
+
+`IApiVersionReader.Read` now returns `IReadOnlyList` instead of `string?`. There are a few reasons for this
+change. First, the _Null Mistake_ is removed as an empty list is completely acceptable. Second, it was entirely possible
+for a particular reader implementation to return more than one value. Consider that `?api-version=1.0&api-version=2.0`
+would return both `1.0` and `2.0`. In previous versions, the implementation would instead throw
+`AmbiguousApiVersionException` that would have to be handled. That behavior becomes problematic for the server to
+correctly report the response to the client. Reading multiple API version values in and of itself isn't exceptional,
+it's just an invalid client request. `ApiVersionReader.Combine` also enables combining different types of readers
+through composition. Readers for different parts of a request are even more likely to return different values.
+Refactoring to return a list makes it very simple to return all of the raw API versions provided without any exceptions
+and regardless of where they were read from.
+
+## API Version Reporting
+
+`IReportApiVersions.Report` now accepts the entire HTTP response as opposed to just the headers. Accepting only the
+headers was an over-normalization that wasn't really necessary. Additional information was also necessary to support
+[sunset policies]. The `Report` overload that accepts `Lazy` has been removed as it's no longer used
+or necessary.
+
+[sunset policies]: https://github.com/dotnet/aspnet-api-versioning/wiki/Version-Policies
+
+## API Version Model Extensions
+
+Extension methods related to retrieving an `ApiVersionModel` have been supplanted by the new extension property
+`ApiVersionMetadata`. The previous `GetApiVersionModel()` extension method, for example, was a shortcut for
+`GetApiVersionModel(ApiVersionMapping.Explicit)`. A new type - `ApiVersionMetadata` - has been introduced that unifies
+the metadata implementation across ASP.NET platforms.
+
+The following is the mapping between the old and new extension methods or properties:
+
+- `GetApiVersionModel(ApiVersionMapping) → ApiVersionMetadata`
+- `GetApiVersionModel() → ApiVersionMetadata.Map(ApiVersionMapping.Explicit)`
+- `MappingTo(ApiVersion) → ApiVersionMetadata.MappingTo(ApiVersion)`
+- `IsMappedTo(ApiVersion) → ApiVersionMetadata.IsMappedTo(ApiVersion)`
\ No newline at end of file
diff --git a/wiki/src/shared/quick-starts/migration-overview.md b/wiki/src/shared/quick-starts/migration-overview.md
new file mode 100644
index 000000000..4c40c501a
--- /dev/null
+++ b/wiki/src/shared/quick-starts/migration-overview.md
@@ -0,0 +1,14 @@
+# Migration From Previous Versions
+
+This topic serves as the guide for migrating from version `<= 5.x.x` to version `>= 6.0.0`. The majority of this
+information has been outlined in previous [discussions].
+
+>[!NOTE]
+>If you'd like more information on the background context, you can read the [Hello Project "Asp"] announcement.
+
+For the most part, you can expect the required changes to be a new package identifier and different namespaces. It is
+entirely possible that you may update those and find the rest of the code to be identical. The mileage will vary
+depending on your level of customization, but you can expect the changes to be trivial in most cases.
+
+[discussions]: https://github.com/dotnet/aspnet-api-versioning/discussions
+[Hello Project "Asp"]: https://github.com/dotnet/aspnet-api-versioning/discussions/807
\ No newline at end of file
diff --git a/wiki/src/shared/quick-starts/migration.md b/wiki/src/shared/quick-starts/migration.md
new file mode 100644
index 000000000..39cbbc5a0
--- /dev/null
+++ b/wiki/src/shared/quick-starts/migration.md
@@ -0,0 +1,108 @@
+# Migration From Previous Versions
+
+This topic serves as the guide for migrating from version `<= 5.x.x` to version `>= 6.0.0`. The majority of this
+information has been outlined in previous [discussions].
+
+>[!TIP]
+>If you'd like more information on the background context, you can read the [Hello Project "Asp"] announcement.
+
+For the most part, you can expect the required changes to be a new package identifier and different namespaces. It is
+entirely possible that you may update those and find the rest of the code to be identical. The mileage will vary
+depending on your level of customization, but you can expect the changes to be trivial in most cases.
+
+[discussions]: https://github.com/dotnet/aspnet-api-versioning/discussions
+[Hello Project "Asp"]: https://github.com/dotnet/aspnet-api-versioning/discussions/807
+
+## Package Identifiers
+
+The original `Microsoft.*` packages are now deprecated and will only undergo servicing:
+
+| Platform | Package | Version | TFM |
+| --------------- | ---------------------------------------------- | -------- | --------------------- |
+| ASP.NET Web API | Microsoft.AspNet.WebApi.Versioning | <= 5.x.x | net45 |
+| ASP.NET Web API | Microsoft.AspNet.WebApi.Versioning.ApiExplorer | <= 5.x.x | net45 |
+| ASP.NET Web API | Microsoft.AspNet.OData.Versioning | <= 5.x.x | net45 |
+| ASP.NET Web API | Microsoft.AspNet.OData.Versioning.ApiExplorer | <= 5.x.x | net45 |
+| ASP.NET Core | Microsoft.AspNetCore.Mvc.Versioning | <= 5.x.x | netcoreapp3.1, net5.0 |
+| ASP.NET Core | Microsoft.AspNetCore.Mvc.ApiExplorer | <= 5.x.x | netcoreapp3.1, net5.0 |
+| ASP.NET Core | Microsoft.AspNetCore.OData | <= 5.x.x | netcoreapp3.1, net5.0 |
+| ASP.NET Core | Microsoft.AspNetCore.OData.ApiExplorer | <= 5.x.x | netcoreapp3.1, net5.0 |
+
+All new features and platform support will use the `Asp.Versioning.*` prefix:
+
+| Platform | Package | Version | TFM |
+| --------------- | ------------------------------------------ | ------- | --------------------------------------- |
+| All | Asp.Versioning.Abstractions | 6.0.0+ | net6.0+, netstandard1.0, netstandard2.0 |
+| ASP.NET Web API | Asp.Versioning.WebApi | 6.0.0+ | net45, net472 |
+| ASP.NET Web API | Asp.Versioning.WebApi.ApiExplorer | 6.0.0+ | net45, net472 |
+| ASP.NET Web API | Asp.Versioning.WebApi.OData | 6.0.0+ | net45, net472 |
+| ASP.NET Web API | Asp.Versioning.WebApi.OData.ApiExplorer | 6.0.0+ | net45, net472 |
+| ASP.NET Core | Asp.Versioning.Http1 | 6.0.0+ | net6.0+ |
+| ASP.NET Core | Asp.Versioning.Mvc2 | 6.0.0+ | net6.0+ |
+| ASP.NET Core | Asp.Versioning.Mvc.ApiExplorer3 | 6.0.0+ | net6.0+ |
+| ASP.NET Core | Asp.Versioning.OData | 6.0.0+ | net6.0+ |
+| ASP.NET Core | Asp.Versioning.OData.ApiExplorer | 6.0.0+ | net6.0+ |
+| All | Asp.Versioning.Http.Client | 6.0.0+ | net6.0+, netstandard1.1, netstandard2.0 |
+
+[1] Base library that supports _Minimal APIs_
+[2] MVC Core with controller support
+[3] Supports exploration of _Minimal APIs_ and controllers
+
+## Namespaces
+
+As the project is no longer part of Microsoft, all namespaces have become `Asp.Versioning.*`. It didn't make sense to
+keep using `Microsoft.*` when things don't line up. Furthermore, what namespace should all new code live under?
+Continuing to use the `Microsoft` namespace seemed _wrong_. An interesting benefit, however, is that using
+`Api.Versioning.*` allows for more consistency across the ASP.NET Web API and Core implementations. The existing
+differences in library namespaces for shared code often led to conditional compiler directives. For ease of use,
+extension methods will continue to live in the namespace they correspond to.
+
+## API Version
+
+The format and default implementation has not changed, but parsing has been broken apart. The new `IApiVersionParser`
+service has been introduced to support this capability. `ApiVersion.Parse` and `ApiVersion.TryParse` have been removed,
+but are replaced by `ApiVersionParser.Default`, which will provide a default implementation.
+
+`ApiVersion.GroupVersion` in .NET 6.0 and beyond is now represented as `DateOnly`. `DateOnly` accurately represents how
+a group or date version was always meant to be, but couldn't be represented without introducing its own type due to the
+design of `DateTime`. The .NET Standard and .NET Framework representations will continue to use `DateTime`.
+
+## API Version Reader
+
+`IApiVersionReader.Read` now returns `IReadOnlyList` instead of `string?`. There are a few reasons for this
+change. First, the _Null Mistake_ is removed as an empty list is completely acceptable. Second, it was entirely possible
+for a particular reader implementation to return more than one value. Consider that `?api-version=1.0&api-version=2.0`
+would return both `1.0` and `2.0`. In previous versions, the implementation would instead throw
+`AmbiguousApiVersionException` that would have to be handled. That behavior becomes problematic for the server to
+correctly report the response to the client. Reading multiple API version values in and of itself isn't exceptional,
+it's just an invalid client request. `ApiVersionReader.Combine` also enables combining different types of readers
+through composition. Readers for different parts of a request are even more likely to return different values.
+Refactoring to return a list makes it very simple to return all of the raw API versions provided without any exceptions
+and regardless of where they were read from.
+
+## API Version Reporting
+
+`IReportApiVersions.Report` now accepts the entire HTTP response as opposed to just the headers. Accepting only the
+headers was an over-normalization that wasn't really necessary. Additional information was also necessary to support
+[sunset policies]. The `Report` overload that accepts `Lazy` has been removed as it's no longer used
+or necessary.
+
+[sunset policies]: https://github.com/dotnet/aspnet-api-versioning/wiki/Version-Policies
+
+## API Version Model Extensions
+
+Extension methods related to retrieving an `ApiVersionModel` have been supplanted by the new extension property
+`ApiVersionMetadata`. The previous `GetApiVersionModel()` extension method, for example, was a shortcut for
+`GetApiVersionModel(ApiVersionMapping.Explicit)`. A new type - `ApiVersionMetadata` - has been introduced that unifies
+the metadata implementation across ASP.NET platforms.
+
+The following is the mapping between the old and new extension methods or properties:
+
+- `GetApiVersionModel(ApiVersionMapping) → ApiVersionMetadata`
+- `GetApiVersionModel() → ApiVersionMetadata.Map(ApiVersionMapping.Explicit)`
+- `MappingTo(ApiVersion) → ApiVersionMetadata.MappingTo(ApiVersion)`
+- `IsMappedTo(ApiVersion) → ApiVersionMetadata.IsMappedTo(ApiVersion)`
+
+
+
+
diff --git a/wiki/src/shared/quick-starts/new-services.md b/wiki/src/shared/quick-starts/new-services.md
new file mode 100644
index 000000000..8a4704b7b
--- /dev/null
+++ b/wiki/src/shared/quick-starts/new-services.md
@@ -0,0 +1,5 @@
+# New Services
+
+When a service author creates new services that consider API versioning upfront, then the configuration and setup is very straightforward. The following examples provide a quick start setup for the respective platforms with default configurations.
+
+API versions can be expressed with .NET attributes or by configured conventions. These examples all use .NET attributes. If you're interested in using conventions instead, please review the [API version conventions](../configuring-your-application/api-version-conventions.md) topic.
\ No newline at end of file
diff --git a/wiki/src/shared/version-discovery.md b/wiki/src/shared/version-discovery.md
new file mode 100644
index 000000000..fe6603c50
--- /dev/null
+++ b/wiki/src/shared/version-discovery.md
@@ -0,0 +1,19 @@
+# Version Discovery
+
+Requiring an explicit service version helps ensure existing clients don’t break, but we also need a way to advertise
+which service versions are currently supported and which versions are deprecated.
+
+To facilitate this need, services should respond with the `api-supported-versions` and `api-deprecated-versions`, which
+are multi-value HTTP headers that indicate the supported and deprecated API versions, respectively. A deprecated version
+is still implemented, but is expected to be permanently removed in six months or more. When a version is no longer
+supported, it should stop being advertised. Additional information can be provided via [versioning policies].
+
+Reporting API versions is disabled by default. Service authors can enable this behavior for all services by setting the
+[ApiVersioningOptions.ReportApiVersions] to true or scoped to individual services by applying the `[ReportApiVersions]`
+attribute or the `ReportApiVersions()` convention.
+
+Service authors might also choose to implement the `OPTIONS` method so that clients and tooling can interrogate which
+API versions their service supports.
+
+[versioning policies]: version-policies.md
+[ApiVersioningOptions.ReportApiVersions]: config/api-versioning-options.md
\ No newline at end of file
diff --git a/wiki/src/shared/version-format.md b/wiki/src/shared/version-format.md
new file mode 100644
index 000000000..bc2186faf
--- /dev/null
+++ b/wiki/src/shared/version-format.md
@@ -0,0 +1,115 @@
+# Version Format
+
+Services are versioned using a version group (e.g. date) or major and minor version scheme with an optional status. The
+version format has the following syntax:
+
+```ebnf
+letter = "A" | "B" | "C" | "D" | "E" | "F" | "G"
+ | "H" | "I" | "J" | "K" | "L" | "M" | "N"
+ | "O" | "P" | "Q" | "R" | "S" | "T" | "U"
+ | "V" | "W" | "X" | "Y" | "Z" | "a" | "b"
+ | "c" | "d" | "e" | "f" | "g" | "h" | "i"
+ | "j" | "k" | "l" | "m" | "n" | "o" | "p"
+ | "q" | "r" | "s" | "t" | "u" | "v" | "w"
+ | "x" | "y" | "z" ;
+
+positive = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
+
+digit = "0" | positive ;
+
+day = ( [ "0" ] positive ) | ( "1" | "2" ) digit | ( "3" ( "0" | "1" ) ) ;
+
+month = ( [ "0" ] positive ) | ( "1" ( "0" | "1" | "2" ) ) ;
+
+year = 4 * digit ;
+
+group = year "-" month "-" day ;
+
+version = { digit } [ "." { digit } ] ;
+
+status = letter [ { letter | digit | "." } { letter | digit } ] ;
+
+api-version = ( group | version ) [ "-" status ] ;
+
+```
+
+The version status allows you to provide a condition to a version such as **alpha**, **beta**, **rc**, and
+so on. While the status is optional, either the version group or the major and minor versions must be specified.
+
+## Versioned Request
+
+By default, clients must explicitly request the version of a service via the **api-version** query string parameter or
+URL path segment per the [Microsoft REST Guidelines for versioning]. It is possible to customize this behavior for
+legacy and other non-compliant services, which will be covered in the **Advanced Versioning** topic.
+
+>[!NOTE]
+>When versioning by URL segment, the `v` prefix is neither required nor part of the API version.
+
+[Microsoft REST Guidelines for versioning]: https://github.com/Microsoft/api-guidelines/blob/master/Guidelines.md#12-versioning
+
+## Versioned Request Examples
+
+The following outlines examples of various service version formats:
+
+- /api/foo?api-version=1.0
+- /api/foo?api-version=2.0-alpha
+- /api/foo?api-version=2015-05-01.3.0
+- /api/v1/foo
+- /api/v2.0-alpha/foo
+- /api/v2015-05-01.3.0/foo
+
+## Custom
+
+The `ApiVersion` class implements `IFormattable` and uses the `ApiVersionFormatProvider` for formatting by default. The
+following table outlines the supported format specifiers.
+
+| Format Specifier | Description | Examples |
+| ---------------- | ----------- | -------- |
+| F | Full API version as _[group version][.major[.minor]][-status]_ | 2017-05-01.1-RC -> 2017-05-01.1-RC |
+| FF | Full API version with optional minor version as _[group version][.major[.minor,0]][-status]_ | 2017-05-01.1-RC -> 2017-05-01.1.0-RC |
+| G | Group version as _yyyy-MM-dd_ | 2017-05-01.1-RC -> 2017-05-01 |
+| GG | Group version as _yyyy-MM-dd_ with status | 2017-05-01.1-RC -> 2017-05-01-RC |
+| y | Group version year from 0 to 99 | 2001-05-01.1-RC -> 1 |
+| yy | Group version year from 00 to 99 | 2001-05-01.1-RC -> 01 |
+| yyy | Group version year with a minimum of three digits | 2017-05-01.1-RC -> 017 |
+| yyyy | Group version year as a four-digit number | 2017-05-01.1-RC -> 2017 |
+| M | Group version month from 1 through 12 | 2001-05-01.1-RC -> 5 |
+| MM | Group version month from 01 through 12 | 2001-05-01.1-RC -> 05 |
+| MMM | Group version abbreviated name of the month | 2001-06-01.1-RC -> Jun |
+| MMMM | Group version full name of the month | 2001-06-01.1-RC -> June |
+| d | Group version day of the month, from 1 through 31 | 2001-05-01.1-RC -> 1 |
+| dd | Group version day of the month, from 01 through 31 | 2001-05-01.1-RC -> 01 |
+| ddd | Group version abbreviated name of the day of the week | 2001-05-01.1-RC -> Mon |
+| dddd | Group version full name of the day of the week | 2001-05-01.1-RC -> Monday |
+| v | Minor version | 2001-05-01.1-RC -> 1 1.1 -> 1 |
+| V | Major version | 1.0-RC -> 1 2.0 -> 2 |
+| VV | Major and minor version | 1-RC -> 1 1.1-RC -> 1.1 1.1 -> 1.1 |
+| VVV | Major, optional minor version, and status | 1-RC -> 1-RC 1.1 -> 1.1 |
+| VVVV | Major, minor version, and status | 1-RC -> 1.0-RC 1.1 -> 1.1 1 -> 1.0 |
+| S | Status | 1.0-Beta -> Beta |
+| p | Padded minor version with default of two digits | 1.1 -> 01 1 -> 00 |
+| p[_n_] | Padded minor version with _N_ digits | **p2**: 1.1 -> 01 **p3**: 1.1 -> 001 |
+| P | Padded major version with default of two digits | 2.1 -> 02 2 -> 02 |
+| P[_n_] | Padded major version with _N_ digits | **P2**: 2.1 -> 02 **P3**: 2.1 -> 002 |
+| PP | Padded major and minor version with a default of two digits | 2.1 -> 02.01 2 -> 02.00 |
+| PPP | Padded major, optional minor version, and status with a default of two digits | 1-RC -> 01-RC 1.1-RC -> 01.01-RC |
+| PPPP | Padded major, minor version, and status with a default of two digits | 1-RC -> 01.00-RC 1.1-RC -> 01.01-RC |
+
+### Custom Examples
+
+```c#
+var apiVersion = new ApiVersion( 1, 0 );
+Console.WriteLine( "Welcome to version " + apiVersion.ToString( "V" ) );
+
+apiVersion = new ApiVersion( 1, 1, "Beta" );
+var message = string.Format( "Welcome to version {0:VV}{0:' ('S')'}", apiVersion );
+Console.WriteLine( message );
+
+apiVersion = new ApiVersion( 2, 0 );
+message = string.Format( "Welcome to version {0:VV}{0:' ('S')'}", apiVersion );
+Console.WriteLine( message );
+
+// Output: Welcome to version 1
+// Output: Welcome to version 1.1 (Beta)
+// Output: Welcome to version 2.0
+```
diff --git a/wiki/src/shared/version-policies.md b/wiki/src/shared/version-policies.md
new file mode 100644
index 000000000..bd6a859ed
--- /dev/null
+++ b/wiki/src/shared/version-policies.md
@@ -0,0 +1,99 @@
+# Version Policies
+
+[Version discovery][discovery] supports advertising which API versions are supported and deprecated via the
+`api-supported-versions` and `api-deprecated-versions` respectively. A key limitation of this support is that it does
+not indicate when an API version will be deprecated, sunset, nor what the stated policy is.
+
+Version policies introduce support for [RFC 9745] (Deprecation) and [RFC 8594] (Sunset). These will allow an API version
+to indicate when it will be deprecated via the `deprecation` header as well as when it will disappear for good via the
+`sunset` header. These headers do not necessarily apply to all API versions; they will only apply to the API version
+that was requested. The deprecation and sunset policies can include additional information such as a web page or OpenAPI
+document. These additional links will conform to [RFC 8288] (Web Linking).
+
+These capabilities are useful, not only for instrumented clients, but also for tooling. As an example, an API might
+support an `OPTIONS` request to retrieve this information for tooling:
+
+```http
+OPTIONS /weather?api-version=1.0 HTTP/2
+host: localhost
+```
+
+```http
+HTTP/2 200
+allow: GET, POST, OPTIONS
+api-supported-versions: 1.0, 2.0, 3.0
+api-deprecated-versions: 0.9
+deprecation: @1640995200
+sunset: Thu, 01 Apr 2022 00:00:00 GMT
+link: ; rel="deprecation"; title="API Policy"; type="text/html"
+link: ; rel="sunset"; title="API Policy"; type="text/html"
+link: ; rel="openapi"; title="OpenAPI"; type="application/json"
+```
+
+This indicates to a client that the requested API version `1.0` was deprecated on January 1, 2022 and will sunset on
+April 1, 2022. It also provides links to public documentation that outlines the API versioning policies as well as where
+to locate the OpenAPI document.
+
+Policies do not have to have a date. The following scenarios are supported:
+
+- Define a policy by API name and version
+- Define a policy by API name for any version
+- Define a policy by API version for any API
+- A sunset policy may have a date
+- A sunset policy can have zero or more links
+
+Supporting a policy with links alone enables advertising a stated policy when you don't know when an API version might
+actually be deprecated or sunset, which will be common for the current version of an API. If a policy is defined, it
+will be emitted through the existing `IReportApiVersions` service. This service is automatically utilized whenever
+`ApiVersioningOptions.ReportApiVersions` is set to `true`, `ReportApiVersionsAttribute` is applied, or the
+`ReportApiVersions()` convention is applied.
+
+## Configuration
+
+The configuration is performed the same way across all platforms via:
+
+```c#
+AddApiVersioning( options =>
+{
+ // version 1.0 deprecates 1/1/2022 with a public policy page
+ options.Policies.Deprecate( 1.0 )
+ .Effective( 2022, 1, 1 )
+ .Link( "https://docs.api.com/policies/deprecation.html" )
+ .Title( "Version Deprecation Policy" )
+ .Type( "text/html" );
+
+ // version 1.0 sunsets 4/1/2022 with a public policy page
+ options.Policies.Sunset( 1.0 )
+ .Effective( 2022, 4, 1 )
+ .Link( "https://docs.api.com/policies/sunset.html" )
+ .Title( "Version Sunset Policy" )
+ .Type( "text/html" );
+
+ // public policy page for version 2.0 without a sunset date
+ options.Policies.Sunset( 2.0 )
+ .Link( "https://docs.api.com/policies/sunset.html" )
+ .Title( "Version Sunset Policy" )
+ .Type( "text/html" )
+})
+```
+
+>[!NOTE]
+>It should be noted that although links confirm to [RFC 8288], all configurable links are meant to be specific to API
+versioning policies. The provided configuration APIs, therefore, only expose a subset of what is configurable and always
+use a relation type of `rel="deprecation"` or `rel="sunset"`. The default implementation can be replaced or extended or
+you can use the `LinkHeaderValue` directly in your own code, which exposes the complete feature set.
+
+## API Explorer Integration
+
+The API Explorer extensions will attach the appropriate `DeprecationPolicy` or `SunsetPolicy` to a
+`ApiVersionDescription` and `ApiDescription`. The policy for a `ApiVersionDescription` will be for an entire API version,
+while the policy for an `ApiDescription` could be for a specific API, version, or combination of both.
+
+The provided information can be used in any number of different ways, but would most likely be used in conjunction with
+OpenAPI. There is currently no direct support for a deprecation or sunset policy in OpenAPI, but it can be exposed via
+an OpenAPI extension or directly in the API documentation.
+
+[discovery]: version-discovery.md
+[RFC 8288]: https://datatracker.ietf.org/doc/html/rfc8288
+[RFC 8594]: https://www.rfc-editor.org/rfc/rfc8594.html
+[RFC 9745]: https://www.rfc-editor.org/rfc/rfc9745.html
\ No newline at end of file
diff --git a/wiki/theme/custom.css b/wiki/theme/custom.css
new file mode 100644
index 000000000..808faee39
--- /dev/null
+++ b/wiki/theme/custom.css
@@ -0,0 +1,215 @@
+/*
+ * Fit a 120-character code line, matching the source authoring guideline.
+ * 120 chars * 8.4px (Source Code Pro 14px) + 20px code padding = 1028px, +2px for
+ * subpixel rounding. Re-measure if the code font or its size ever changes.
+ * mdBook defaults this to 750px in css/variables.css; this file loads last so it wins.
+ */
+:root {
+ --content-max-width: 1030px;
+}
+
+/*
+ * SUMMARY.md entries with no link -- "- [Quick Starts]()" -- render as a bare
+ * span, so they inherit .chapter li { color: var(--sidebar-non-existant) } and
+ * come out dimmer than their linked siblings. Match the linked entries instead.
+ */
+.chapter li {
+ color: var(--sidebar-fg);
+}
+
+strong {
+ color: darkcyan;
+}
+
+/*
+ * Page TOC pinned to the top-right. theme/pagetoc.js moves mdBook's generated
+ * .on-this-page tree into .pagetoc; below the breakpoint it moves it back into
+ * the sidebar, so the two media queries here must use the same breakpoint as
+ * BREAKPOINT in that file.
+ */
+.pagetoc {
+ display: none;
+}
+
+@media (min-width: 1500px) {
+ /* Reserve the right gutter so the centred content column shifts left
+ instead of running underneath the panel. */
+ .content {
+ padding-right: 260px;
+ }
+
+ .pagetoc {
+ display: block;
+ position: fixed;
+ top: var(--menu-bar-height);
+ right: 0;
+ width: 240px;
+ max-height: calc(100vh - var(--menu-bar-height) - 1rem);
+ overflow-y: auto;
+ padding: 1rem 1rem 2rem 0;
+ font-size: 0.85em;
+ line-height: 1.5;
+ }
+}
+
+.pagetoc-title {
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ opacity: 0.6;
+ margin-bottom: 0.5em;
+}
+
+/* Outside the sidebar the tree loses mdBook's .chapter-scoped styling, so the
+ list, links and active state are restyled here. */
+.pagetoc .on-this-page {
+ margin-left: 0;
+ padding-left: 10px;
+ border-inline-start: 2px solid var(--quote-border);
+}
+
+.pagetoc ol {
+ list-style: none;
+ margin: 0;
+ padding-inline-start: 1em;
+}
+
+.pagetoc > .on-this-page > ol {
+ padding-inline-start: 0;
+}
+
+.pagetoc a {
+ display: block;
+ padding: 2px 0;
+ color: var(--fg);
+ opacity: 0.75;
+ text-decoration: none;
+}
+
+.pagetoc a:hover {
+ opacity: 1;
+ color: var(--links);
+}
+
+.pagetoc a.current-header {
+ opacity: 1;
+ color: var(--links);
+ font-weight: 600;
+}
+
+/* The fold chevrons only earn their space in the narrow sidebar. */
+.pagetoc .chapter-fold-toggle {
+ display: none;
+}
+
+/* logo in the header, to the left of the book title */
+.menu-title::before {
+ content: "";
+ display: inline-block;
+ width: 1.6em;
+ height: 1.6em;
+ margin-inline-end: 0.4em;
+ vertical-align: -0.35em;
+ background: url("../logo.svg") no-repeat center / contain;
+}
+
+/* the logo's dark strokes vanish on the dark themes, so swap in a light variant */
+.ayu .menu-title::before,
+.coal .menu-title::before,
+.navy .menu-title::before {
+ background-image: url("../logo-dark.svg");
+}
+
+/*
+ * Site footer, appended to #mdbook-content by theme/footer.js. mdBook ships no footer
+ * of its own, so nothing here is overriding a default. Muted and separated by a
+ * rule so it reads as page furniture rather than as a final section of content.
+ */
+.book-footer {
+ /* #mdbook-content is wider than the text column, so without this the rule
+ above the footer would overhang the prose it is meant to sit under.
+ Matches how centres itself inside the same box. */
+ max-width: var(--content-max-width);
+ margin: 3rem auto 0;
+ padding: 1rem 0 2rem;
+ border-top: 1px solid var(--quote-border);
+ font-size: 0.85em;
+ text-align: center;
+ opacity: 0.7;
+}
+
+/* Inheriting the muted color keeps the links from outweighing the copyright;
+ the underline is what marks them as links, and hover restores the accent.
+ The :link/:visited pair is required, not stylistic: chrome.css colors anchors
+ through `.content a:link`, so a plain `.book-footer a` loses on specificity
+ and the links come out in full accent blue. */
+.book-footer a:link,
+.book-footer a:visited {
+ color: inherit;
+ text-decoration: underline;
+}
+
+.book-footer a:hover {
+ color: var(--links);
+}
+
+/*
+ * mdBook rewrites into an inline