diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml new file mode 100644 index 000000000..728602e12 --- /dev/null +++ b/.github/workflows/gh-pages.yml @@ -0,0 +1,47 @@ +name: Deploy + +# REF: https://github.com/rust-lang/mdBook/wiki/Automated-Deployment%3A-GitHub-Actions + +on: + push: + branches: + - main + paths: + - 'wiki/**' + - '.github/workflows/gh-pages.yml' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Latest mdBook + run: | + tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name') + url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz" + mkdir mdbook + curl -sSL $url | tar -xz --directory=./mdbook + echo `pwd`/mdbook >> $GITHUB_PATH + + - name: Deploy GitHub Pages + run: | + cd wiki + mdbook build + git worktree add gh-pages + git config user.name "GitHub Pages from CI" + git config user.email "" + cd gh-pages + # Delete the ref to avoid keeping history + git update-ref -d refs/heads/gh-pages + rm -rf * + mv ../book/* . + git add . + git commit -m "Deploy $GITHUB_SHA to gh-pages" + git push --force --set-upstream origin gh-pages \ No newline at end of file diff --git a/.gitignore b/.gitignore index f4ef69c48..0b8e04003 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,7 @@ _ReSharper*/ # NCrunch folders and files _NCrunch_*/ _NCrunch_* -*.ncrunch* \ No newline at end of file +*.ncrunch* + +# mdBook generated output (built by CI; see .github/workflows/gh-pages.yml) +wiki/book/ diff --git a/README.md b/README.md index f4fad53ef..4c503c3fe 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![.NET Foundation](https://img.shields.io/badge/.NET%20Foundation-blueviolet.svg)](https://dotnetfoundation.org/projects/aspnet-api-versioning) +[![.NET Foundation](https://img.shields.io/badge/.NET%20Foundation-blueviolet.svg)](https://dotnetfoundation.org/projects/project-detail/asp.net-api-versioning) [![MIT License](https://img.shields.io/github/license/dotnet/aspnet-api-versioning?color=%230b0&style=flat-square)](https://github.com/dotnet/aspnet-api-versioning/blob/main/LICENSE.txt) [![Build Status](https://dev.azure.com/aspnet-api-versioning/build/_apis/build/status/dotnet.aspnet-api-versioning?branchName=main)](https://dev.azure.com/aspnet-api-versioning/build/_build/latest?definitionId=1&branchName=main) @@ -21,85 +21,99 @@ versioning in the past or supported API versioning with semantics that are diffe The supported flavors of ASP.NET are: * **ASP.NET Core** -
Adds API versioning to your ASP.NET Core Minimal API applications
+
Adds API versioning to your ASP.NET Core Minimal API applications
[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.Http.svg)](https://www.nuget.org/packages/Asp.Versioning.Http) [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.Http.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.Http) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../wiki/New-Services-Quick-Start#aspnet-core) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/quick-starts/new-services.html#minimal-api) [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../tree/main/examples/AspNetCore/WebApi) -* **ASP.NET Core MVC** +* **ASP.NET Core with MVC (Core)**
Adds API versioning to your ASP.NET Core MVC (Core) applications
[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.Mvc.svg)](https://www.nuget.org/packages/Asp.Versioning.Mvc) [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.Mvc.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.Mvc) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../wiki/New-Services-Quick-Start#aspnet-core) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/quick-starts/new-services.html#mvc-core) [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../tree/main/examples/AspNetCore/WebApi) -* **ASP.NET Core and OData** +* **ASP.NET Core with gRPC** +
Adds API versioning to your ASP.NET Core gRPC applications
+ + [![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.Grpc.svg)](https://www.nuget.org/packages/Asp.Versioning.Grpc) + [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.Grpc.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.Grpc) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/grpc/overview.html) + [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../tree/main/examples/AspNetCore/WebApi/GrpcOpenApiExample) + +* **ASP.NET Core with OData**
Adds API versioning to your ASP.NET Core applications using OData v4.0
[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.OData.svg)](https://www.nuget.org/packages/Asp.Versioning.OData) [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.OData.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.OData) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../wiki/New-Services-Quick-Start#aspnet-core-with-odata-v40) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/quick-starts/new-services.html#odata) [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../tree/main/examples/AspNetCore/OData) * **ASP.NET Web API** -
Adds API versioning to your Web API applications
+
Adds API versioning to your classic Web API applications
[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.WebApi.svg)](https://www.nuget.org/packages/Asp.Versioning.WebApi) [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.WebApi.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.WebApi) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../wiki/New-Services-Quick-Start#aspnet-web-api) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet/quick-starts/new-services.html#web-api) [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../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
[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.WebApi.OData.svg)](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData) [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.WebApi.OData.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../wiki/New-Services-Quick-Start#aspnet-web-api-with-odata-v40) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet/quick-starts/new-services.html#odata) [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../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
- - [![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.Mvc.ApiExplorer.svg)](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer) - [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.Mvc.ApiExplorer.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../wiki/API-Documentation#aspnet-core) - [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../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
[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.OpenApi.svg)](https://www.nuget.org/packages/Asp.Versioning.OpenApi) [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.OpenApi.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.OpenApi) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../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
+ + [![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.Mvc.ApiExplorer.svg)](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer) + [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.Mvc.ApiExplorer.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/docs/overview.html#minimal-api-or-mvc-core) [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../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
+ + [![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.Grpc.ApiExplorer.svg)](https://www.nuget.org/packages/Asp.Versioning.Grpc.ApiExplorer) + [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.Grpc.ApiExplorer.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.Grpc.ApiExplorer) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/docs/overview.html#grpc) + [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../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
[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.OData.ApiExplorer.svg)](https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer) [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.OData.ApiExplorer.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../wiki/API-Documentation#aspnet-core-with-odata) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet-core/docs/overview.html#odata) [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../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
[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.WebApi.ApiExplorer.svg)](https://www.nuget.org/packages/Asp.Versioning.WebApi.ApiExplorer) [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.WebApi.ApiExplorer.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.WebApi.ApiExplorer) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../wiki/API-Documentation#aspnet-web-api) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet/docs/overview.html#web-api) [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../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
[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.WebApi.OData.ApiExplorer.svg)](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData.ApiExplorer) [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.WebApi.OData.ApiExplorer.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.WebApi.OData.ApiExplorer) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../wiki/API-Documentation#aspnet-web-api-with-odata) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](https://dotnet.github.io/aspnet-api-versioning/aspnet/docs/overview.html#odata) [![Examples](https://img.shields.io/badge/example-code-2B91AF)](../../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 [![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.Http.Client.svg)](https://www.nuget.org/packages/Asp.Versioning.Http.Client) [![NuGet Downloads](https://img.shields.io/nuget/dt/Asp.Versioning.Http.Client.svg?color=green)](https://www.nuget.org/packages/Asp.Versioning.Http.Client) - [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](../../wiki/API-Documentation#http-client) + [![Quick Start](https://img.shields.io/badge/quick-start-9B6CD1)](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 @@ true MIT $(MSBuildThisFileDirectory)..\bin - README.md + + README.md NU5118;$(NoWarn) - + @@ -25,9 +26,7 @@ - - true - snupkg + true true @@ -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.0 API Versioning Abstractions The 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.0 net45;net472 Asp.Versioning ASP.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.0 net45;net472 Asp.Versioning API 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.0 net45;net472 ASP.NET Web API Versioning API Explorer The 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.0 net45;net472 ASP.NET Web API Versioning A 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.Versioning ASP.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.Versioning ASP.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.Versioning ASP.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? additionalValues; private ModelPropertyCollection? properties; private ModelMetadata? elementMetadata; @@ -25,13 +27,13 @@ public GrpcModelMetadata( ModelMetadataIdentity identity, MessageDescriptor? mes private GrpcModelMetadata( ModelMetadataIdentity identity, MessageDescriptor? messageDescriptor, - ApiVersionMetadataCache? cache, + IAnnotation? annotation, ApiVersion? apiVersion, bool repeated = false ) : base( identity ) { this.messageDescriptor = messageDescriptor; - this.cache = cache; + this.annotation = annotation; this.apiVersion = apiVersion; this.repeated = repeated; } @@ -41,11 +43,16 @@ private GrpcModelMetadata( // the API version is only known after the versioned API Explorer has expanded the API description into one // result per version. the metadata of the original description is shared by every clone, so a new instance is // returned rather than mutating the existing one - internal GrpcModelMetadata ForApiVersion( ApiVersionMetadataCache cache, ApiVersion apiVersion ) => - new( Identity, messageDescriptor, cache, apiVersion ) { dataTypeName = dataTypeName }; + internal GrpcModelMetadata ForApiVersion( IAnnotation annotation, ApiVersion apiVersion ) => + new( Identity, messageDescriptor, annotation, apiVersion ) { dataTypeName = dataTypeName }; - public override IReadOnlyDictionary AdditionalValues { get; } = - new Dictionary( capacity: 0 ); + // the API version is recorded under a well-known key so that a consumer can tell metadata which describes a + // subset of a message from metadata which describes the message as declared. a key is used rather than a + // shared type because the API Explorer package is not referenced by design + public override IReadOnlyDictionary AdditionalValues => + additionalValues ??= apiVersion is null + ? [] + : new Dictionary( capacity: 1 ) { [typeof( ApiVersion )] = apiVersion }; // evaluated on demand so that a message which references itself, directly or transitively, doesn't recurse public override ModelPropertyCollection Properties => properties ??= NewProperties(); @@ -71,8 +78,8 @@ internal GrpcModelMetadata ForApiVersion( ApiVersionMetadataCache cache, ApiVers // a repeated field is described by the schema of its element, so the message members hang off the element // rather than off the collection property itself public override ModelMetadata? ElementMetadata => - elementMetadata ??= repeated && messageDescriptor is not null && cache is not null && apiVersion is not null - ? new GrpcModelMetadata( ModelMetadataIdentity.ForType( messageDescriptor.ClrType ), messageDescriptor, cache, apiVersion ) + elementMetadata ??= repeated && messageDescriptor is not null && annotation is not null && apiVersion is not null + ? new GrpcModelMetadata( ModelMetadataIdentity.ForType( messageDescriptor.ClrType ), messageDescriptor, annotation, apiVersion ) : default; public override IEnumerable>? EnumGroupedDisplayNamesAndValues { get; } @@ -126,7 +133,7 @@ internal GrpcModelMetadata ForApiVersion( ApiVersionMetadataCache cache, ApiVers [UnconditionalSuppressMessage( "ILLink", "IL2075", Justification = "Message types are rooted by the generated gRPC service and are never trimmed" )] private ModelPropertyCollection NewProperties() { - if ( messageDescriptor is null || cache is null || apiVersion is null || repeated ) + if ( messageDescriptor is null || annotation is null || apiVersion is null || repeated ) { return new( [] ); } @@ -138,7 +145,7 @@ private ModelPropertyCollection NewProperties() { var field = fields[i]; - if ( !cache.IsVisibleTo( field, apiVersion ) + if ( !annotation.IsVisible( field, apiVersion ) || ModelType.GetProperty( field.PropertyName ) is not { } propertyInfo ) { continue; @@ -148,7 +155,7 @@ private ModelPropertyCollection NewProperties() var nested = field.FieldType == FieldType.Message && !field.IsMap ? field.MessageType : default; var identity = ModelMetadataIdentity.ForProperty( propertyInfo, propertyInfo.PropertyType, ModelType ); - members.Add( new GrpcModelMetadata( identity, nested, cache, apiVersion, field.IsRepeated && !field.IsMap ) ); + members.Add( new GrpcModelMetadata( identity, nested, annotation, apiVersion, field.IsRepeated && !field.IsMap ) ); } return new( members ); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/README.md b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/README.md index f5c4d05b6..0742c772c 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/README.md +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/README.md @@ -1,14 +1,11 @@ ASP.NET API versioning gives you a powerful, but easy-to-use method for adding API versioning semantics to your new -and existing services built with ASP.NET Core MVC and gRPC. The API versioning extensions define simple metadata -attributes and conventions that you use to describe which API versions are implemented by your services. +and existing services built with ASP.NET Core and gRPC. The API versioning extensions define simple metadata attributes +and conventions that you use to describe which API versions are implemented by your services. -This package contains the API version-aware extensions for API Explorer in ASP.NET Core MVC and gRPC, which are useful -in a number of scenarios such as test automation or OpenAPI document generation. Although this package is intended to -be used in combination with ASP.NET API Versioning, this package also supports API Explorer features without versioning. +This package contains the API version-aware extensions for API Explorer in ASP.NET Core gRPC, which are useful in a +number of scenarios such as test automation or OpenAPI document generation. Although this package is intended to be used +in combination with ASP.NET API Versioning, this package also supports API Explorer features without versioning. ## Commonly Used Types -- Asp.Versioning.ApiExplorer.GrpcApiExplorerOptions - -## Release Notes - +- Asp.Versioning.ApiExplorer.GrpcApiExplorerOptions \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/AnnotationCache.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/AnnotationCache.cs new file mode 100644 index 000000000..8026d8d5f --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/AnnotationCache.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +#pragma warning disable CA1812 + +namespace Asp.Versioning; + +using Google.Protobuf.Reflection; +using System.Collections.Concurrent; + +internal sealed class AnnotationCache( IApiVersionParser parser ) : IAnnotation +{ + private readonly ConcurrentDictionary cache = new(); + + public bool TryGet( FieldDescriptor source, [MaybeNullWhen( false )] out ApiVersionRange annotation ) + { + annotation = cache.GetOrAdd( source, Resolve ); + return annotation is not null; + } + + private ApiVersionRange? Resolve( FieldDescriptor field ) + { + if ( field.GetOptions()?.GetExtension( AnnotationsExtensions.Version ) is not { Count: > 0 } versions ) + { + return default; + } + + return ApiVersionRange.Parse( parser, versions ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Annotations.g.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/Annotations.g.cs similarity index 100% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/Annotations.g.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/Annotations.g.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/AnnotationsExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/AnnotationsExtensions.cs similarity index 100% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/AnnotationsExtensions.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/AnnotationsExtensions.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/AnnotationsReflection.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/AnnotationsReflection.cs similarity index 100% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/AnnotationsReflection.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/AnnotationsReflection.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/ApiVersioningGrpcOptions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/ApiVersioningGrpcOptions.cs similarity index 70% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/ApiVersioningGrpcOptions.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/ApiVersioningGrpcOptions.cs index b5d0ee20c..fbeee549b 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/ApiExplorer/ApiVersioningGrpcOptions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/ApiVersioningGrpcOptions.cs @@ -1,12 +1,10 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. -// created by the options infrastructure #pragma warning disable CA1812 -namespace Asp.Versioning.ApiExplorer; +namespace Asp.Versioning; -using Asp.Versioning.Grpc; -using global::Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server; using Microsoft.Extensions.Options; internal sealed class ApiVersioningGrpcOptions : IConfigureOptions diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/Asp.Versioning.Grpc.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/Asp.Versioning.Grpc.csproj new file mode 100644 index 000000000..8ef1e75ca --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/Asp.Versioning.Grpc.csproj @@ -0,0 +1,28 @@ + + + + 10.2.0 + 10.2.0.0 + $(DefaultTargetFramework) + Asp.Versioning + ASP.NET Core API Versioning with gRPC + A service API versioning library for Microsoft ASP.NET Core with gRPC. + Asp;AspNet;AspNetCore;Versioning;gRPC + true + + + + + + + + + + + + + + + + + diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/DependencyInjection/IApiVersioningBuilderExtensions.cs new file mode 100644 index 000000000..3107001f1 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +#pragma warning disable IDE0130 + +namespace Microsoft.Extensions.DependencyInjection; + +using Asp.Versioning; +using Google.Protobuf.Reflection; +using Grpc.AspNetCore.Server; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor; + +/// +/// Provides ASP.NET Core gRPC specific extension methods for . +/// +public static class IApiVersioningBuilderExtensions +{ + /// The extended API versioning builder. + /// The original . + extension( IApiVersioningBuilder builder ) + { + /// + /// Adds ASP.NET Core gRPC support for API versioning. + /// + public IApiVersioningBuilder AddGrpc() + { + ArgumentNullException.ThrowIfNull( builder ); + + var services = builder.Services; + + services.AddGrpc(); + services.TryAddSingleton, AnnotationCache>(); + services.TryAddEnumerable( Transient, ApiVersioningGrpcOptions>() ); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/FieldInterceptor.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/FieldInterceptor.cs new file mode 100644 index 000000000..354f9b814 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/FieldInterceptor.cs @@ -0,0 +1,118 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +#pragma warning disable CA1812 + +namespace Asp.Versioning; + +using Google.Protobuf.Reflection; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; + +internal sealed class FieldInterceptor : Interceptor +{ + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation ) + { + if ( !TryResolve( context, out var annotations, out var apiVersion ) ) + { + return await continuation( request, context ).ConfigureAwait( false ); + } + + MessageFields.Validate( annotations, request, apiVersion ); + + var response = await continuation( request, context ).ConfigureAwait( false ); + + MessageFields.Filter( annotations, response, apiVersion ); + + return response; + } + + public override Task ClientStreamingServerHandler( + IAsyncStreamReader requestStream, + ServerCallContext context, + ClientStreamingServerMethod continuation ) + { + if ( !TryResolve( context, out var annotations, out var apiVersion ) ) + { + return continuation( requestStream, context ); + } + + return Filtered( requestStream, context, continuation, annotations, apiVersion ); + + static async Task Filtered( + IAsyncStreamReader requestStream, + ServerCallContext context, + ClientStreamingServerMethod continuation, + IAnnotation annotations, + ApiVersion apiVersion ) + { + var reader = new ValidatingStreamReader( requestStream, annotations, apiVersion ); + var response = await continuation( reader, context ).ConfigureAwait( false ); + + MessageFields.Filter( annotations, response, apiVersion ); + + return response; + } + } + + public override Task ServerStreamingServerHandler( + TRequest request, + IServerStreamWriter responseStream, + ServerCallContext context, + ServerStreamingServerMethod continuation ) + { + if ( !TryResolve( context, out var annotations, out var apiVersion ) ) + { + return continuation( request, responseStream, context ); + } + + MessageFields.Validate( annotations, request, apiVersion ); + + var writer = new FilteringStreamWriter( responseStream, annotations, apiVersion ); + + return continuation( request, writer, context ); + } + + public override Task DuplexStreamingServerHandler( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context, + DuplexStreamingServerMethod continuation ) + { + if ( !TryResolve( context, out var annotations, out var apiVersion ) ) + { + return continuation( requestStream, responseStream, context ); + } + + var reader = new ValidatingStreamReader( requestStream, annotations, apiVersion ); + var writer = new FilteringStreamWriter( responseStream, annotations, apiVersion ); + + return continuation( reader, writer, context ); + } + + // a call that did not resolve an API version is passed through untouched. there is no version to compare a + // field against, so no field can be shown to be out of range + private static bool TryResolve( + ServerCallContext context, + [NotNullWhen( true )] out IAnnotation? annotations, + [NotNullWhen( true )] out ApiVersion? apiVersion ) + { + if ( context.GetHttpContext() is not { } http || + http.ApiVersioningFeature.RequestedApiVersion is not { } version ) + { + annotations = default; + apiVersion = default; + return false; + } + + annotations = http.RequestServices.GetRequiredService>(); + apiVersion = version; + + return true; + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/FilteringStreamWriter.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/FilteringStreamWriter.cs new file mode 100644 index 000000000..f361ecde1 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/FilteringStreamWriter.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Google.Protobuf.Reflection; +using Grpc.Core; + +/// +/// Filters each message written to a server stream for the requested API version. +/// +/// The type of message written. +internal sealed class FilteringStreamWriter( + IServerStreamWriter stream, + IAnnotation annotations, + ApiVersion apiVersion ) : IServerStreamWriter + where T : class +{ + public WriteOptions? WriteOptions + { + get => stream.WriteOptions; + set => stream.WriteOptions = value; + } + + public Task WriteAsync( T message ) + { + MessageFields.Filter( annotations, message, apiVersion ); + return stream.WriteAsync( message ); + } + + public Task WriteAsync( T message, CancellationToken cancellationToken ) + { + MessageFields.Filter( annotations, message, apiVersion ); + return stream.WriteAsync( message, cancellationToken ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/MessageFields.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/MessageFields.cs new file mode 100644 index 000000000..d115b35c4 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/MessageFields.cs @@ -0,0 +1,142 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Grpc.Core; +using System.Collections; +using System.Globalization; + +/// +/// Applies API version visibility to the fields of a protocol buffer message. +/// +internal static class MessageFields +{ + /// + /// Clears the fields of a response message that are not visible in the requested API version. + /// + internal static void Filter( IAnnotation annotations, object? value, ApiVersion apiVersion ) + { + if ( value is IMessage message ) + { + Visit( annotations, message, apiVersion, validate: false ); + } + } + + /// + /// Rejects a request message that supplies a field which is not visible in the requested API version. + /// + internal static void Validate( IAnnotation annotations, object? value, ApiVersion apiVersion ) + { + if ( value is IMessage message ) + { + Visit( annotations, message, apiVersion, validate: true ); + } + } + + private static void Visit( + IAnnotation annotations, + IMessage message, + ApiVersion apiVersion, + bool validate ) + { + var fields = message.Descriptor.Fields.InDeclarationOrder(); + + for ( var i = 0; i < fields.Count; i++ ) + { + var field = fields[i]; + + if ( !annotations.IsVisible( field, apiVersion ) ) + { + if ( !validate ) + { + field.Accessor.Clear( message ); + } + else if ( IsSet( field, message ) ) + { + throw UnknownField( field ); + } + + continue; + } + + if ( field.FieldType != FieldType.Message ) + { + continue; + } + + var value = field.Accessor.GetValue( message ); + + // a repeated or map field yields a collection rather than a message, so its elements are visited + // individually. the entry type of a map is synthetic and cannot be annotated, so only the values + // of a map are visited + if ( field.IsMap ) + { + foreach ( var item in ( (IDictionary) value ).Values ) + { + if ( item is IMessage entry ) + { + Visit( annotations, entry, apiVersion, validate ); + } + } + } + else if ( field.IsRepeated ) + { + var items = (IList) value; + + for ( var j = 0; j < items.Count; j++ ) + { + if ( items[j] is IMessage element ) + { + Visit( annotations, element, apiVersion, validate ); + } + } + } + else if ( value is IMessage nested ) + { + Visit( annotations, nested, apiVersion, validate ); + } + } + } + + // a client is not told that a field exists in another API version. the field is reported the same way the + // underlying parser reports a field it does not know about, which is what the client would have seen if the + // field had never been defined + private static RpcException UnknownField( FieldDescriptor field ) => + new( new Status( StatusCode.InvalidArgument, "Unknown field: " + field.JsonName ) ); + + private static bool IsSet( FieldDescriptor field, IMessage message ) + { + var accessor = field.Accessor; + + if ( field.IsMap || field.IsRepeated ) + { + return accessor.GetValue( message ) is ICollection { Count: > 0 }; + } + + // a field with explicit presence records whether it was set, which is exact. a proto3 field with implicit + // presence does not, so an explicitly supplied default cannot be distinguished from an absent value and + // the best that can be done is to treat any non-default value as over-posted + if ( field.HasPresence ) + { + return accessor.HasValue( message ); + } + + return accessor.GetValue( message ) switch + { + null => false, + string text => text.Length > 0, + ByteString bytes => bytes.Length > 0, + bool flag => flag, + int number => number != 0, + uint number => number != 0U, + long number => number != 0L, + ulong number => number != 0UL, + float number => number != 0F, + double number => number != 0D, + Enum value => Convert.ToInt64( value, CultureInfo.InvariantCulture ) != 0L, + _ => true, + }; + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/README.md b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/README.md new file mode 100644 index 000000000..dbe65877e --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/README.md @@ -0,0 +1,3 @@ +ASP.NET API versioning gives you a powerful, but easy-to-use method for adding API versioning semantics to your new +and existing gRPC services built with ASP.NET Core. The API versioning extensions define simple metadata attributes +and conventions that you use to describe which API versions are implemented by your services. \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/ReleaseNotes.txt new file mode 100644 index 000000000..5f282702b --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/ReleaseNotes.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/ValidatingStreamReader.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/ValidatingStreamReader.cs new file mode 100644 index 000000000..69b2c0c2a --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/ValidatingStreamReader.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Google.Protobuf.Reflection; +using Grpc.Core; + +/// +/// Validates each message read from a client stream against the requested API version. +/// +/// The type of message read. +internal sealed class ValidatingStreamReader( + IAsyncStreamReader stream, + IAnnotation annotations, + ApiVersion apiVersion ) : IAsyncStreamReader + where T : class +{ + public T Current => stream.Current; + + public async Task MoveNext( CancellationToken cancellationToken ) + { + if ( !await stream.MoveNext( cancellationToken ).ConfigureAwait( false ) ) + { + return false; + } + + MessageFields.Validate( annotations, stream.Current, apiVersion ); + + return true; + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/build/Asp.Versioning.Grpc.ApiExplorer.targets b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/build/Asp.Versioning.Grpc.targets similarity index 100% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/build/Asp.Versioning.Grpc.ApiExplorer.targets rename to src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/build/Asp.Versioning.Grpc.targets diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/protos/asp/api/annotations.proto b/src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/protos/asp/api/annotations.proto similarity index 100% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Grpc.ApiExplorer/protos/asp/api/annotations.proto rename to src/AspNetCore/WebApi/src/Asp.Versioning.Grpc/protos/asp/api/annotations.proto diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/AnnotationCache.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/AnnotationCache.cs new file mode 100644 index 000000000..e3d9a9519 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/AnnotationCache.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using System.Collections.Concurrent; +using System.Reflection; + +/// +/// Represents a cache of member-specified annotations; for example, . +/// +internal sealed class AnnotationCache : IAnnotation +{ + private readonly ConcurrentDictionary cache = new(); + + public bool TryGet( MemberInfo source, [MaybeNullWhen( false )] out ApiVersionRange annotation ) + { + annotation = cache.GetOrAdd( source, Resolve ); + return annotation is not null; + } + + private static ApiVersionRange? Resolve( MemberInfo member ) => + member.GetCustomAttribute( inherit: true )?.Range; +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj index 6211ffcc0..6a0818b25 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj @@ -1,8 +1,8 @@  - 10.0.1 - 10.0.0.0 + 10.2.0 + 10.2.0.0 $(DefaultTargetFramework) Asp.Versioning ASP.NET Core API Versioning @@ -15,10 +15,21 @@ + + + + + + + + + diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs index 29916a74b..2ab1d22f1 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ namespace Microsoft.Extensions.DependencyInjection; using Asp.Versioning; using Asp.Versioning.ApiExplorer; +using Asp.Versioning.Json; using Asp.Versioning.Routing; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Json; @@ -13,6 +14,7 @@ namespace Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using System; +using System.Reflection; using static ServiceDescriptor; using static System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes; @@ -134,6 +136,7 @@ private static void AddApiVersioningServices( IServiceCollection services ) { ArgumentNullException.ThrowIfNull( services ); + services.AddHttpContextAccessor(); services.AddTransient( ApiVersionAsService ); services.TryAddSingleton(); services.AddSingleton( static sp => sp.GetRequiredService>().Value.ApiVersionReader ); @@ -147,6 +150,8 @@ private static void AddApiVersioningServices( IServiceCollection services ) services.TryAddEnumerable( Singleton() ); services.TryAddEnumerable( Singleton() ); services.TryAddTransient(); + services.TryAddSingleton>( static _ => new AnnotationCache() ); + services.TryAddEnumerable( Transient, MemberVisibilityJsonOptionsSetup>() ); services.Replace( WithLinkGeneratorDecorator( services ) ); } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Json/MemberVisibilityJsonModifier.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Json/MemberVisibilityJsonModifier.cs new file mode 100644 index 000000000..550aad2be --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Json/MemberVisibilityJsonModifier.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Json; + +using Microsoft.AspNetCore.Http; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using static System.Globalization.CultureInfo; + +/// +/// Applies API version member visibility to a JSON contract. +/// +/// +/// +/// A contract is resolved once per type, but the API version is only known per request. The range a member is +/// visible in is therefore resolved here, while the comparison against the requested API version is deferred to +/// the point the member is read or written. +/// +/// +/// A member that is visible in every API version is left untouched, so a contract with no filtered members costs +/// nothing beyond the one-time resolution performed here. +/// +/// +internal sealed class MemberVisibilityJsonModifier( + IAnnotation annotation, + IHttpContextAccessor httpContextAccessor ) +{ + private static readonly CompositeFormat UnmappedMember = CompositeFormat.Parse( SR.UnmappedMember ); + + internal void Modify( JsonTypeInfo typeInfo ) + { + if ( typeInfo.Kind != JsonTypeInfoKind.Object ) + { + return; + } + + var properties = typeInfo.Properties; + + for ( var i = 0; i < properties.Count; i++ ) + { + var property = properties[i]; + + if ( property.AttributeProvider is not MemberInfo member ) + { + continue; + } + + // a member that is not annotated needs no per-request evaluation at all and is left untouched + if ( !annotation.TryGet( member, out var apiVersions ) ) + { + continue; + } + + Hide( typeInfo, property, apiVersions ); + } + } + + private void Hide( JsonTypeInfo typeInfo, JsonPropertyInfo property, ApiVersionRange apiVersions ) + { + var shouldSerialize = property.ShouldSerialize; + + property.ShouldSerialize = ( obj, value ) => + IsVisible( apiVersions ) && ( shouldSerialize is null || shouldSerialize( obj, value ) ); + + if ( property.Set is not { } set ) + { + return; + } + + var name = property.Name; + var type = typeInfo.Type; + + // a member that is not visible does not exist as far as the client is concerned, so supplying it is + // reported the same way as supplying a member that was never defined + property.Set = ( obj, value ) => + { + if ( !IsVisible( apiVersions ) ) + { + throw new JsonException( string.Format( CurrentCulture, UnmappedMember, name, type.Name ) ); + } + + set( obj, value ); + }; + } + + // a request that did not resolve an API version is passed through untouched. there is no version to compare + // a member against, so no member can be shown to be out of range. the feature is read rather than the + // HttpContext.ApiVersioningFeature extension so that a request which never went through API versioning does + // not have a feature created for it here + private bool IsVisible( ApiVersionRange apiVersions ) => + httpContextAccessor.HttpContext?.Features.Get() + is not { RequestedApiVersion: { } apiVersion } + || apiVersions.Contains( apiVersion ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Json/MemberVisibilityJsonOptionsSetup.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Json/MemberVisibilityJsonOptionsSetup.cs new file mode 100644 index 000000000..7a675f867 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Json/MemberVisibilityJsonOptionsSetup.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.Http.Json; +using Microsoft.Extensions.Options; +using System.Reflection; +using System.Text.Json.Serialization.Metadata; + +/// +/// Applies API version member visibility to the JSON options used by minimal APIs. +/// +/// The modifier is added after all other configuration has run so that it observes every resolver the +/// application registered, including any added by the application itself. +internal sealed class MemberVisibilityJsonOptionsSetup( + IAnnotation annotation, + IHttpContextAccessor httpContextAccessor ) : IPostConfigureOptions +{ + private readonly MemberVisibilityJsonModifier modifier = new( annotation, httpContextAccessor ); + + public void PostConfigure( string? name, JsonOptions options ) + { + var serializerOptions = options.SerializerOptions; + + 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.Http/README.md b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md index 7e4930635..ac371a8fb 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md @@ -16,6 +16,4 @@ Minimal APIs. For additional functionality provided by ASP.NET Core MVC use the - Asp.Versioning.IReportApiVersions - Asp.Versioning.IDeprecationPolicyBuilder - Asp.Versioning.ISunsetPolicyBuilder -- Asp.Versioning.QueryStringApiVersionReader - -## Release Notes +- Asp.Versioning.QueryStringApiVersionReader \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt index 464e2030c..5f282702b 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt @@ -1,2 +1 @@ -Fix API version extraction in URLs with status [Issue #1187](https://github.com/dotnet/aspnet-api-versioning/issues/1187) -Support versioning by URL segment for gRPC services [Issue #](https://github.com/dotnet/aspnet-api-versioning/issues/1109) \ No newline at end of file + \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs index 4a56d6b90..f35fe9427 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs @@ -19,7 +19,7 @@ namespace Asp.Versioning { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class SR { @@ -132,6 +132,15 @@ internal static string RequestTypeUnconfigured { } } + /// + /// Looks up a localized string similar to The JSON property '{0}' could not be found on type '{1}'.. + /// + internal static string UnmappedMember { + get { + return ResourceManager.GetString("UnmappedMember", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} must be specified to construct a {1}.. /// diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx index dde23ba11..3047ce905 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx @@ -146,6 +146,9 @@ The request type was not configured. + + The JSON property '{0}' could not be found on type '{1}'. + {0} must be specified to construct a {1}. diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionModelMetadata.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionModelMetadata.cs index bdd9020f6..f70165b85 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionModelMetadata.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionModelMetadata.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Copyright (c) .NET Foundation and contributors. All rights reserved. namespace Asp.Versioning.ApiExplorer; @@ -9,9 +9,8 @@ namespace Asp.Versioning.ApiExplorer; /// Represents the model metadata for an API version. /// [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.ApiExplorer ASP.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.Versioning ASP.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.OpenApi ASP.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.0 Asp.Versioning.Http API 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-bc4359ef1ac5 14.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-3a640d831601 14.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-75d272b78e9d 14.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-978df1eb7d3f 14.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-e9698d27ab3d 14.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-3f88a1d4eb04 14.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-47ad5e89f4e3 14.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-145e5352f99f 14.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-d53a727c2c5e 14.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-1cd28a7e1cdb 14.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-89831d5993d4 14.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-af593e33634f 14.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')))) enable false true - 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) + +[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.Mvc.ApiExplorer.svg)](https://www.nuget.org/packages/Asp.Versioning.Mvc.ApiExplorer) [![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.OpenApi.svg)](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 + +[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.Grpc.svg)](https://www.nuget.org/packages/Asp.Versioning.Grpc) [![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.Grpc.ApiExplorer.svg)](https://www.nuget.org/packages/Asp.Versioning.Grpc.ApiExplorer) [![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.OpenApi.svg)](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 + +[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.OData.ApiExplorer.svg)](https://www.nuget.org/packages/Asp.Versioning.OData.ApiExplorer) [![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.OpenApi.svg)](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 + +[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.WebApi.svg)](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 + +[![NuGet Package](https://img.shields.io/nuget/v/Asp.Versioning.WebApi.OData.svg)](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** | 4001 2 | +| **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 wrapped in .fa-svg + * and ships no Font Awesome stylesheet, so fa-stack/fa-stack-1x/fa-stack-2x resolve to + * nothing. Worse, any extra class on the makes mdBook skip the rewrite and emit a + * bare that paints nothing -- keep those class lists to style + icon name only and + * do the layering here. Both icons sit at 1em rather than Font Awesome's 2em/1x ratio, + * which is what the severity icons want: a white disc directly behind the glyph so its + * knocked-out center reads white on the dark themes instead of showing the page through. + */ +.fa-stack { + position: relative; + display: inline-block; + width: 1em; + height: 1em; + vertical-align: -0.1em; +} + +.fa-stack .fa-svg { + position: absolute; + top: 0; + left: 0; +} + +/* the -0.1em baseline nudge on .fa-svg svg is for the inline case; .fa-stack owns it now */ +.fa-stack .fa-svg svg { + margin-bottom: 0; +} + +/* + * fa-circle and the outer subpath of the fa-circle-* glyphs are the same radius, so the + * backing disc's anti-aliased rim fringes out from under the glyph on top. Shrink it just + * below the glyph's edge; scaling defaults to the center, so the two stay concentric. + */ +.fa-stack .fa-svg:first-child svg { + transform: scale(0.94); +} + +/* + * Severity icons. Authored as src/icons/severity/*.md and pulled in with {{#include}}, + * so the markup is written once and the colors live here rather than inline -- mdBook + * drops any carrying an extra class, so the severity class goes on the wrapper and + * the glyph picks the color up through currentColor. info and error stack their glyph + * over a white disc; warning is a bare triangle, where a disc would show past the + * sloped edges. + */ +.fa-stack .fa-svg:first-child { + color: white; +} + +.severity-info { + color: rgb(68, 114, 196); +} + +.severity-warning { + color: rgb(255, 192, 0); +} + +.severity-error { + color: rgb(192, 0, 0); +} diff --git a/wiki/theme/favicon.png b/wiki/theme/favicon.png new file mode 100644 index 000000000..8170c8d48 Binary files /dev/null and b/wiki/theme/favicon.png differ diff --git a/wiki/theme/favicon.svg b/wiki/theme/favicon.svg new file mode 100644 index 000000000..4b3a08ad4 --- /dev/null +++ b/wiki/theme/favicon.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wiki/theme/footer.js b/wiki/theme/footer.js new file mode 100644 index 000000000..5716ac6a4 --- /dev/null +++ b/wiki/theme/footer.js @@ -0,0 +1,54 @@ +// Site footer. +// +// mdBook has no footer setting, and theme/index.hbs is deliberately not +// overridden here -- forking the page template would turn every mdBook upgrade +// into a diff against upstream, for markup this small. So the element is +// appended client-side instead. +// +// It attaches to #mdbook-content rather than
so that it sits below the +// previous/next chapter links, and so it inherits the right-hand padding that +// custom.css reserves for the pagetoc panel above 1500px -- centring it over +// the text column rather than over the viewport. +(function bookFooter() { + const COPYRIGHT = '© .NET Foundation and contributors'; + + // No year, matching LICENSE.txt, which does not carry one either. A + // hard-coded year in a static site is only correct until January. + const LINKS = [ + { + text: 'MIT', + href: 'https://github.com/dotnet/aspnet-api-versioning/blob/main/LICENSE.txt', + }, + { + text: 'GitHub', + href: 'https://github.com/dotnet/aspnet-api-versioning', + }, + { + text: '.NET Foundation', + href: 'https://dotnetfoundation.org/projects/project-detail/asp.net-api-versioning', + }, + ]; + + document.addEventListener('DOMContentLoaded', function () { + const content = document.querySelector('#mdbook-content'); + + if (content === null) { + return; + } + + const footer = document.createElement('footer'); + + footer.className = 'book-footer'; + footer.append(COPYRIGHT); + + for (const link of LINKS) { + const anchor = document.createElement('a'); + + anchor.href = link.href; + anchor.textContent = link.text; + footer.append(' · ', anchor); + } + + content.append(footer); + }); +})(); diff --git a/wiki/theme/highlight.js b/wiki/theme/highlight.js new file mode 100644 index 000000000..2dff7ca3a --- /dev/null +++ b/wiki/theme/highlight.js @@ -0,0 +1,74 @@ +/* + Highlight.js 10.1.1 (93fd0d73) + License: BSD-3-Clause + Copyright (c) 2006-2020, Ivan Sagalaev +*/ +var hljs=function(){"use strict";function e(n){Object.freeze(n);var t="function"==typeof n;return Object.getOwnPropertyNames(n).forEach((function(r){!Object.hasOwnProperty.call(n,r)||null===n[r]||"object"!=typeof n[r]&&"function"!=typeof n[r]||t&&("caller"===r||"callee"===r||"arguments"===r)||Object.isFrozen(n[r])||e(n[r])})),n}class n{constructor(e){void 0===e.data&&(e.data={}),this.data=e.data}ignoreMatch(){this.ignore=!0}}function t(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function r(e,...n){var t={};for(const n in e)t[n]=e[n];return n.forEach((function(e){for(const n in e)t[n]=e[n]})),t}function a(e){return e.nodeName.toLowerCase()}var i=Object.freeze({__proto__:null,escapeHTML:t,inherit:r,nodeStream:function(e){var n=[];return function e(t,r){for(var i=t.firstChild;i;i=i.nextSibling)3===i.nodeType?r+=i.nodeValue.length:1===i.nodeType&&(n.push({event:"start",offset:r,node:i}),r=e(i,r),a(i).match(/br|hr|img|input/)||n.push({event:"stop",offset:r,node:i}));return r}(e,0),n},mergeStreams:function(e,n,r){var i=0,s="",o=[];function l(){return e.length&&n.length?e[0].offset!==n[0].offset?e[0].offset"}function u(e){s+=""}function d(e){("start"===e.event?c:u)(e.node)}for(;e.length||n.length;){var g=l();if(s+=t(r.substring(i,g[0].offset)),i=g[0].offset,g===e){o.reverse().forEach(u);do{d(g.splice(0,1)[0]),g=l()}while(g===e&&g.length&&g[0].offset===i);o.reverse().forEach(c)}else"start"===g[0].event?o.push(g[0].node):o.pop(),d(g.splice(0,1)[0])}return s+t(r.substr(i))}});const s="",o=e=>!!e.kind;class l{constructor(e,n){this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){this.buffer+=t(e)}openNode(e){if(!o(e))return;let n=e.kind;e.sublanguage||(n=`${this.classPrefix}${n}`),this.span(n)}closeNode(e){o(e)&&(this.buffer+=s)}value(){return this.buffer}span(e){this.buffer+=``}}class c{constructor(){this.rootNode={children:[]},this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){this.top.children.push(e)}openNode(e){const n={kind:e,children:[]};this.add(n),this.stack.push(n)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n),n.children.forEach(n=>this._walk(e,n)),e.closeNode(n)),e}static _collapse(e){"string"!=typeof e&&e.children&&(e.children.every(e=>"string"==typeof e)?e.children=[e.children.join("")]:e.children.forEach(e=>{c._collapse(e)}))}}class u extends c{constructor(e){super(),this.options=e}addKeyword(e,n){""!==e&&(this.openNode(n),this.addText(e),this.closeNode())}addText(e){""!==e&&this.add(e)}addSublanguage(e,n){const t=e.root;t.kind=n,t.sublanguage=!0,this.add(t)}toHTML(){return new l(this,this.options).value()}finalize(){return!0}}function d(e){return e?"string"==typeof e?e:e.source:null}const g="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",h={begin:"\\\\[\\s\\S]",relevance:0},f={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[h]},p={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[h]},b={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},m=function(e,n,t={}){var a=r({className:"comment",begin:e,end:n,contains:[]},t);return a.contains.push(b),a.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),a},v=m("//","$"),x=m("/\\*","\\*/"),E=m("#","$");var _=Object.freeze({__proto__:null,IDENT_RE:"[a-zA-Z]\\w*",UNDERSCORE_IDENT_RE:"[a-zA-Z_]\\w*",NUMBER_RE:"\\b\\d+(\\.\\d+)?",C_NUMBER_RE:g,BINARY_NUMBER_RE:"\\b(0b[01]+)",RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",SHEBANG:(e={})=>{const n=/^#![ ]*\//;return e.binary&&(e.begin=function(...e){return e.map(e=>d(e)).join("")}(n,/.*\b/,e.binary,/\b.*/)),r({className:"meta",begin:n,end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)},BACKSLASH_ESCAPE:h,APOS_STRING_MODE:f,QUOTE_STRING_MODE:p,PHRASAL_WORDS_MODE:b,COMMENT:m,C_LINE_COMMENT_MODE:v,C_BLOCK_COMMENT_MODE:x,HASH_COMMENT_MODE:E,NUMBER_MODE:{className:"number",begin:"\\b\\d+(\\.\\d+)?",relevance:0},C_NUMBER_MODE:{className:"number",begin:g,relevance:0},BINARY_NUMBER_MODE:{className:"number",begin:"\\b(0b[01]+)",relevance:0},CSS_NUMBER_MODE:{className:"number",begin:"\\b\\d+(\\.\\d+)?(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[h,{begin:/\[/,end:/\]/,relevance:0,contains:[h]}]}]},TITLE_MODE:{className:"title",begin:"[a-zA-Z]\\w*",relevance:0},UNDERSCORE_TITLE_MODE:{className:"title",begin:"[a-zA-Z_]\\w*",relevance:0},METHOD_GUARD:{begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:function(e){return Object.assign(e,{"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{n.data._beginMatch!==e[1]&&n.ignoreMatch()}})}}),N="of and for in not or if then".split(" ");function w(e,n){return n?+n:function(e){return N.includes(e.toLowerCase())}(e)?0:1}const R=t,y=r,{nodeStream:k,mergeStreams:O}=i,M=Symbol("nomatch");return function(t){var a=[],i={},s={},o=[],l=!0,c=/(^(<[^>]+>|\t|)+|\n)/gm,g="Could not find the language '{}', did you forget to load/include a language module?";const h={disableAutodetect:!0,name:"Plain text",contains:[]};var f={noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:null,__emitter:u};function p(e){return f.noHighlightRe.test(e)}function b(e,n,t,r){var a={code:n,language:e};S("before:highlight",a);var i=a.result?a.result:m(a.language,a.code,t,r);return i.code=a.code,S("after:highlight",i),i}function m(e,t,a,s){var o=t;function c(e,n){var t=E.case_insensitive?n[0].toLowerCase():n[0];return Object.prototype.hasOwnProperty.call(e.keywords,t)&&e.keywords[t]}function u(){null!=y.subLanguage?function(){if(""!==A){var e=null;if("string"==typeof y.subLanguage){if(!i[y.subLanguage])return void O.addText(A);e=m(y.subLanguage,A,!0,k[y.subLanguage]),k[y.subLanguage]=e.top}else e=v(A,y.subLanguage.length?y.subLanguage:null);y.relevance>0&&(I+=e.relevance),O.addSublanguage(e.emitter,e.language)}}():function(){if(!y.keywords)return void O.addText(A);let e=0;y.keywordPatternRe.lastIndex=0;let n=y.keywordPatternRe.exec(A),t="";for(;n;){t+=A.substring(e,n.index);const r=c(y,n);if(r){const[e,a]=r;O.addText(t),t="",I+=a,O.addKeyword(n[0],e)}else t+=n[0];e=y.keywordPatternRe.lastIndex,n=y.keywordPatternRe.exec(A)}t+=A.substr(e),O.addText(t)}(),A=""}function h(e){return e.className&&O.openNode(e.className),y=Object.create(e,{parent:{value:y}})}function p(e){return 0===y.matcher.regexIndex?(A+=e[0],1):(L=!0,0)}var b={};function x(t,r){var i=r&&r[0];if(A+=t,null==i)return u(),0;if("begin"===b.type&&"end"===r.type&&b.index===r.index&&""===i){if(A+=o.slice(r.index,r.index+1),!l){const n=Error("0 width match regex");throw n.languageName=e,n.badRule=b.rule,n}return 1}if(b=r,"begin"===r.type)return function(e){var t=e[0],r=e.rule;const a=new n(r),i=[r.__beforeBegin,r["on:begin"]];for(const n of i)if(n&&(n(e,a),a.ignore))return p(t);return r&&r.endSameAsBegin&&(r.endRe=RegExp(t.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")),r.skip?A+=t:(r.excludeBegin&&(A+=t),u(),r.returnBegin||r.excludeBegin||(A=t)),h(r),r.returnBegin?0:t.length}(r);if("illegal"===r.type&&!a){const e=Error('Illegal lexeme "'+i+'" for mode "'+(y.className||"")+'"');throw e.mode=y,e}if("end"===r.type){var s=function(e){var t=e[0],r=o.substr(e.index),a=function e(t,r,a){let i=function(e,n){var t=e&&e.exec(n);return t&&0===t.index}(t.endRe,a);if(i){if(t["on:end"]){const e=new n(t);t["on:end"](r,e),e.ignore&&(i=!1)}if(i){for(;t.endsParent&&t.parent;)t=t.parent;return t}}if(t.endsWithParent)return e(t.parent,r,a)}(y,e,r);if(!a)return M;var i=y;i.skip?A+=t:(i.returnEnd||i.excludeEnd||(A+=t),u(),i.excludeEnd&&(A=t));do{y.className&&O.closeNode(),y.skip||y.subLanguage||(I+=y.relevance),y=y.parent}while(y!==a.parent);return a.starts&&(a.endSameAsBegin&&(a.starts.endRe=a.endRe),h(a.starts)),i.returnEnd?0:t.length}(r);if(s!==M)return s}if("illegal"===r.type&&""===i)return 1;if(B>1e5&&B>3*r.index)throw Error("potential infinite loop, way more iterations than matches");return A+=i,i.length}var E=T(e);if(!E)throw console.error(g.replace("{}",e)),Error('Unknown language: "'+e+'"');var _=function(e){function n(n,t){return RegExp(d(n),"m"+(e.case_insensitive?"i":"")+(t?"g":""))}class t{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(e,n){n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]),this.matchAt+=function(e){return RegExp(e.toString()+"|").exec("").length-1}(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const e=this.regexes.map(e=>e[1]);this.matcherRe=n(function(e,n="|"){for(var t=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./,r=0,a="",i=0;i0&&(a+=n),a+="(";o.length>0;){var l=t.exec(o);if(null==l){a+=o;break}a+=o.substring(0,l.index),o=o.substring(l.index+l[0].length),"\\"===l[0][0]&&l[1]?a+="\\"+(+l[1]+s):(a+=l[0],"("===l[0]&&r++)}a+=")"}return a}(e),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex;const n=this.matcherRe.exec(e);if(!n)return null;const t=n.findIndex((e,n)=>n>0&&void 0!==e),r=this.matchIndexes[t];return n.splice(0,t),Object.assign(n,r)}}class a{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t;return this.rules.slice(e).forEach(([e,t])=>n.addRule(e,t)),n.compile(),this.multiRegexes[e]=n,n}considerAll(){this.regexIndex=0}addRule(e,n){this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex;const t=n.exec(e);return t&&(this.regexIndex+=t.position+1,this.regexIndex===this.count&&(this.regexIndex=0)),t}}function i(e,n){const t=e.input[e.index-1],r=e.input[e.index+e[0].length];"."!==t&&"."!==r||n.ignoreMatch()}if(e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return function t(s,o){const l=s;if(s.compiled)return l;s.compiled=!0,s.__beforeBegin=null,s.keywords=s.keywords||s.beginKeywords;let c=null;if("object"==typeof s.keywords&&(c=s.keywords.$pattern,delete s.keywords.$pattern),s.keywords&&(s.keywords=function(e,n){var t={};return"string"==typeof e?r("keyword",e):Object.keys(e).forEach((function(n){r(n,e[n])})),t;function r(e,r){n&&(r=r.toLowerCase()),r.split(" ").forEach((function(n){var r=n.split("|");t[r[0]]=[e,w(r[0],r[1])]}))}}(s.keywords,e.case_insensitive)),s.lexemes&&c)throw Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ");return l.keywordPatternRe=n(s.lexemes||c||/\w+/,!0),o&&(s.beginKeywords&&(s.begin="\\b("+s.beginKeywords.split(" ").join("|")+")(?=\\b|\\s)",s.__beforeBegin=i),s.begin||(s.begin=/\B|\b/),l.beginRe=n(s.begin),s.endSameAsBegin&&(s.end=s.begin),s.end||s.endsWithParent||(s.end=/\B|\b/),s.end&&(l.endRe=n(s.end)),l.terminator_end=d(s.end)||"",s.endsWithParent&&o.terminator_end&&(l.terminator_end+=(s.end?"|":"")+o.terminator_end)),s.illegal&&(l.illegalRe=n(s.illegal)),void 0===s.relevance&&(s.relevance=1),s.contains||(s.contains=[]),s.contains=[].concat(...s.contains.map((function(e){return function(e){return e.variants&&!e.cached_variants&&(e.cached_variants=e.variants.map((function(n){return r(e,{variants:null},n)}))),e.cached_variants?e.cached_variants:function e(n){return!!n&&(n.endsWithParent||e(n.starts))}(e)?r(e,{starts:e.starts?r(e.starts):null}):Object.isFrozen(e)?r(e):e}("self"===e?s:e)}))),s.contains.forEach((function(e){t(e,l)})),s.starts&&t(s.starts,o),l.matcher=function(e){const n=new a;return e.contains.forEach(e=>n.addRule(e.begin,{rule:e,type:"begin"})),e.terminator_end&&n.addRule(e.terminator_end,{type:"end"}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n}(l),l}(e)}(E),N="",y=s||_,k={},O=new f.__emitter(f);!function(){for(var e=[],n=y;n!==E;n=n.parent)n.className&&e.unshift(n.className);e.forEach(e=>O.openNode(e))}();var A="",I=0,S=0,B=0,L=!1;try{for(y.matcher.considerAll();;){B++,L?L=!1:(y.matcher.lastIndex=S,y.matcher.considerAll());const e=y.matcher.exec(o);if(!e)break;const n=x(o.substring(S,e.index),e);S=e.index+n}return x(o.substr(S)),O.closeAllNodes(),O.finalize(),N=O.toHTML(),{relevance:I,value:N,language:e,illegal:!1,emitter:O,top:y}}catch(n){if(n.message&&n.message.includes("Illegal"))return{illegal:!0,illegalBy:{msg:n.message,context:o.slice(S-100,S+100),mode:n.mode},sofar:N,relevance:0,value:R(o),emitter:O};if(l)return{illegal:!1,relevance:0,value:R(o),emitter:O,language:e,top:y,errorRaised:n};throw n}}function v(e,n){n=n||f.languages||Object.keys(i);var t=function(e){const n={relevance:0,emitter:new f.__emitter(f),value:R(e),illegal:!1,top:h};return n.emitter.addText(e),n}(e),r=t;return n.filter(T).filter(I).forEach((function(n){var a=m(n,e,!1);a.language=n,a.relevance>r.relevance&&(r=a),a.relevance>t.relevance&&(r=t,t=a)})),r.language&&(t.second_best=r),t}function x(e){return f.tabReplace||f.useBR?e.replace(c,e=>"\n"===e?f.useBR?"
":e:f.tabReplace?e.replace(/\t/g,f.tabReplace):e):e}function E(e){let n=null;const t=function(e){var n=e.className+" ";n+=e.parentNode?e.parentNode.className:"";const t=f.languageDetectRe.exec(n);if(t){var r=T(t[1]);return r||(console.warn(g.replace("{}",t[1])),console.warn("Falling back to no-highlight mode for this block.",e)),r?t[1]:"no-highlight"}return n.split(/\s+/).find(e=>p(e)||T(e))}(e);if(p(t))return;S("before:highlightBlock",{block:e,language:t}),f.useBR?(n=document.createElement("div")).innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n"):n=e;const r=n.textContent,a=t?b(t,r,!0):v(r),i=k(n);if(i.length){const e=document.createElement("div");e.innerHTML=a.value,a.value=O(i,k(e),r)}a.value=x(a.value),S("after:highlightBlock",{block:e,result:a}),e.innerHTML=a.value,e.className=function(e,n,t){var r=n?s[n]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),e.includes(r)||a.push(r),a.join(" ").trim()}(e.className,t,a.language),e.result={language:a.language,re:a.relevance,relavance:a.relevance},a.second_best&&(e.second_best={language:a.second_best.language,re:a.second_best.relevance,relavance:a.second_best.relevance})}const N=()=>{if(!N.called){N.called=!0;var e=document.querySelectorAll("pre code");a.forEach.call(e,E)}};function T(e){return e=(e||"").toLowerCase(),i[e]||i[s[e]]}function A(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach(e=>{s[e]=n})}function I(e){var n=T(e);return n&&!n.disableAutodetect}function S(e,n){var t=e;o.forEach((function(e){e[t]&&e[t](n)}))}Object.assign(t,{highlight:b,highlightAuto:v,fixMarkup:x,highlightBlock:E,configure:function(e){f=y(f,e)},initHighlighting:N,initHighlightingOnLoad:function(){window.addEventListener("DOMContentLoaded",N,!1)},registerLanguage:function(e,n){var r=null;try{r=n(t)}catch(n){if(console.error("Language definition for '{}' could not be registered.".replace("{}",e)),!l)throw n;console.error(n),r=h}r.name||(r.name=e),i[e]=r,r.rawDefinition=n.bind(null,t),r.aliases&&A(r.aliases,{languageName:e})},listLanguages:function(){return Object.keys(i)},getLanguage:T,registerAliases:A,requireLanguage:function(e){var n=T(e);if(n)return n;throw Error("The '{}' language is required, but not loaded.".replace("{}",e))},autoDetection:I,inherit:y,addPlugin:function(e){o.push(e)}}),t.debugMode=function(){l=!1},t.safeMode=function(){l=!0},t.versionString="10.1.1";for(const n in _)"object"==typeof _[n]&&e(_[n]);return Object.assign(t,_),t}({})}();"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); +hljs.registerLanguage("apache",function(){"use strict";return function(e){var n={className:"number",begin:"\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?"};return{name:"Apache config",aliases:["apacheconf"],case_insensitive:!0,contains:[e.HASH_COMMENT_MODE,{className:"section",begin:"",contains:[n,{className:"number",begin:":\\d{1,5}"},e.inherit(e.QUOTE_STRING_MODE,{relevance:0})]},{className:"attribute",begin:/\w+/,relevance:0,keywords:{nomarkup:"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername"},starts:{end:/$/,relevance:0,keywords:{literal:"on off all deny allow"},contains:[{className:"meta",begin:"\\s\\[",end:"\\]$"},{className:"variable",begin:"[\\$%]\\{",end:"\\}",contains:["self",{className:"number",begin:"[\\$%]\\d+"}]},n,{className:"number",begin:"\\d+"},e.QUOTE_STRING_MODE]}}],illegal:/\S/}}}()); +hljs.registerLanguage("bash",function(){"use strict";return function(e){const s={};Object.assign(s,{className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{/,end:/\}/,contains:[{begin:/:-/,contains:[s]}]}]});const t={className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},n={className:"string",begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,s,t]};t.contains.push(n);const a={begin:/\$\(\(/,end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,s]},i=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10}),c={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b-?[a-z\._]+\b/,keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[i,e.SHEBANG(),c,a,e.HASH_COMMENT_MODE,n,{className:"",begin:/\\"/},{className:"string",begin:/'/,end:/'/},s]}}}()); +hljs.registerLanguage("c-like",function(){"use strict";return function(e){function t(e){return"(?:"+e+")?"}var n="(decltype\\(auto\\)|"+t("[a-zA-Z_]\\w*::")+"[a-zA-Z_]\\w*"+t("<.*?>")+")",r={className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},a={className:"string",variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",end:"'",illegal:"."},e.END_SAME_AS_BEGIN({begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},i={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},s={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"},contains:[{begin:/\\\n/,relevance:0},e.inherit(a,{className:"meta-string"}),{className:"meta-string",begin:/<.*?>/,end:/$/,illegal:"\\n"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},o={className:"title",begin:t("[a-zA-Z_]\\w*::")+e.IDENT_RE,relevance:0},c=t("[a-zA-Z_]\\w*::")+e.IDENT_RE+"\\s*\\(",l={keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary",literal:"true false nullptr NULL"},d=[r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,i,a],_={variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}],keywords:l,contains:d.concat([{begin:/\(/,end:/\)/,keywords:l,contains:d.concat(["self"]),relevance:0}]),relevance:0},u={className:"function",begin:"("+n+"[\\*&\\s]+)+"+c,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:l,illegal:/[^\w\s\*&:<>]/,contains:[{begin:"decltype\\(auto\\)",keywords:l,relevance:0},{begin:c,returnBegin:!0,contains:[o],relevance:0},{className:"params",begin:/\(/,end:/\)/,keywords:l,relevance:0,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,i,r,{begin:/\(/,end:/\)/,keywords:l,relevance:0,contains:["self",e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,i,r]}]},r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s]};return{aliases:["c","cc","h","c++","h++","hpp","hh","hxx","cxx"],keywords:l,disableAutodetect:!0,illegal:"",keywords:l,contains:["self",r]},{begin:e.IDENT_RE+"::",keywords:l},{className:"class",beginKeywords:"class struct",end:/[{;:]/,contains:[{begin://,contains:["self"]},e.TITLE_MODE]}]),exports:{preprocessor:s,strings:a,keywords:l}}}}()); +hljs.registerLanguage("c",function(){"use strict";return function(e){var n=e.getLanguage("c-like").rawDefinition();return n.name="C",n.aliases=["c","h"],n}}()); +hljs.registerLanguage("coffeescript",function(){"use strict";const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);return function(r){var t={keyword:e.concat(["then","unless","until","loop","by","when","and","or","is","isnt","not"]).filter((e=>n=>!e.includes(n))(["var","const","let","function","static"])).join(" "),literal:n.concat(["yes","no","on","off"]).join(" "),built_in:a.concat(["npm","print"]).join(" ")},i="[A-Za-z$_][0-9A-Za-z$_]*",s={className:"subst",begin:/#\{/,end:/}/,keywords:t},o=[r.BINARY_NUMBER_MODE,r.inherit(r.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",relevance:0}}),{className:"string",variants:[{begin:/'''/,end:/'''/,contains:[r.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[r.BACKSLASH_ESCAPE]},{begin:/"""/,end:/"""/,contains:[r.BACKSLASH_ESCAPE,s]},{begin:/"/,end:/"/,contains:[r.BACKSLASH_ESCAPE,s]}]},{className:"regexp",variants:[{begin:"///",end:"///",contains:[s,r.HASH_COMMENT_MODE]},{begin:"//[gim]{0,3}(?=\\W)",relevance:0},{begin:/\/(?![ *]).*?(?![\\]).\/[gim]{0,3}(?=\W)/}]},{begin:"@"+i},{subLanguage:"javascript",excludeBegin:!0,excludeEnd:!0,variants:[{begin:"```",end:"```"},{begin:"`",end:"`"}]}];s.contains=o;var c=r.inherit(r.TITLE_MODE,{begin:i}),l={className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,end:/\)/,keywords:t,contains:["self"].concat(o)}]};return{name:"CoffeeScript",aliases:["coffee","cson","iced"],keywords:t,illegal:/\/\*/,contains:o.concat([r.COMMENT("###","###"),r.HASH_COMMENT_MODE,{className:"function",begin:"^\\s*"+i+"\\s*=\\s*(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[c,l]},{begin:/[:\(,=]\s*/,relevance:0,contains:[{className:"function",begin:"(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[l]}]},{className:"class",beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[c]},c]},{begin:i+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0}])}}}()); +hljs.registerLanguage("cpp",function(){"use strict";return function(e){var t=e.getLanguage("c-like").rawDefinition();return t.disableAutodetect=!1,t.name="C++",t.aliases=["cc","c++","h++","hpp","hh","hxx","cxx"],t}}()); +hljs.registerLanguage("csharp",function(){"use strict";return function(e){var n={keyword:"abstract as base bool break byte case catch char checked const continue decimal default delegate do double enum event explicit extern finally fixed float for foreach goto if implicit in int interface internal is lock long object operator out override params private protected public readonly ref sbyte sealed short sizeof stackalloc static string struct switch this try typeof uint ulong unchecked unsafe ushort using virtual void volatile while add alias ascending async await by descending dynamic equals from get global group into join let nameof on orderby partial remove select set value var when where yield",literal:"null false true"},i=e.inherit(e.TITLE_MODE,{begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},s={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},t=e.inherit(s,{illegal:/\n/}),l={className:"subst",begin:"{",end:"}",keywords:n},r=e.inherit(l,{illegal:/\n/}),c={className:"string",begin:/\$"/,end:'"',illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},e.BACKSLASH_ESCAPE,r]},o={className:"string",begin:/\$@"/,end:'"',contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},l]},g=e.inherit(o,{illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},r]});l.contains=[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE],r.contains=[g,c,t,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{illegal:/\n/})];var d={variants:[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},E={begin:"<",end:">",contains:[{beginKeywords:"in out"},i]},_=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",b={begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"],keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0,contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{begin:"\x3c!--|--\x3e"},{begin:""}]}]}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"}},d,a,{beginKeywords:"class interface",end:/[{;=]/,illegal:/[^\s:,]/,contains:[{beginKeywords:"where class"},i,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",end:/[{;=]/,illegal:/[^\s:]/,contains:[i,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta",begin:"^\\s*\\[",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{className:"meta-string",begin:/"/,end:/"/}]},{beginKeywords:"new return throw await else",relevance:0},{className:"function",begin:"("+_+"\\s+)+"+e.IDENT_RE+"\\s*(\\<.+\\>)?\\s*\\(",returnBegin:!0,end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{begin:e.IDENT_RE+"\\s*(\\<.+\\>)?\\s*\\(",returnBegin:!0,contains:[e.TITLE_MODE,E],relevance:0},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0,contains:[d,a,e.C_BLOCK_COMMENT_MODE]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},b]}}}()); +hljs.registerLanguage("css",function(){"use strict";return function(e){var n={begin:/(?:[A-Z\_\.\-]+|--[a-zA-Z0-9_-]+)\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",begin:/\S/,end:":",excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.CSS_NUMBER_MODE]}]},e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]};return{name:"CSS",case_insensitive:!0,illegal:/[=\/|'\$]/,contains:[e.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"@(page|font-face)",lexemes:"@[a-z-]+",keywords:"@page @font-face"},{begin:"@",end:"[{;]",illegal:/:/,returnBegin:!0,contains:[{className:"keyword",begin:/@\-?\w[\w]*(\-\w+)*/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:"and or not only",contains:[{begin:/[a-z-]+:/,className:"attribute"},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[e.C_BLOCK_COMMENT_MODE,n]}]}}}()); +hljs.registerLanguage("diff",function(){"use strict";return function(e){return{name:"Diff",aliases:["patch"],contains:[{className:"meta",relevance:10,variants:[{begin:/^@@ +\-\d+,\d+ +\+\d+,\d+ +@@$/},{begin:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{begin:/^\-\-\- +\d+,\d+ +\-\-\-\-$/}]},{className:"comment",variants:[{begin:/Index: /,end:/$/},{begin:/={3,}/,end:/$/},{begin:/^\-{3}/,end:/$/},{begin:/^\*{3} /,end:/$/},{begin:/^\+{3}/,end:/$/},{begin:/^\*{15}$/}]},{className:"addition",begin:"^\\+",end:"$"},{className:"deletion",begin:"^\\-",end:"$"},{className:"addition",begin:"^\\!",end:"$"}]}}}()); +hljs.registerLanguage("go",function(){"use strict";return function(e){var n={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",literal:"true false iota nil",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};return{name:"Go",aliases:["golang"],keywords:n,illegal:"e(n)).join("")}return function(a){var s={className:"number",relevance:0,variants:[{begin:/([\+\-]+)?[\d]+_[\d_]+/},{begin:a.NUMBER_RE}]},i=a.COMMENT();i.variants=[{begin:/;/,end:/$/},{begin:/#/,end:/$/}];var t={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{begin:/\$\{(.*?)}/}]},r={className:"literal",begin:/\bon|off|true|false|yes|no\b/},l={className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}]},c={begin:/\[/,end:/\]/,contains:[i,r,t,l,s,"self"],relevance:0},g="("+[/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/].map(n=>e(n)).join("|")+")";return{name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/,contains:[i,{className:"section",begin:/\[+/,end:/\]+/},{begin:n(g,"(\\s*\\.\\s*",g,")*",n("(?=",/\s*=\s*[^#\s]/,")")),className:"attr",starts:{end:/$/,contains:[i,c,r,t,l,s]}}]}}}()); +hljs.registerLanguage("java",function(){"use strict";function e(e){return e?"string"==typeof e?e:e.source:null}function n(e){return a("(",e,")?")}function a(...n){return n.map(n=>e(n)).join("")}function s(...n){return"("+n.map(n=>e(n)).join("|")+")"}return function(e){var t="false synchronized int abstract float private char boolean var static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",i={className:"meta",begin:"@[À-ʸa-zA-Z_$][À-ʸa-zA-Z_$0-9]*",contains:[{begin:/\(/,end:/\)/,contains:["self"]}]},r=e=>a("[",e,"]+([",e,"_]*[",e,"]+)?"),c={className:"number",variants:[{begin:`\\b(0[bB]${r("01")})[lL]?`},{begin:`\\b(0${r("0-7")})[dDfFlL]?`},{begin:a(/\b0[xX]/,s(a(r("a-fA-F0-9"),/\./,r("a-fA-F0-9")),a(r("a-fA-F0-9"),/\.?/),a(/\./,r("a-fA-F0-9"))),/([pP][+-]?(\d+))?/,/[fFdDlL]?/)},{begin:a(/\b/,s(a(/\d*\./,r("\\d")),r("\\d")),/[eE][+-]?[\d]+[dDfF]?/)},{begin:a(/\b/,r(/\d/),n(/\.?/),n(r(/\d/)),/[dDfFlL]?/)}],relevance:0};return{name:"Java",aliases:["jsp"],keywords:t,illegal:/<\/|#/,contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"class",beginKeywords:"class interface",end:/[{;=]/,excludeEnd:!0,keywords:"class interface",illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"new throw return else",relevance:0},{className:"function",begin:"([À-ʸa-zA-Z_$][À-ʸa-zA-Z_$0-9]*(<[À-ʸa-zA-Z_$][À-ʸa-zA-Z_$0-9]*(\\s*,\\s*[À-ʸa-zA-Z_$][À-ʸa-zA-Z_$0-9]*)*>)?\\s+)+"+e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:t,contains:[{begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[e.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,keywords:t,relevance:0,contains:[i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},c,i]}}}()); +hljs.registerLanguage("javascript",function(){"use strict";const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);function s(e){return r("(?=",e,")")}function r(...e){return e.map(e=>(function(e){return e?"string"==typeof e?e:e.source:null})(e)).join("")}return function(t){var i="[A-Za-z$_][0-9A-Za-z$_]*",c={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/},o={$pattern:"[A-Za-z$_][0-9A-Za-z$_]*",keyword:e.join(" "),literal:n.join(" "),built_in:a.join(" ")},l={className:"number",variants:[{begin:"\\b(0[bB][01]+)n?"},{begin:"\\b(0[oO][0-7]+)n?"},{begin:t.C_NUMBER_RE+"n?"}],relevance:0},E={className:"subst",begin:"\\$\\{",end:"\\}",keywords:o,contains:[]},d={begin:"html`",end:"",starts:{end:"`",returnEnd:!1,contains:[t.BACKSLASH_ESCAPE,E],subLanguage:"xml"}},g={begin:"css`",end:"",starts:{end:"`",returnEnd:!1,contains:[t.BACKSLASH_ESCAPE,E],subLanguage:"css"}},u={className:"string",begin:"`",end:"`",contains:[t.BACKSLASH_ESCAPE,E]};E.contains=[t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,d,g,u,l,t.REGEXP_MODE];var b=E.contains.concat([{begin:/\(/,end:/\)/,contains:["self"].concat(E.contains,[t.C_BLOCK_COMMENT_MODE,t.C_LINE_COMMENT_MODE])},t.C_BLOCK_COMMENT_MODE,t.C_LINE_COMMENT_MODE]),_={className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:b};return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:o,contains:[t.SHEBANG({binary:"node",relevance:5}),{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,d,g,u,t.C_LINE_COMMENT_MODE,t.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{",end:"\\}",relevance:0},{className:"variable",begin:i+"(?=\\s*(-)|$)",endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]}),t.C_BLOCK_COMMENT_MODE,l,{begin:r(/[{,\n]\s*/,s(r(/(((\/\/.*)|(\/\*(.|\n)*\*\/))\s*)*/,i+"\\s*:"))),relevance:0,contains:[{className:"attr",begin:i+s("\\s*:"),relevance:0}]},{begin:"("+t.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE,t.REGEXP_MODE,{className:"function",begin:"(\\([^(]*(\\([^(]*(\\([^(]*\\))?\\))?\\)|"+t.UNDERSCORE_IDENT_RE+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:t.UNDERSCORE_IDENT_RE},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:o,contains:b}]}]},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{variants:[{begin:"<>",end:""},{begin:c.begin,end:c.end}],subLanguage:"xml",contains:[{begin:c.begin,end:c.end,skip:!0,contains:["self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[t.inherit(t.TITLE_MODE,{begin:i}),_],illegal:/\[|%/},{begin:/\$[(.]/},t.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},t.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0},{begin:"(get|set)\\s+(?="+i+"\\()",end:/{/,keywords:"get set",contains:[t.inherit(t.TITLE_MODE,{begin:i}),{begin:/\(\)/},_]}],illegal:/#(?!!)/}}}()); +hljs.registerLanguage("json",function(){"use strict";return function(n){var e={literal:"true false null"},i=[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE],t=[n.QUOTE_STRING_MODE,n.C_NUMBER_MODE],a={end:",",endsWithParent:!0,excludeEnd:!0,contains:t,keywords:e},l={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,end:/"/,contains:[n.BACKSLASH_ESCAPE],illegal:"\\n"},n.inherit(a,{begin:/:/})].concat(i),illegal:"\\S"},s={begin:"\\[",end:"\\]",contains:[n.inherit(a)],illegal:"\\S"};return t.push(l,s),i.forEach((function(n){t.push(n)})),{name:"JSON",contains:t,keywords:e,illegal:"\\S"}}}()); +hljs.registerLanguage("kotlin",function(){"use strict";return function(e){var n={keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual trait volatile transient native default",built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing",literal:"true false null"},a={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@"},i={className:"subst",begin:"\\${",end:"}",contains:[e.C_NUMBER_MODE]},s={className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},t={className:"string",variants:[{begin:'"""',end:'"""(?=[^"])',contains:[s,i]},{begin:"'",end:"'",illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/,contains:[e.BACKSLASH_ESCAPE,s,i]}]};i.contains.push(t);var r={className:"meta",begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?"},l={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/,end:/\)/,contains:[e.inherit(t,{className:"meta-string"})]}]},c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),o={variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/,contains:[]}]},d=o;return d.variants[1].contains=[o],o.variants[1].contains=[d],{name:"Kotlin",aliases:["kt"],keywords:n,contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword",begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol",begin:/@\w+/}]}},a,r,l,{className:"function",beginKeywords:"fun",end:"[(]|$",returnBegin:!0,excludeEnd:!0,keywords:n,illegal:/fun\s+(<.*>)?[^\s\(]+(\s+[^\s\(]+)\s*=/,relevance:5,contains:[{begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://,keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/,endsWithParent:!0,contains:[o,e.C_LINE_COMMENT_MODE,c],relevance:0},e.C_LINE_COMMENT_MODE,c,r,l,t,e.C_NUMBER_MODE]},c]},{className:"class",beginKeywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0,illegal:"extends implements",contains:[{beginKeywords:"public protected internal private constructor"},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,]|$/,excludeBegin:!0,returnEnd:!0},r,l]},t,{className:"meta",begin:"^#!/usr/bin/env",end:"$",illegal:"\n"},{className:"number",begin:"\\b(0[bB]([01]+[01_]+[01]+|[01]+)|0[xX]([a-fA-F0-9]+[a-fA-F0-9_]+[a-fA-F0-9]+|[a-fA-F0-9]+)|(([\\d]+[\\d_]+[\\d]+|[\\d]+)(\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))?|\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))([eE][-+]?\\d+)?)[lLfF]?",relevance:0}]}}}()); +hljs.registerLanguage("less",function(){"use strict";return function(e){var n="([\\w-]+|@{[\\w-]+})",a=[],s=[],t=function(e){return{className:"string",begin:"~?"+e+".*?"+e}},r=function(e,n,a){return{className:e,begin:n,relevance:a}},i={begin:"\\(",end:"\\)",contains:s,relevance:0};s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,t("'"),t('"'),e.CSS_NUMBER_MODE,{begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]",excludeEnd:!0}},r("number","#[0-9A-Fa-f]+\\b"),i,r("variable","@@?[\\w-]+",10),r("variable","@{[\\w-]+}"),r("built_in","~?`[^`]*?`"),{className:"attribute",begin:"[\\w-]+\\s*:",end:":",returnBegin:!0,excludeEnd:!0},{className:"meta",begin:"!important"});var c=s.concat({begin:"{",end:"}",contains:a}),l={beginKeywords:"when",endsWithParent:!0,contains:[{beginKeywords:"and not"}].concat(s)},o={begin:n+"\\s*:",returnBegin:!0,end:"[;}]",relevance:0,contains:[{className:"attribute",begin:n,end:":",excludeEnd:!0,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}]},g={className:"keyword",begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b",starts:{end:"[;{}]",returnEnd:!0,contains:s,relevance:0}},d={className:"variable",variants:[{begin:"@[\\w-]+\\s*:",relevance:15},{begin:"@[\\w-]+"}],starts:{end:"[;}]",returnEnd:!0,contains:c}},b={variants:[{begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:n,end:"{"}],returnBegin:!0,returnEnd:!0,illegal:"[<='$\"]",relevance:0,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,l,r("keyword","all\\b"),r("variable","@{[\\w-]+}"),r("selector-tag",n+"%?",0),r("selector-id","#"+n),r("selector-class","\\."+n,0),r("selector-tag","&",0),{className:"selector-attr",begin:"\\[",end:"\\]"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"\\(",end:"\\)",contains:c},{begin:"!important"}]};return a.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,g,d,o,b),{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:a}}}()); +hljs.registerLanguage("lua",function(){"use strict";return function(e){var t={begin:"\\[=*\\[",end:"\\]=*\\]",contains:["self"]},a=[e.COMMENT("--(?!\\[=*\\[)","$"),e.COMMENT("--\\[=*\\[","\\]=*\\]",{contains:[t],relevance:10})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE,literal:"true false nil",keyword:"and break do else elseif end for goto if in local not or repeat return then until while",built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove"},contains:a.concat([{className:"function",beginKeywords:"function",end:"\\)",contains:[e.inherit(e.TITLE_MODE,{begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",begin:"\\(",endsWithParent:!0,contains:a}].concat(a)},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string",begin:"\\[=*\\[",end:"\\]=*\\]",contains:[t],relevance:5}])}}}()); +hljs.registerLanguage("makefile",function(){"use strict";return function(e){var i={className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)",contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,contains:[{className:"meta",begin:"",relevance:10,contains:[a,i,t,s,{begin:"\\[",end:"\\]",contains:[{className:"meta",begin:"",contains:[a,s,i,t]}]}]},e.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},n,{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:")",end:">",keywords:{name:"style"},contains:[c],starts:{end:"",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:")",end:">",keywords:{name:"script"},contains:[c],starts:{end:"<\/script>",returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:"",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},c]}]}}}()); +hljs.registerLanguage("markdown",function(){"use strict";return function(n){const e={begin:"<",end:">",subLanguage:"xml",relevance:0},a={begin:"\\[.+?\\][\\(\\[].*?[\\)\\]]",returnBegin:!0,contains:[{className:"string",begin:"\\[",end:"\\]",excludeBegin:!0,returnEnd:!0,relevance:0},{className:"link",begin:"\\]\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"symbol",begin:"\\]\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0}],relevance:10},i={className:"strong",contains:[],variants:[{begin:/_{2}/,end:/_{2}/},{begin:/\*{2}/,end:/\*{2}/}]},s={className:"emphasis",contains:[],variants:[{begin:/\*(?!\*)/,end:/\*/},{begin:/_(?!_)/,end:/_/,relevance:0}]};i.contains.push(s),s.contains.push(i);var c=[e,a];return i.contains=i.contains.concat(c),s.contains=s.contains.concat(c),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:c=c.concat(i,s)},{begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n",contains:c}]}]},e,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)",end:"\\s+",excludeEnd:!0},i,s,{className:"quote",begin:"^>\\s+",contains:c,end:"$"},{className:"code",variants:[{begin:"(`{3,})(.|\\n)*?\\1`*[ ]*"},{begin:"(~{3,})(.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))",contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{begin:"^[-\\*]{3,}",end:"$"},a,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}}}()); +hljs.registerLanguage("nginx",function(){"use strict";return function(e){var n={className:"variable",variants:[{begin:/\$\d+/},{begin:/\$\{/,end:/}/},{begin:"[\\$\\@]"+e.UNDERSCORE_IDENT_RE}]},a={endsWithParent:!0,keywords:{$pattern:"[a-z/_]+",literal:"on off yes no true false none blocked debug info notice warn error crit select break last permanent redirect kqueue rtsig epoll poll /dev/poll"},relevance:0,illegal:"=>",contains:[e.HASH_COMMENT_MODE,{className:"string",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/}]},{begin:"([a-z]+):/",end:"\\s",endsWithParent:!0,excludeEnd:!0,contains:[n]},{className:"regexp",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:"\\s\\^",end:"\\s|{|;",returnEnd:!0},{begin:"~\\*?\\s+",end:"\\s|{|;",returnEnd:!0},{begin:"\\*(\\.[a-z\\-]+)+"},{begin:"([a-z\\-]+\\.)+\\*"}]},{className:"number",begin:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{className:"number",begin:"\\b\\d+[kKmMgGdshdwy]*\\b",relevance:0},n]};return{name:"Nginx config",aliases:["nginxconf"],contains:[e.HASH_COMMENT_MODE,{begin:e.UNDERSCORE_IDENT_RE+"\\s+{",returnBegin:!0,end:"{",contains:[{className:"section",begin:e.UNDERSCORE_IDENT_RE}],relevance:0},{begin:e.UNDERSCORE_IDENT_RE+"\\s",end:";|{",returnBegin:!0,contains:[{className:"attribute",begin:e.UNDERSCORE_IDENT_RE,starts:a}],relevance:0}],illegal:"[^\\s\\}]"}}}()); +hljs.registerLanguage("objectivec",function(){"use strict";return function(e){var n=/[a-zA-Z@][a-zA-Z0-9_]*/,_={$pattern:n,keyword:"@interface @class @protocol @implementation"};return{name:"Objective-C",aliases:["mm","objc","obj-c"],keywords:{$pattern:n,keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",literal:"false true FALSE TRUE nil YES NO NULL",built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"},illegal:"/,end:/$/,illegal:"\\n"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class",begin:"("+_.keyword.split(" ").join("|")+")\\b",end:"({|$)",excludeEnd:!0,keywords:_,contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE,relevance:0}]}}}()); +hljs.registerLanguage("perl",function(){"use strict";return function(e){var n={$pattern:/[\w.]+/,keyword:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qq fileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmget sub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedir ioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when"},t={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:n},s={begin:"->{",end:"}"},r={variants:[{begin:/\$\d/},{begin:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{begin:/[\$%@][^\s\w{]/,relevance:0}]},i=[e.BACKSLASH_ESCAPE,t,r],a=[r,e.HASH_COMMENT_MODE,e.COMMENT("^\\=\\w","\\=cut",{endsWithParent:!0}),s,{className:"string",contains:i,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*\\<",end:"\\>",relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE]},{begin:"{\\w+}",contains:[],relevance:0},{begin:"-?\\w+\\s*\\=\\>",contains:[],relevance:0}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*",keywords:"split return print reverse grep",relevance:0,contains:[e.HASH_COMMENT_MODE,{className:"regexp",begin:"(s|tr|y)/(\\\\.|[^/])*/(\\\\.|[^/])*/[a-z]*",relevance:10},{className:"regexp",begin:"(m|qr)?/",end:"/[a-z]*",contains:[e.BACKSLASH_ESCAPE],relevance:0}]},{className:"function",beginKeywords:"sub",end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}]}];return t.contains=a,s.contains=a,{name:"Perl",aliases:["pl","pm"],keywords:n,contains:a}}}()); +hljs.registerLanguage("php",function(){"use strict";return function(e){var r={begin:"\\$+[a-zA-Z_-ÿ][a-zA-Z0-9_-ÿ]*"},t={className:"meta",variants:[{begin:/<\?php/,relevance:10},{begin:/<\?[=]?/},{begin:/\?>/}]},a={className:"string",contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:'b"',end:'"'},{begin:"b'",end:"'"},e.inherit(e.APOS_STRING_MODE,{illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null})]},n={variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]},i={keyword:"__CLASS__ __DIR__ __FILE__ __FUNCTION__ __LINE__ __METHOD__ __NAMESPACE__ __TRAIT__ die echo exit include include_once print require require_once array abstract and as binary bool boolean break callable case catch class clone const continue declare default do double else elseif empty enddeclare endfor endforeach endif endswitch endwhile eval extends final finally float for foreach from global goto if implements instanceof insteadof int integer interface isset iterable list new object or private protected public real return string switch throw trait try unset use var void while xor yield",literal:"false null true",built_in:"Error|0 AppendIterator ArgumentCountError ArithmeticError ArrayIterator ArrayObject AssertionError BadFunctionCallException BadMethodCallException CachingIterator CallbackFilterIterator CompileError Countable DirectoryIterator DivisionByZeroError DomainException EmptyIterator ErrorException Exception FilesystemIterator FilterIterator GlobIterator InfiniteIterator InvalidArgumentException IteratorIterator LengthException LimitIterator LogicException MultipleIterator NoRewindIterator OutOfBoundsException OutOfRangeException OuterIterator OverflowException ParentIterator ParseError RangeException RecursiveArrayIterator RecursiveCachingIterator RecursiveCallbackFilterIterator RecursiveDirectoryIterator RecursiveFilterIterator RecursiveIterator RecursiveIteratorIterator RecursiveRegexIterator RecursiveTreeIterator RegexIterator RuntimeException SeekableIterator SplDoublyLinkedList SplFileInfo SplFileObject SplFixedArray SplHeap SplMaxHeap SplMinHeap SplObjectStorage SplObserver SplObserver SplPriorityQueue SplQueue SplStack SplSubject SplSubject SplTempFileObject TypeError UnderflowException UnexpectedValueException ArrayAccess Closure Generator Iterator IteratorAggregate Serializable Throwable Traversable WeakReference Directory __PHP_Incomplete_Class parent php_user_filter self static stdClass"};return{aliases:["php","php3","php4","php5","php6","php7"],case_insensitive:!0,keywords:i,contains:[e.HASH_COMMENT_MODE,e.COMMENT("//","$",{contains:[t]}),e.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),e.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,keywords:"__halt_compiler"}),{className:"string",begin:/<<<['"]?\w+['"]?$/,end:/^\w+;?$/,contains:[e.BACKSLASH_ESCAPE,{className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,end:/\}/}]}]},t,{className:"keyword",begin:/\$this\b/},r,{begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function",beginKeywords:"fn function",end:/[;{]/,excludeEnd:!0,illegal:"[$%\\[]",contains:[e.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:i,contains:["self",r,e.C_BLOCK_COMMENT_MODE,a,n]}]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:/[:\(\$"]/,contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"namespace",end:";",illegal:/[\.']/,contains:[e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",end:";",contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"=>"},a,n]}}}()); +hljs.registerLanguage("php-template",function(){"use strict";return function(n){return{name:"PHP template",subLanguage:"xml",contains:[{begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0},n.inherit(n.APOS_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0}),n.inherit(n.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0})]}]}}}()); +hljs.registerLanguage("plaintext",function(){"use strict";return function(t){return{name:"Plain text",aliases:["text","txt"],disableAutodetect:!0}}}()); +hljs.registerLanguage("properties",function(){"use strict";return function(e){var n="[ \\t\\f]*",t="("+n+"[:=]"+n+"|[ \\t\\f]+)",a="([^\\\\:= \\t\\f\\n]|\\\\.)+",s={end:t,relevance:0,starts:{className:"string",end:/$/,relevance:0,contains:[{begin:"\\\\\\n"}]}};return{name:".properties",case_insensitive:!0,illegal:/\S/,contains:[e.COMMENT("^\\s*[!#]","$"),{begin:"([^\\\\\\W:= \\t\\f\\n]|\\\\.)+"+t,returnBegin:!0,contains:[{className:"attr",begin:"([^\\\\\\W:= \\t\\f\\n]|\\\\.)+",endsParent:!0,relevance:0}],starts:s},{begin:a+t,returnBegin:!0,relevance:0,contains:[{className:"meta",begin:a,endsParent:!0,relevance:0}],starts:s},{className:"attr",relevance:0,begin:a+n+"$"}]}}}()); +hljs.registerLanguage("python",function(){"use strict";return function(e){var n={keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10",built_in:"Ellipsis NotImplemented",literal:"False None True"},a={className:"meta",begin:/^(>>>|\.\.\.) /},i={className:"subst",begin:/\{/,end:/\}/,keywords:n,illegal:/#/},s={begin:/\{\{/,relevance:0},r={className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{begin:/(fr|rf|f)'''/,end:/'''/,contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/(fr|rf|f)"""/,end:/"""/,contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{begin:/(b|br)"/,end:/"/},{begin:/(fr|rf|f)'/,end:/'/,contains:[e.BACKSLASH_ESCAPE,s,i]},{begin:/(fr|rf|f)"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,s,i]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},l={className:"number",relevance:0,variants:[{begin:e.BINARY_NUMBER_RE+"[lLjJ]?"},{begin:"\\b(0o[0-7]+)[lLjJ]?"},{begin:e.C_NUMBER_RE+"[lLjJ]?"}]},t={className:"params",variants:[{begin:/\(\s*\)/,skip:!0,className:null},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:["self",a,l,r,e.HASH_COMMENT_MODE]}]};return i.contains=[r,l,a],{name:"Python",aliases:["py","gyp","ipython"],keywords:n,illegal:/(<\/|->|\?)|=>/,contains:[a,l,{beginKeywords:"if",relevance:0},r,e.HASH_COMMENT_MODE,{variants:[{className:"function",beginKeywords:"def"},{className:"class",beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,contains:[e.UNDERSCORE_TITLE_MODE,t,{begin:/->/,endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,end:/$/},{begin:/\b(print|exec)\(/}]}}}()); +hljs.registerLanguage("python-repl",function(){"use strict";return function(n){return{aliases:["pycon"],contains:[{className:"meta",starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}}}()); +hljs.registerLanguage("ruby",function(){"use strict";return function(e){var n="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?",a={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",literal:"true false nil"},s={className:"doctag",begin:"@[A-Za-z]+"},i={begin:"#<",end:">"},r=[e.COMMENT("#","$",{contains:[s]}),e.COMMENT("^\\=begin","^\\=end",{contains:[s],relevance:10}),e.COMMENT("^__END__","\\n$")],c={className:"subst",begin:"#\\{",end:"}",keywords:a},t={className:"string",contains:[e.BACKSLASH_ESCAPE,c],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[qQwWx]?\\(",end:"\\)"},{begin:"%[qQwWx]?\\[",end:"\\]"},{begin:"%[qQwWx]?{",end:"}"},{begin:"%[qQwWx]?<",end:">"},{begin:"%[qQwWx]?/",end:"/"},{begin:"%[qQwWx]?%",end:"%"},{begin:"%[qQwWx]?-",end:"-"},{begin:"%[qQwWx]?\\|",end:"\\|"},{begin:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/},{begin:/<<[-~]?'?(\w+)(?:.|\n)*?\n\s*\1\b/,returnBegin:!0,contains:[{begin:/<<[-~]?'?/},e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,contains:[e.BACKSLASH_ESCAPE,c]})]}]},b={className:"params",begin:"\\(",end:"\\)",endsParent:!0,keywords:a},d=[t,i,{className:"class",beginKeywords:"class module",end:"$|;",illegal:/=/,contains:[e.inherit(e.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<\\s*",contains:[{begin:"("+e.IDENT_RE+"::)?"+e.IDENT_RE}]}].concat(r)},{className:"function",beginKeywords:"def",end:"$|;",contains:[e.inherit(e.TITLE_MODE,{begin:n}),b].concat(r)},{begin:e.IDENT_RE+"::"},{className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[t,{begin:n}],relevance:0},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{className:"params",begin:/\|/,end:/\|/,keywords:a},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[i,{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r{",end:"}[a-z]*"},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(r),relevance:0}].concat(r);c.contains=d,b.contains=d;var g=[{begin:/^\s*=>/,starts:{end:"$",contains:d}},{className:"meta",begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>)",starts:{end:"$",contains:d}}];return{name:"Ruby",aliases:["rb","gemspec","podspec","thor","irb"],keywords:a,illegal:/\/\*/,contains:r.concat(g).concat(d)}}}()); +hljs.registerLanguage("rust",function(){"use strict";return function(e){var n="([ui](8|16|32|64|128|size)|f(32|64))?",t="drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!";return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",keyword:"abstract as async await become box break const continue crate do dyn else enum extern false final fn for if impl in let loop macro match mod move mut override priv pub ref return self Self static struct super trait true try type typeof unsafe unsized use virtual where while yield",literal:"true false Some None Ok Err",built_in:t},illegal:""}]}}}()); +hljs.registerLanguage("scss",function(){"use strict";return function(e){var t={className:"variable",begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b"},i={className:"number",begin:"#[0-9A-Fa-f]+"};return e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_BLOCK_COMMENT_MODE,{name:"SCSS",case_insensitive:!0,illegal:"[=/|']",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:"\\#[A-Za-z0-9_-]+",relevance:0},{className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0},{className:"selector-attr",begin:"\\[",end:"\\]",illegal:"$"},{className:"selector-tag",begin:"\\b(a|abbr|acronym|address|area|article|aside|audio|b|base|big|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|command|datalist|dd|del|details|dfn|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|frame|frameset|(h[1-6])|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|keygen|label|legend|li|link|map|mark|meta|meter|nav|noframes|noscript|object|ol|optgroup|option|output|p|param|pre|progress|q|rp|rt|ruby|samp|script|section|select|small|span|strike|strong|style|sub|sup|table|tbody|td|textarea|tfoot|th|thead|time|title|tr|tt|ul|var|video)\\b",relevance:0},{className:"selector-pseudo",begin:":(visited|valid|root|right|required|read-write|read-only|out-range|optional|only-of-type|only-child|nth-of-type|nth-last-of-type|nth-last-child|nth-child|not|link|left|last-of-type|last-child|lang|invalid|indeterminate|in-range|hover|focus|first-of-type|first-line|first-letter|first-child|first|enabled|empty|disabled|default|checked|before|after|active)"},{className:"selector-pseudo",begin:"::(after|before|choices|first-letter|first-line|repeat-index|repeat-item|selection|value)"},t,{className:"attribute",begin:"\\b(src|z-index|word-wrap|word-spacing|word-break|width|widows|white-space|visibility|vertical-align|unicode-bidi|transition-timing-function|transition-property|transition-duration|transition-delay|transition|transform-style|transform-origin|transform|top|text-underline-position|text-transform|text-shadow|text-rendering|text-overflow|text-indent|text-decoration-style|text-decoration-line|text-decoration-color|text-decoration|text-align-last|text-align|tab-size|table-layout|right|resize|quotes|position|pointer-events|perspective-origin|perspective|page-break-inside|page-break-before|page-break-after|padding-top|padding-right|padding-left|padding-bottom|padding|overflow-y|overflow-x|overflow-wrap|overflow|outline-width|outline-style|outline-offset|outline-color|outline|orphans|order|opacity|object-position|object-fit|normal|none|nav-up|nav-right|nav-left|nav-index|nav-down|min-width|min-height|max-width|max-height|mask|marks|margin-top|margin-right|margin-left|margin-bottom|margin|list-style-type|list-style-position|list-style-image|list-style|line-height|letter-spacing|left|justify-content|initial|inherit|ime-mode|image-orientation|image-resolution|image-rendering|icon|hyphens|height|font-weight|font-variant-ligatures|font-variant|font-style|font-stretch|font-size-adjust|font-size|font-language-override|font-kerning|font-feature-settings|font-family|font|float|flex-wrap|flex-shrink|flex-grow|flex-flow|flex-direction|flex-basis|flex|filter|empty-cells|display|direction|cursor|counter-reset|counter-increment|content|column-width|column-span|column-rule-width|column-rule-style|column-rule-color|column-rule|column-gap|column-fill|column-count|columns|color|clip-path|clip|clear|caption-side|break-inside|break-before|break-after|box-sizing|box-shadow|box-decoration-break|bottom|border-width|border-top-width|border-top-style|border-top-right-radius|border-top-left-radius|border-top-color|border-top|border-style|border-spacing|border-right-width|border-right-style|border-right-color|border-right|border-radius|border-left-width|border-left-style|border-left-color|border-left|border-image-width|border-image-source|border-image-slice|border-image-repeat|border-image-outset|border-image|border-color|border-collapse|border-bottom-width|border-bottom-style|border-bottom-right-radius|border-bottom-left-radius|border-bottom-color|border-bottom|border|background-size|background-repeat|background-position|background-origin|background-image|background-color|background-clip|background-attachment|background-blend-mode|background|backface-visibility|auto|animation-timing-function|animation-play-state|animation-name|animation-iteration-count|animation-fill-mode|animation-duration|animation-direction|animation-delay|animation|align-self|align-items|align-content)\\b",illegal:"[^\\s]"},{begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b"},{begin:":",end:";",contains:[t,i,e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{className:"meta",begin:"!important"}]},{begin:"@(page|font-face)",lexemes:"@[a-z-]+",keywords:"@page @font-face"},{begin:"@",end:"[{;]",returnBegin:!0,keywords:"and or not only",contains:[{begin:"@[a-z-]+",className:"keyword"},t,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,i,e.CSS_NUMBER_MODE]}]}}}()); +hljs.registerLanguage("shell",function(){"use strict";return function(s){return{name:"Shell Session",aliases:["console"],contains:[{className:"meta",begin:"^\\s{0,3}[/\\w\\d\\[\\]()@-]*[>%$#]",starts:{end:"$",subLanguage:"bash"}}]}}}()); +hljs.registerLanguage("sql",function(){"use strict";return function(e){var t=e.COMMENT("--","$");return{name:"SQL",case_insensitive:!0,illegal:/[<>{}*]/,contains:[{beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment values with",end:/;/,endsWithParent:!0,keywords:{$pattern:/[\w\.]+/,keyword:"as abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias all allocate allow alter always analyze ancillary and anti any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound bucket buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain explode export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force foreign form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour hours http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lateral lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minutes minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notnull notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second seconds section securefile security seed segment select self semi sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tablesample tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unnest unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace window with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",literal:"true false null unknown",built_in:"array bigint binary bit blob bool boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text time timestamp tinyint varchar varchar2 varying void"},contains:[{className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},{className:"string",begin:'"',end:'"',contains:[{begin:'""'}]},{className:"string",begin:"`",end:"`"},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,e.HASH_COMMENT_MODE]},e.C_BLOCK_COMMENT_MODE,t,e.HASH_COMMENT_MODE]}}}()); +hljs.registerLanguage("swift",function(){"use strict";return function(e){var i={keyword:"#available #colorLiteral #column #else #elseif #endif #file #fileLiteral #function #if #imageLiteral #line #selector #sourceLocation _ __COLUMN__ __FILE__ __FUNCTION__ __LINE__ Any as as! as? associatedtype associativity break case catch class continue convenience default defer deinit didSet do dynamic dynamicType else enum extension fallthrough false fileprivate final for func get guard if import in indirect infix init inout internal is lazy left let mutating nil none nonmutating open operator optional override postfix precedence prefix private protocol Protocol public repeat required rethrows return right self Self set static struct subscript super switch throw throws true try try! try? Type typealias unowned var weak where while willSet",literal:"true false nil",built_in:"abs advance alignof alignofValue anyGenerator assert assertionFailure bridgeFromObjectiveC bridgeFromObjectiveCUnconditional bridgeToObjectiveC bridgeToObjectiveCUnconditional c compactMap contains count countElements countLeadingZeros debugPrint debugPrintln distance dropFirst dropLast dump encodeBitsAsWords enumerate equal fatalError filter find getBridgedObjectiveCType getVaList indices insertionSort isBridgedToObjectiveC isBridgedVerbatimToObjectiveC isUniquelyReferenced isUniquelyReferencedNonObjC join lazy lexicographicalCompare map max maxElement min minElement numericCast overlaps partition posix precondition preconditionFailure print println quickSort readLine reduce reflect reinterpretCast reverse roundUpToAlignment sizeof sizeofValue sort split startsWith stride strideof strideofValue swap toString transcode underestimateCount unsafeAddressOf unsafeBitCast unsafeDowncast unsafeUnwrap unsafeReflect withExtendedLifetime withObjectAtPlusZero withUnsafePointer withUnsafePointerToObject withUnsafeMutablePointer withUnsafeMutablePointers withUnsafePointer withUnsafePointers withVaList zip"},n=e.COMMENT("/\\*","\\*/",{contains:["self"]}),t={className:"subst",begin:/\\\(/,end:"\\)",keywords:i,contains:[]},a={className:"string",contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:/"""/,end:/"""/},{begin:/"/,end:/"/}]},r={className:"number",begin:"\\b([\\d_]+(\\.[\\deE_]+)?|0x[a-fA-F0-9_]+(\\.[a-fA-F0-9p_]+)?|0b[01_]+|0o[0-7_]+)\\b",relevance:0};return t.contains=[r],{name:"Swift",keywords:i,contains:[a,e.C_LINE_COMMENT_MODE,n,{className:"type",begin:"\\b[A-Z][\\wÀ-ʸ']*[!?]"},{className:"type",begin:"\\b[A-Z][\\wÀ-ʸ']*",relevance:0},r,{className:"function",beginKeywords:"func",end:"{",excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{begin://},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:i,contains:["self",r,a,e.C_BLOCK_COMMENT_MODE,{begin:":"}],illegal:/["']/}],illegal:/\[|%/},{className:"class",beginKeywords:"struct protocol class extension enum",keywords:i,end:"\\{",excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/})]},{className:"meta",begin:"(@discardableResult|@warn_unused_result|@exported|@lazy|@noescape|@NSCopying|@NSManaged|@objc|@objcMembers|@convention|@required|@noreturn|@IBAction|@IBDesignable|@IBInspectable|@IBOutlet|@infix|@prefix|@postfix|@autoclosure|@testable|@available|@nonobjc|@NSApplicationMain|@UIApplicationMain|@dynamicMemberLookup|@propertyWrapper)\\b"},{beginKeywords:"import",end:/$/,contains:[e.C_LINE_COMMENT_MODE,n]}]}}}()); +hljs.registerLanguage("typescript",function(){"use strict";const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);return function(r){var t={$pattern:"[A-Za-z$_][0-9A-Za-z$_]*",keyword:e.concat(["type","namespace","typedef","interface","public","private","protected","implements","declare","abstract","readonly"]).join(" "),literal:n.join(" "),built_in:a.concat(["any","void","number","boolean","string","object","never","enum"]).join(" ")},s={className:"meta",begin:"@[A-Za-z$_][0-9A-Za-z$_]*"},i={className:"number",variants:[{begin:"\\b(0[bB][01]+)n?"},{begin:"\\b(0[oO][0-7]+)n?"},{begin:r.C_NUMBER_RE+"n?"}],relevance:0},o={className:"subst",begin:"\\$\\{",end:"\\}",keywords:t,contains:[]},c={begin:"html`",end:"",starts:{end:"`",returnEnd:!1,contains:[r.BACKSLASH_ESCAPE,o],subLanguage:"xml"}},l={begin:"css`",end:"",starts:{end:"`",returnEnd:!1,contains:[r.BACKSLASH_ESCAPE,o],subLanguage:"css"}},E={className:"string",begin:"`",end:"`",contains:[r.BACKSLASH_ESCAPE,o]};o.contains=[r.APOS_STRING_MODE,r.QUOTE_STRING_MODE,c,l,E,i,r.REGEXP_MODE];var d={begin:"\\(",end:/\)/,keywords:t,contains:["self",r.QUOTE_STRING_MODE,r.APOS_STRING_MODE,r.NUMBER_MODE]},u={className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:t,contains:[r.C_LINE_COMMENT_MODE,r.C_BLOCK_COMMENT_MODE,s,d]};return{name:"TypeScript",aliases:["ts"],keywords:t,contains:[r.SHEBANG(),{className:"meta",begin:/^\s*['"]use strict['"]/},r.APOS_STRING_MODE,r.QUOTE_STRING_MODE,c,l,E,r.C_LINE_COMMENT_MODE,r.C_BLOCK_COMMENT_MODE,i,{begin:"("+r.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[r.C_LINE_COMMENT_MODE,r.C_BLOCK_COMMENT_MODE,r.REGEXP_MODE,{className:"function",begin:"(\\([^(]*(\\([^(]*(\\([^(]*\\))?\\))?\\)|"+r.UNDERSCORE_IDENT_RE+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:r.UNDERSCORE_IDENT_RE},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:t,contains:d.contains}]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/[\{;]/,excludeEnd:!0,keywords:t,contains:["self",r.inherit(r.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"}),u],illegal:/%/,relevance:0},{beginKeywords:"constructor",end:/[\{;]/,excludeEnd:!0,contains:["self",u]},{begin:/module\./,keywords:{built_in:"module"},relevance:0},{beginKeywords:"module",end:/\{/,excludeEnd:!0},{beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:"interface extends"},{begin:/\$[(.]/},{begin:"\\."+r.IDENT_RE,relevance:0},s,d]}}}()); +hljs.registerLanguage("yaml",function(){"use strict";return function(e){var n="true false yes no null",a="[\\w#;/?:@&=+$,.~*\\'()[\\]]+",s={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:"{{",end:"}}"},{begin:"%{",end:"}"}]}]},i=e.inherit(s,{variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),l={end:",",endsWithParent:!0,excludeEnd:!0,contains:[],keywords:n,relevance:0},t={begin:"{",end:"}",contains:[l],illegal:"\\n",relevance:0},g={begin:"\\[",end:"\\]",contains:[l],illegal:"\\n",relevance:0},b=[{className:"attr",variants:[{begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---s*$",relevance:10},{className:"string",begin:"[\\|>]([0-9]?[+-])?[ ]*\\n( *)[\\S ]+\\n(\\2[\\S ]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!\\w+!"+a},{className:"type",begin:"!<"+a+">"},{className:"type",begin:"!"+a},{className:"type",begin:"!!"+a},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"\\-(?=[ ]|$)",relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"},{className:"number",begin:e.C_NUMBER_RE+"\\b"},t,g,s],c=[...b];return c.pop(),c.push(i),l.contains=c,{name:"YAML",case_insensitive:!0,aliases:["yml","YAML"],contains:b}}}()); +hljs.registerLanguage("armasm",function(){"use strict";return function(s){const e={variants:[s.COMMENT("^[ \\t]*(?=#)","$",{relevance:0,excludeBegin:!0}),s.COMMENT("[;@]","$",{relevance:0}),s.C_LINE_COMMENT_MODE,s.C_BLOCK_COMMENT_MODE]};return{name:"ARM Assembly",case_insensitive:!0,aliases:["arm"],keywords:{$pattern:"\\.?"+s.IDENT_RE,meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .arm .thumb .code16 .code32 .force_thumb .thumb_func .ltorg ALIAS ALIGN ARM AREA ASSERT ATTR CN CODE CODE16 CODE32 COMMON CP DATA DCB DCD DCDU DCDO DCFD DCFDU DCI DCQ DCQU DCW DCWU DN ELIF ELSE END ENDFUNC ENDIF ENDP ENTRY EQU EXPORT EXPORTAS EXTERN FIELD FILL FUNCTION GBLA GBLL GBLS GET GLOBAL IF IMPORT INCBIN INCLUDE INFO KEEP LCLA LCLL LCLS LTORG MACRO MAP MEND MEXIT NOFP OPT PRESERVE8 PROC QN READONLY RELOC REQUIRE REQUIRE8 RLIST FN ROUT SETA SETL SETS SN SPACE SUBT THUMB THUMBX TTL WHILE WEND ",built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 pc lr sp ip sl sb fp a1 a2 a3 a4 v1 v2 v3 v4 v5 v6 v7 v8 f0 f1 f2 f3 f4 f5 f6 f7 p0 p1 p2 p3 p4 p5 p6 p7 p8 p9 p10 p11 p12 p13 p14 p15 c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14 c15 q0 q1 q2 q3 q4 q5 q6 q7 q8 q9 q10 q11 q12 q13 q14 q15 cpsr_c cpsr_x cpsr_s cpsr_f cpsr_cx cpsr_cxs cpsr_xs cpsr_xsf cpsr_sf cpsr_cxsf spsr_c spsr_x spsr_s spsr_f spsr_cx spsr_cxs spsr_xs spsr_xsf spsr_sf spsr_cxsf s0 s1 s2 s3 s4 s5 s6 s7 s8 s9 s10 s11 s12 s13 s14 s15 s16 s17 s18 s19 s20 s21 s22 s23 s24 s25 s26 s27 s28 s29 s30 s31 d0 d1 d2 d3 d4 d5 d6 d7 d8 d9 d10 d11 d12 d13 d14 d15 d16 d17 d18 d19 d20 d21 d22 d23 d24 d25 d26 d27 d28 d29 d30 d31 {PC} {VAR} {TRUE} {FALSE} {OPT} {CONFIG} {ENDIAN} {CODESIZE} {CPU} {FPU} {ARCHITECTURE} {PCSTOREOFFSET} {ARMASM_VERSION} {INTER} {ROPI} {RWPI} {SWST} {NOSWST} . @"},contains:[{className:"keyword",begin:"\\b(adc|(qd?|sh?|u[qh]?)?add(8|16)?|usada?8|(q|sh?|u[qh]?)?(as|sa)x|and|adrl?|sbc|rs[bc]|asr|b[lx]?|blx|bxj|cbn?z|tb[bh]|bic|bfc|bfi|[su]bfx|bkpt|cdp2?|clz|clrex|cmp|cmn|cpsi[ed]|cps|setend|dbg|dmb|dsb|eor|isb|it[te]{0,3}|lsl|lsr|ror|rrx|ldm(([id][ab])|f[ds])?|ldr((s|ex)?[bhd])?|movt?|mvn|mra|mar|mul|[us]mull|smul[bwt][bt]|smu[as]d|smmul|smmla|mla|umlaal|smlal?([wbt][bt]|d)|mls|smlsl?[ds]|smc|svc|sev|mia([bt]{2}|ph)?|mrr?c2?|mcrr2?|mrs|msr|orr|orn|pkh(tb|bt)|rbit|rev(16|sh)?|sel|[su]sat(16)?|nop|pop|push|rfe([id][ab])?|stm([id][ab])?|str(ex)?[bhd]?|(qd?)?sub|(sh?|q|u[qh]?)?sub(8|16)|[su]xt(a?h|a?b(16)?)|srs([id][ab])?|swpb?|swi|smi|tst|teq|wfe|wfi|yield)(eq|ne|cs|cc|mi|pl|vs|vc|hi|ls|ge|lt|gt|le|al|hs|lo)?[sptrx]?(?=\\s)"},e,s.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",relevance:0},{className:"title",begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{className:"number",variants:[{begin:"[#$=]?0x[0-9a-f]+"},{begin:"[#$=]?0b[01]+"},{begin:"[#$=]\\d+"},{begin:"\\b\\d+"}],relevance:0},{className:"symbol",variants:[{begin:"^[ \\t]*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{begin:"^[a-z_\\.\\$][a-z0-9_\\.\\$]+"},{begin:"[=#]\\w+"}],relevance:0}]}}}()); +hljs.registerLanguage("d",function(){"use strict";return function(e){var a={$pattern:e.UNDERSCORE_IDENT_RE,keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",literal:"false null true"},d="((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))",n="\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};",t={className:"number",begin:"\\b"+d+"(L|u|U|Lu|LU|uL|UL)?",relevance:0},_={className:"number",begin:"\\b(((0[xX](([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)\\.([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)|\\.?([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))|((0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(\\.\\d*|([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)))|\\d+\\.(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)|\\.(0|[1-9][\\d_]*)([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))?))([fF]|L|i|[fF]i|Li)?|"+d+"(i|[fF]i|Li))",relevance:0},r={className:"string",begin:"'("+n+"|.)",end:"'",illegal:"."},i={className:"string",begin:'"',contains:[{begin:n,relevance:0}],end:'"[cwd]?'},s=e.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{name:"D",keywords:a,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,{className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},i,{className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string",begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},_,t,r,{className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta",begin:"#(line)",end:"$",relevance:5},{className:"keyword",begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}}}()); +hljs.registerLanguage("handlebars",function(){"use strict";function e(...e){return e.map(e=>(function(e){return e?"string"==typeof e?e:e.source:null})(e)).join("")}return function(n){const a={"builtin-name":"action bindattr collection component concat debugger each each-in get hash if in input link-to loc log lookup mut outlet partial query-params render template textarea unbound unless view with yield"},t=/\[.*?\]/,s=/[^\s!"#%&'()*+,.\/;<=>@\[\\\]^`{|}~]+/,i=e("(",/'.*?'/,"|",/".*?"/,"|",t,"|",s,"|",/\.|\//,")+"),r=e("(",t,"|",s,")(?==)"),l={begin:i,lexemes:/[\w.\/]+/},c=n.inherit(l,{keywords:{literal:"true false undefined null"}}),o={begin:/\(/,end:/\)/},m={className:"attr",begin:r,relevance:0,starts:{begin:/=/,end:/=/,starts:{contains:[n.NUMBER_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,c,o]}}},d={contains:[n.NUMBER_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,{begin:/as\s+\|/,keywords:{keyword:"as"},end:/\|/,contains:[{begin:/\w+/}]},m,c,o],returnEnd:!0},g=n.inherit(l,{className:"name",keywords:a,starts:n.inherit(d,{end:/\)/})});o.contains=[g];const u=n.inherit(l,{keywords:a,className:"name",starts:n.inherit(d,{end:/}}/})}),b=n.inherit(l,{keywords:a,className:"name"}),h=n.inherit(l,{className:"name",keywords:a,starts:n.inherit(d,{end:/}}/})});return{name:"Handlebars",aliases:["hbs","html.hbs","html.handlebars","htmlbars"],case_insensitive:!0,subLanguage:"xml",contains:[{begin:/\\\{\{/,skip:!0},{begin:/\\\\(?=\{\{)/,skip:!0},n.COMMENT(/\{\{!--/,/--\}\}/),n.COMMENT(/\{\{!/,/\}\}/),{className:"template-tag",begin:/\{\{\{\{(?!\/)/,end:/\}\}\}\}/,contains:[u],starts:{end:/\{\{\{\{\//,returnEnd:!0,subLanguage:"xml"}},{className:"template-tag",begin:/\{\{\{\{\//,end:/\}\}\}\}/,contains:[b]},{className:"template-tag",begin:/\{\{#/,end:/\}\}/,contains:[u]},{className:"template-tag",begin:/\{\{(?=else\}\})/,end:/\}\}/,keywords:"else"},{className:"template-tag",begin:/\{\{\//,end:/\}\}/,contains:[b]},{className:"template-variable",begin:/\{\{\{/,end:/\}\}\}/,contains:[h]},{className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[h]}]}}}()); +hljs.registerLanguage("haskell",function(){"use strict";return function(e){var n={variants:[e.COMMENT("--","$"),e.COMMENT("{-","-}",{contains:["self"]})]},i={className:"meta",begin:"{-#",end:"#-}"},a={className:"meta",begin:"^#",end:"$"},s={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},l={begin:"\\(",end:"\\)",illegal:'"',contains:[i,a,{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},e.inherit(e.TITLE_MODE,{begin:"[_a-z][\\w']*"}),n]};return{name:"Haskell",aliases:["hs"],keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",contains:[{beginKeywords:"module",end:"where",keywords:"module where",contains:[l,n],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",keywords:"import qualified as hiding",contains:[l,n],illegal:"\\W\\.|;"},{className:"class",begin:"^(\\s*)?(class|instance)\\b",end:"where",keywords:"class family instance where",contains:[s,l,n]},{className:"class",begin:"\\b(data|(new)?type)\\b",end:"$",keywords:"data family type newtype deriving",contains:[i,s,l,{begin:"{",end:"}",contains:l.contains},n]},{beginKeywords:"default",end:"$",contains:[s,l,n]},{beginKeywords:"infix infixl infixr",end:"$",contains:[e.C_NUMBER_MODE,n]},{begin:"\\bforeign\\b",end:"$",keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",contains:[s,e.QUOTE_STRING_MODE,n]},{className:"meta",begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"},i,a,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,s,e.inherit(e.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),n,{begin:"->|<-"}]}}}()); +hljs.registerLanguage("julia",function(){"use strict";return function(e){var r="[A-Za-z_\\u00A1-\\uFFFF][A-Za-z_0-9\\u00A1-\\uFFFF]*",t={$pattern:r,keyword:"in isa where baremodule begin break catch ccall const continue do else elseif end export false finally for function global if import importall let local macro module quote return true try using while type immutable abstract bitstype typealias ",literal:"true false ARGS C_NULL DevNull ENDIAN_BOM ENV I Inf Inf16 Inf32 Inf64 InsertionSort JULIA_HOME LOAD_PATH MergeSort NaN NaN16 NaN32 NaN64 PROGRAM_FILE QuickSort RoundDown RoundFromZero RoundNearest RoundNearestTiesAway RoundNearestTiesUp RoundToZero RoundUp STDERR STDIN STDOUT VERSION catalan e|0 eu|0 eulergamma golden im nothing pi γ π φ ",built_in:"ANY AbstractArray AbstractChannel AbstractFloat AbstractMatrix AbstractRNG AbstractSerializer AbstractSet AbstractSparseArray AbstractSparseMatrix AbstractSparseVector AbstractString AbstractUnitRange AbstractVecOrMat AbstractVector Any ArgumentError Array AssertionError Associative Base64DecodePipe Base64EncodePipe Bidiagonal BigFloat BigInt BitArray BitMatrix BitVector Bool BoundsError BufferStream CachingPool CapturedException CartesianIndex CartesianRange Cchar Cdouble Cfloat Channel Char Cint Cintmax_t Clong Clonglong ClusterManager Cmd CodeInfo Colon Complex Complex128 Complex32 Complex64 CompositeException Condition ConjArray ConjMatrix ConjVector Cptrdiff_t Cshort Csize_t Cssize_t Cstring Cuchar Cuint Cuintmax_t Culong Culonglong Cushort Cwchar_t Cwstring DataType Date DateFormat DateTime DenseArray DenseMatrix DenseVecOrMat DenseVector Diagonal Dict DimensionMismatch Dims DirectIndexString Display DivideError DomainError EOFError EachLine Enum Enumerate ErrorException Exception ExponentialBackOff Expr Factorization FileMonitor Float16 Float32 Float64 Function Future GlobalRef GotoNode HTML Hermitian IO IOBuffer IOContext IOStream IPAddr IPv4 IPv6 IndexCartesian IndexLinear IndexStyle InexactError InitError Int Int128 Int16 Int32 Int64 Int8 IntSet Integer InterruptException InvalidStateException Irrational KeyError LabelNode LinSpace LineNumberNode LoadError LowerTriangular MIME Matrix MersenneTwister Method MethodError MethodTable Module NTuple NewvarNode NullException Nullable Number ObjectIdDict OrdinalRange OutOfMemoryError OverflowError Pair ParseError PartialQuickSort PermutedDimsArray Pipe PollingFileWatcher ProcessExitedException Ptr QuoteNode RandomDevice Range RangeIndex Rational RawFD ReadOnlyMemoryError Real ReentrantLock Ref Regex RegexMatch RemoteChannel RemoteException RevString RoundingMode RowVector SSAValue SegmentationFault SerializationState Set SharedArray SharedMatrix SharedVector Signed SimpleVector Slot SlotNumber SparseMatrixCSC SparseVector StackFrame StackOverflowError StackTrace StepRange StepRangeLen StridedArray StridedMatrix StridedVecOrMat StridedVector String SubArray SubString SymTridiagonal Symbol Symmetric SystemError TCPSocket Task Text TextDisplay Timer Tridiagonal Tuple Type TypeError TypeMapEntry TypeMapLevel TypeName TypeVar TypedSlot UDPSocket UInt UInt128 UInt16 UInt32 UInt64 UInt8 UndefRefError UndefVarError UnicodeError UniformScaling Union UnionAll UnitRange Unsigned UpperTriangular Val Vararg VecElement VecOrMat Vector VersionNumber Void WeakKeyDict WeakRef WorkerConfig WorkerPool "},a={keywords:t,illegal:/<\//},n={className:"subst",begin:/\$\(/,end:/\)/,keywords:t},o={className:"variable",begin:"\\$"+r},i={className:"string",contains:[e.BACKSLASH_ESCAPE,n,o],variants:[{begin:/\w*"""/,end:/"""\w*/,relevance:10},{begin:/\w*"/,end:/"\w*/}]},l={className:"string",contains:[e.BACKSLASH_ESCAPE,n,o],begin:"`",end:"`"},s={className:"meta",begin:"@"+r};return a.name="Julia",a.contains=[{className:"number",begin:/(\b0x[\d_]*(\.[\d_]*)?|0x\.\d[\d_]*)p[-+]?\d+|\b0[box][a-fA-F0-9][a-fA-F0-9_]*|(\b\d[\d_]*(\.[\d_]*)?|\.\d[\d_]*)([eEfF][-+]?\d+)?/,relevance:0},{className:"string",begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},i,l,s,{className:"comment",variants:[{begin:"#=",end:"=#",relevance:10},{begin:"#",end:"$"}]},e.HASH_COMMENT_MODE,{className:"keyword",begin:"\\b(((abstract|primitive)\\s+)type|(mutable\\s+)?struct)\\b"},{begin:/<:/}],n.contains=a.contains,a}}()); +hljs.registerLanguage("nim",function(){"use strict";return function(e){return{name:"Nim",aliases:["nim"],keywords:{keyword:"addr and as asm bind block break case cast const continue converter discard distinct div do elif else end enum except export finally for from func generic if import in include interface is isnot iterator let macro method mixin mod nil not notin object of or out proc ptr raise ref return shl shr static template try tuple type using var when while with without xor yield",literal:"shared guarded stdin stdout stderr result true false",built_in:"int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float float32 float64 bool char string cstring pointer expr stmt void auto any range array openarray varargs seq set clong culong cchar cschar cshort cint csize clonglong cfloat cdouble clongdouble cuchar cushort cuint culonglong cstringarray semistatic"},contains:[{className:"meta",begin:/{\./,end:/\.}/,relevance:10},{className:"string",begin:/[a-zA-Z]\w*"/,end:/"/,contains:[{begin:/""/}]},{className:"string",begin:/([a-zA-Z]\w*)?"""/,end:/"""/},e.QUOTE_STRING_MODE,{className:"type",begin:/\b[A-Z]\w+\b/,relevance:0},{className:"number",relevance:0,variants:[{begin:/\b(0[xX][0-9a-fA-F][_0-9a-fA-F]*)('?[iIuU](8|16|32|64))?/},{begin:/\b(0o[0-7][_0-7]*)('?[iIuUfF](8|16|32|64))?/},{begin:/\b(0(b|B)[01][_01]*)('?[iIuUfF](8|16|32|64))?/},{begin:/\b(\d[_\d]*)('?[iIuUfF](8|16|32|64))?/}]},e.HASH_COMMENT_MODE]}}}()); +hljs.registerLanguage("nix",function(){"use strict";return function(e){var n={keyword:"rec with let in inherit assert if else then",literal:"true false or and null",built_in:"import abort baseNameOf dirOf isNull builtins map removeAttrs throw toString derivation"},i={className:"subst",begin:/\$\{/,end:/}/,keywords:n},t={className:"string",contains:[i],variants:[{begin:"''",end:"''"},{begin:'"',end:'"'}]},s=[e.NUMBER_MODE,e.HASH_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,t,{begin:/[a-zA-Z0-9-_]+(\s*=)/,returnBegin:!0,relevance:0,contains:[{className:"attr",begin:/\S+/}]}];return i.contains=s,{name:"Nix",aliases:["nixos"],keywords:n,contains:s}}}()); +hljs.registerLanguage("r",function(){"use strict";return function(e){var n="([a-zA-Z]|\\.[a-zA-Z.])[a-zA-Z0-9._]*";return{name:"R",contains:[e.HASH_COMMENT_MODE,{begin:n,keywords:{$pattern:n,keyword:"function if in break next repeat else for return switch while try tryCatch stop warning require library attach detach source setMethod setGeneric setGroupGeneric setClass ...",literal:"NULL NA TRUE FALSE T F Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10"},relevance:0},{className:"number",begin:"0[xX][0-9a-fA-F]+[Li]?\\b",relevance:0},{className:"number",begin:"\\d+(?:[eE][+\\-]?\\d*)?L\\b",relevance:0},{className:"number",begin:"\\d+\\.(?!\\d)(?:i\\b)?",relevance:0},{className:"number",begin:"\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d*)?i?\\b",relevance:0},{className:"number",begin:"\\.\\d+(?:[eE][+\\-]?\\d*)?i?\\b",relevance:0},{begin:"`",end:"`",relevance:0},{className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:'"',end:'"'},{begin:"'",end:"'"}]}]}}}()); +hljs.registerLanguage("scala",function(){"use strict";return function(e){var n={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:"\\${",end:"}"}]},a={className:"string",variants:[{begin:'"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{begin:'"""',end:'"""',relevance:10},{begin:'[a-z]+"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,n]},{className:"string",begin:'[a-z]+"""',end:'"""',contains:[n],relevance:10}]},s={className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},t={className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,relevance:0},i={className:"class",beginKeywords:"class object trait type",end:/[:={\[\n;]/,excludeEnd:!0,contains:[{beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[s]},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[s]},t]},l={className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[t]};return{name:"Scala",keywords:{literal:"true false null",keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,{className:"symbol",begin:"'\\w[\\w\\d_]*(?!')"},s,l,i,e.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}}}()); +hljs.registerLanguage("x86asm",function(){"use strict";return function(s){return{name:"Intel x86 Assembly",case_insensitive:!0,keywords:{$pattern:"[.%]?"+s.IDENT_RE,keyword:"lock rep repe repz repne repnz xaquire xrelease bnd nobnd aaa aad aam aas adc add and arpl bb0_reset bb1_reset bound bsf bsr bswap bt btc btr bts call cbw cdq cdqe clc cld cli clts cmc cmp cmpsb cmpsd cmpsq cmpsw cmpxchg cmpxchg486 cmpxchg8b cmpxchg16b cpuid cpu_read cpu_write cqo cwd cwde daa das dec div dmint emms enter equ f2xm1 fabs fadd faddp fbld fbstp fchs fclex fcmovb fcmovbe fcmove fcmovnb fcmovnbe fcmovne fcmovnu fcmovu fcom fcomi fcomip fcomp fcompp fcos fdecstp fdisi fdiv fdivp fdivr fdivrp femms feni ffree ffreep fiadd ficom ficomp fidiv fidivr fild fimul fincstp finit fist fistp fisttp fisub fisubr fld fld1 fldcw fldenv fldl2e fldl2t fldlg2 fldln2 fldpi fldz fmul fmulp fnclex fndisi fneni fninit fnop fnsave fnstcw fnstenv fnstsw fpatan fprem fprem1 fptan frndint frstor fsave fscale fsetpm fsin fsincos fsqrt fst fstcw fstenv fstp fstsw fsub fsubp fsubr fsubrp ftst fucom fucomi fucomip fucomp fucompp fxam fxch fxtract fyl2x fyl2xp1 hlt ibts icebp idiv imul in inc incbin insb insd insw int int01 int1 int03 int3 into invd invpcid invlpg invlpga iret iretd iretq iretw jcxz jecxz jrcxz jmp jmpe lahf lar lds lea leave les lfence lfs lgdt lgs lidt lldt lmsw loadall loadall286 lodsb lodsd lodsq lodsw loop loope loopne loopnz loopz lsl lss ltr mfence monitor mov movd movq movsb movsd movsq movsw movsx movsxd movzx mul mwait neg nop not or out outsb outsd outsw packssdw packsswb packuswb paddb paddd paddsb paddsiw paddsw paddusb paddusw paddw pand pandn pause paveb pavgusb pcmpeqb pcmpeqd pcmpeqw pcmpgtb pcmpgtd pcmpgtw pdistib pf2id pfacc pfadd pfcmpeq pfcmpge pfcmpgt pfmax pfmin pfmul pfrcp pfrcpit1 pfrcpit2 pfrsqit1 pfrsqrt pfsub pfsubr pi2fd pmachriw pmaddwd pmagw pmulhriw pmulhrwa pmulhrwc pmulhw pmullw pmvgezb pmvlzb pmvnzb pmvzb pop popa popad popaw popf popfd popfq popfw por prefetch prefetchw pslld psllq psllw psrad psraw psrld psrlq psrlw psubb psubd psubsb psubsiw psubsw psubusb psubusw psubw punpckhbw punpckhdq punpckhwd punpcklbw punpckldq punpcklwd push pusha pushad pushaw pushf pushfd pushfq pushfw pxor rcl rcr rdshr rdmsr rdpmc rdtsc rdtscp ret retf retn rol ror rdm rsdc rsldt rsm rsts sahf sal salc sar sbb scasb scasd scasq scasw sfence sgdt shl shld shr shrd sidt sldt skinit smi smint smintold smsw stc std sti stosb stosd stosq stosw str sub svdc svldt svts swapgs syscall sysenter sysexit sysret test ud0 ud1 ud2b ud2 ud2a umov verr verw fwait wbinvd wrshr wrmsr xadd xbts xchg xlatb xlat xor cmove cmovz cmovne cmovnz cmova cmovnbe cmovae cmovnb cmovb cmovnae cmovbe cmovna cmovg cmovnle cmovge cmovnl cmovl cmovnge cmovle cmovng cmovc cmovnc cmovo cmovno cmovs cmovns cmovp cmovpe cmovnp cmovpo je jz jne jnz ja jnbe jae jnb jb jnae jbe jna jg jnle jge jnl jl jnge jle jng jc jnc jo jno js jns jpo jnp jpe jp sete setz setne setnz seta setnbe setae setnb setnc setb setnae setcset setbe setna setg setnle setge setnl setl setnge setle setng sets setns seto setno setpe setp setpo setnp addps addss andnps andps cmpeqps cmpeqss cmpleps cmpless cmpltps cmpltss cmpneqps cmpneqss cmpnleps cmpnless cmpnltps cmpnltss cmpordps cmpordss cmpunordps cmpunordss cmpps cmpss comiss cvtpi2ps cvtps2pi cvtsi2ss cvtss2si cvttps2pi cvttss2si divps divss ldmxcsr maxps maxss minps minss movaps movhps movlhps movlps movhlps movmskps movntps movss movups mulps mulss orps rcpps rcpss rsqrtps rsqrtss shufps sqrtps sqrtss stmxcsr subps subss ucomiss unpckhps unpcklps xorps fxrstor fxrstor64 fxsave fxsave64 xgetbv xsetbv xsave xsave64 xsaveopt xsaveopt64 xrstor xrstor64 prefetchnta prefetcht0 prefetcht1 prefetcht2 maskmovq movntq pavgb pavgw pextrw pinsrw pmaxsw pmaxub pminsw pminub pmovmskb pmulhuw psadbw pshufw pf2iw pfnacc pfpnacc pi2fw pswapd maskmovdqu clflush movntdq movnti movntpd movdqa movdqu movdq2q movq2dq paddq pmuludq pshufd pshufhw pshuflw pslldq psrldq psubq punpckhqdq punpcklqdq addpd addsd andnpd andpd cmpeqpd cmpeqsd cmplepd cmplesd cmpltpd cmpltsd cmpneqpd cmpneqsd cmpnlepd cmpnlesd cmpnltpd cmpnltsd cmpordpd cmpordsd cmpunordpd cmpunordsd cmppd comisd cvtdq2pd cvtdq2ps cvtpd2dq cvtpd2pi cvtpd2ps cvtpi2pd cvtps2dq cvtps2pd cvtsd2si cvtsd2ss cvtsi2sd cvtss2sd cvttpd2pi cvttpd2dq cvttps2dq cvttsd2si divpd divsd maxpd maxsd minpd minsd movapd movhpd movlpd movmskpd movupd mulpd mulsd orpd shufpd sqrtpd sqrtsd subpd subsd ucomisd unpckhpd unpcklpd xorpd addsubpd addsubps haddpd haddps hsubpd hsubps lddqu movddup movshdup movsldup clgi stgi vmcall vmclear vmfunc vmlaunch vmload vmmcall vmptrld vmptrst vmread vmresume vmrun vmsave vmwrite vmxoff vmxon invept invvpid pabsb pabsw pabsd palignr phaddw phaddd phaddsw phsubw phsubd phsubsw pmaddubsw pmulhrsw pshufb psignb psignw psignd extrq insertq movntsd movntss lzcnt blendpd blendps blendvpd blendvps dppd dpps extractps insertps movntdqa mpsadbw packusdw pblendvb pblendw pcmpeqq pextrb pextrd pextrq phminposuw pinsrb pinsrd pinsrq pmaxsb pmaxsd pmaxud pmaxuw pminsb pminsd pminud pminuw pmovsxbw pmovsxbd pmovsxbq pmovsxwd pmovsxwq pmovsxdq pmovzxbw pmovzxbd pmovzxbq pmovzxwd pmovzxwq pmovzxdq pmuldq pmulld ptest roundpd roundps roundsd roundss crc32 pcmpestri pcmpestrm pcmpistri pcmpistrm pcmpgtq popcnt getsec pfrcpv pfrsqrtv movbe aesenc aesenclast aesdec aesdeclast aesimc aeskeygenassist vaesenc vaesenclast vaesdec vaesdeclast vaesimc vaeskeygenassist vaddpd vaddps vaddsd vaddss vaddsubpd vaddsubps vandpd vandps vandnpd vandnps vblendpd vblendps vblendvpd vblendvps vbroadcastss vbroadcastsd vbroadcastf128 vcmpeq_ospd vcmpeqpd vcmplt_ospd vcmpltpd vcmple_ospd vcmplepd vcmpunord_qpd vcmpunordpd vcmpneq_uqpd vcmpneqpd vcmpnlt_uspd vcmpnltpd vcmpnle_uspd vcmpnlepd vcmpord_qpd vcmpordpd vcmpeq_uqpd vcmpnge_uspd vcmpngepd vcmpngt_uspd vcmpngtpd vcmpfalse_oqpd vcmpfalsepd vcmpneq_oqpd vcmpge_ospd vcmpgepd vcmpgt_ospd vcmpgtpd vcmptrue_uqpd vcmptruepd vcmplt_oqpd vcmple_oqpd vcmpunord_spd vcmpneq_uspd vcmpnlt_uqpd vcmpnle_uqpd vcmpord_spd vcmpeq_uspd vcmpnge_uqpd vcmpngt_uqpd vcmpfalse_ospd vcmpneq_ospd vcmpge_oqpd vcmpgt_oqpd vcmptrue_uspd vcmppd vcmpeq_osps vcmpeqps vcmplt_osps vcmpltps vcmple_osps vcmpleps vcmpunord_qps vcmpunordps vcmpneq_uqps vcmpneqps vcmpnlt_usps vcmpnltps vcmpnle_usps vcmpnleps vcmpord_qps vcmpordps vcmpeq_uqps vcmpnge_usps vcmpngeps vcmpngt_usps vcmpngtps vcmpfalse_oqps vcmpfalseps vcmpneq_oqps vcmpge_osps vcmpgeps vcmpgt_osps vcmpgtps vcmptrue_uqps vcmptrueps vcmplt_oqps vcmple_oqps vcmpunord_sps vcmpneq_usps vcmpnlt_uqps vcmpnle_uqps vcmpord_sps vcmpeq_usps vcmpnge_uqps vcmpngt_uqps vcmpfalse_osps vcmpneq_osps vcmpge_oqps vcmpgt_oqps vcmptrue_usps vcmpps vcmpeq_ossd vcmpeqsd vcmplt_ossd vcmpltsd vcmple_ossd vcmplesd vcmpunord_qsd vcmpunordsd vcmpneq_uqsd vcmpneqsd vcmpnlt_ussd vcmpnltsd vcmpnle_ussd vcmpnlesd vcmpord_qsd vcmpordsd vcmpeq_uqsd vcmpnge_ussd vcmpngesd vcmpngt_ussd vcmpngtsd vcmpfalse_oqsd vcmpfalsesd vcmpneq_oqsd vcmpge_ossd vcmpgesd vcmpgt_ossd vcmpgtsd vcmptrue_uqsd vcmptruesd vcmplt_oqsd vcmple_oqsd vcmpunord_ssd vcmpneq_ussd vcmpnlt_uqsd vcmpnle_uqsd vcmpord_ssd vcmpeq_ussd vcmpnge_uqsd vcmpngt_uqsd vcmpfalse_ossd vcmpneq_ossd vcmpge_oqsd vcmpgt_oqsd vcmptrue_ussd vcmpsd vcmpeq_osss vcmpeqss vcmplt_osss vcmpltss vcmple_osss vcmpless vcmpunord_qss vcmpunordss vcmpneq_uqss vcmpneqss vcmpnlt_usss vcmpnltss vcmpnle_usss vcmpnless vcmpord_qss vcmpordss vcmpeq_uqss vcmpnge_usss vcmpngess vcmpngt_usss vcmpngtss vcmpfalse_oqss vcmpfalsess vcmpneq_oqss vcmpge_osss vcmpgess vcmpgt_osss vcmpgtss vcmptrue_uqss vcmptruess vcmplt_oqss vcmple_oqss vcmpunord_sss vcmpneq_usss vcmpnlt_uqss vcmpnle_uqss vcmpord_sss vcmpeq_usss vcmpnge_uqss vcmpngt_uqss vcmpfalse_osss vcmpneq_osss vcmpge_oqss vcmpgt_oqss vcmptrue_usss vcmpss vcomisd vcomiss vcvtdq2pd vcvtdq2ps vcvtpd2dq vcvtpd2ps vcvtps2dq vcvtps2pd vcvtsd2si vcvtsd2ss vcvtsi2sd vcvtsi2ss vcvtss2sd vcvtss2si vcvttpd2dq vcvttps2dq vcvttsd2si vcvttss2si vdivpd vdivps vdivsd vdivss vdppd vdpps vextractf128 vextractps vhaddpd vhaddps vhsubpd vhsubps vinsertf128 vinsertps vlddqu vldqqu vldmxcsr vmaskmovdqu vmaskmovps vmaskmovpd vmaxpd vmaxps vmaxsd vmaxss vminpd vminps vminsd vminss vmovapd vmovaps vmovd vmovq vmovddup vmovdqa vmovqqa vmovdqu vmovqqu vmovhlps vmovhpd vmovhps vmovlhps vmovlpd vmovlps vmovmskpd vmovmskps vmovntdq vmovntqq vmovntdqa vmovntpd vmovntps vmovsd vmovshdup vmovsldup vmovss vmovupd vmovups vmpsadbw vmulpd vmulps vmulsd vmulss vorpd vorps vpabsb vpabsw vpabsd vpacksswb vpackssdw vpackuswb vpackusdw vpaddb vpaddw vpaddd vpaddq vpaddsb vpaddsw vpaddusb vpaddusw vpalignr vpand vpandn vpavgb vpavgw vpblendvb vpblendw vpcmpestri vpcmpestrm vpcmpistri vpcmpistrm vpcmpeqb vpcmpeqw vpcmpeqd vpcmpeqq vpcmpgtb vpcmpgtw vpcmpgtd vpcmpgtq vpermilpd vpermilps vperm2f128 vpextrb vpextrw vpextrd vpextrq vphaddw vphaddd vphaddsw vphminposuw vphsubw vphsubd vphsubsw vpinsrb vpinsrw vpinsrd vpinsrq vpmaddwd vpmaddubsw vpmaxsb vpmaxsw vpmaxsd vpmaxub vpmaxuw vpmaxud vpminsb vpminsw vpminsd vpminub vpminuw vpminud vpmovmskb vpmovsxbw vpmovsxbd vpmovsxbq vpmovsxwd vpmovsxwq vpmovsxdq vpmovzxbw vpmovzxbd vpmovzxbq vpmovzxwd vpmovzxwq vpmovzxdq vpmulhuw vpmulhrsw vpmulhw vpmullw vpmulld vpmuludq vpmuldq vpor vpsadbw vpshufb vpshufd vpshufhw vpshuflw vpsignb vpsignw vpsignd vpslldq vpsrldq vpsllw vpslld vpsllq vpsraw vpsrad vpsrlw vpsrld vpsrlq vptest vpsubb vpsubw vpsubd vpsubq vpsubsb vpsubsw vpsubusb vpsubusw vpunpckhbw vpunpckhwd vpunpckhdq vpunpckhqdq vpunpcklbw vpunpcklwd vpunpckldq vpunpcklqdq vpxor vrcpps vrcpss vrsqrtps vrsqrtss vroundpd vroundps vroundsd vroundss vshufpd vshufps vsqrtpd vsqrtps vsqrtsd vsqrtss vstmxcsr vsubpd vsubps vsubsd vsubss vtestps vtestpd vucomisd vucomiss vunpckhpd vunpckhps vunpcklpd vunpcklps vxorpd vxorps vzeroall vzeroupper pclmullqlqdq pclmulhqlqdq pclmullqhqdq pclmulhqhqdq pclmulqdq vpclmullqlqdq vpclmulhqlqdq vpclmullqhqdq vpclmulhqhqdq vpclmulqdq vfmadd132ps vfmadd132pd vfmadd312ps vfmadd312pd vfmadd213ps vfmadd213pd vfmadd123ps vfmadd123pd vfmadd231ps vfmadd231pd vfmadd321ps vfmadd321pd vfmaddsub132ps vfmaddsub132pd vfmaddsub312ps vfmaddsub312pd vfmaddsub213ps vfmaddsub213pd vfmaddsub123ps vfmaddsub123pd vfmaddsub231ps vfmaddsub231pd vfmaddsub321ps vfmaddsub321pd vfmsub132ps vfmsub132pd vfmsub312ps vfmsub312pd vfmsub213ps vfmsub213pd vfmsub123ps vfmsub123pd vfmsub231ps vfmsub231pd vfmsub321ps vfmsub321pd vfmsubadd132ps vfmsubadd132pd vfmsubadd312ps vfmsubadd312pd vfmsubadd213ps vfmsubadd213pd vfmsubadd123ps vfmsubadd123pd vfmsubadd231ps vfmsubadd231pd vfmsubadd321ps vfmsubadd321pd vfnmadd132ps vfnmadd132pd vfnmadd312ps vfnmadd312pd vfnmadd213ps vfnmadd213pd vfnmadd123ps vfnmadd123pd vfnmadd231ps vfnmadd231pd vfnmadd321ps vfnmadd321pd vfnmsub132ps vfnmsub132pd vfnmsub312ps vfnmsub312pd vfnmsub213ps vfnmsub213pd vfnmsub123ps vfnmsub123pd vfnmsub231ps vfnmsub231pd vfnmsub321ps vfnmsub321pd vfmadd132ss vfmadd132sd vfmadd312ss vfmadd312sd vfmadd213ss vfmadd213sd vfmadd123ss vfmadd123sd vfmadd231ss vfmadd231sd vfmadd321ss vfmadd321sd vfmsub132ss vfmsub132sd vfmsub312ss vfmsub312sd vfmsub213ss vfmsub213sd vfmsub123ss vfmsub123sd vfmsub231ss vfmsub231sd vfmsub321ss vfmsub321sd vfnmadd132ss vfnmadd132sd vfnmadd312ss vfnmadd312sd vfnmadd213ss vfnmadd213sd vfnmadd123ss vfnmadd123sd vfnmadd231ss vfnmadd231sd vfnmadd321ss vfnmadd321sd vfnmsub132ss vfnmsub132sd vfnmsub312ss vfnmsub312sd vfnmsub213ss vfnmsub213sd vfnmsub123ss vfnmsub123sd vfnmsub231ss vfnmsub231sd vfnmsub321ss vfnmsub321sd rdfsbase rdgsbase rdrand wrfsbase wrgsbase vcvtph2ps vcvtps2ph adcx adox rdseed clac stac xstore xcryptecb xcryptcbc xcryptctr xcryptcfb xcryptofb montmul xsha1 xsha256 llwpcb slwpcb lwpval lwpins vfmaddpd vfmaddps vfmaddsd vfmaddss vfmaddsubpd vfmaddsubps vfmsubaddpd vfmsubaddps vfmsubpd vfmsubps vfmsubsd vfmsubss vfnmaddpd vfnmaddps vfnmaddsd vfnmaddss vfnmsubpd vfnmsubps vfnmsubsd vfnmsubss vfrczpd vfrczps vfrczsd vfrczss vpcmov vpcomb vpcomd vpcomq vpcomub vpcomud vpcomuq vpcomuw vpcomw vphaddbd vphaddbq vphaddbw vphadddq vphaddubd vphaddubq vphaddubw vphaddudq vphadduwd vphadduwq vphaddwd vphaddwq vphsubbw vphsubdq vphsubwd vpmacsdd vpmacsdqh vpmacsdql vpmacssdd vpmacssdqh vpmacssdql vpmacsswd vpmacssww vpmacswd vpmacsww vpmadcsswd vpmadcswd vpperm vprotb vprotd vprotq vprotw vpshab vpshad vpshaq vpshaw vpshlb vpshld vpshlq vpshlw vbroadcasti128 vpblendd vpbroadcastb vpbroadcastw vpbroadcastd vpbroadcastq vpermd vpermpd vpermps vpermq vperm2i128 vextracti128 vinserti128 vpmaskmovd vpmaskmovq vpsllvd vpsllvq vpsravd vpsrlvd vpsrlvq vgatherdpd vgatherqpd vgatherdps vgatherqps vpgatherdd vpgatherqd vpgatherdq vpgatherqq xabort xbegin xend xtest andn bextr blci blcic blsi blsic blcfill blsfill blcmsk blsmsk blsr blcs bzhi mulx pdep pext rorx sarx shlx shrx tzcnt tzmsk t1mskc valignd valignq vblendmpd vblendmps vbroadcastf32x4 vbroadcastf64x4 vbroadcasti32x4 vbroadcasti64x4 vcompresspd vcompressps vcvtpd2udq vcvtps2udq vcvtsd2usi vcvtss2usi vcvttpd2udq vcvttps2udq vcvttsd2usi vcvttss2usi vcvtudq2pd vcvtudq2ps vcvtusi2sd vcvtusi2ss vexpandpd vexpandps vextractf32x4 vextractf64x4 vextracti32x4 vextracti64x4 vfixupimmpd vfixupimmps vfixupimmsd vfixupimmss vgetexppd vgetexpps vgetexpsd vgetexpss vgetmantpd vgetmantps vgetmantsd vgetmantss vinsertf32x4 vinsertf64x4 vinserti32x4 vinserti64x4 vmovdqa32 vmovdqa64 vmovdqu32 vmovdqu64 vpabsq vpandd vpandnd vpandnq vpandq vpblendmd vpblendmq vpcmpltd vpcmpled vpcmpneqd vpcmpnltd vpcmpnled vpcmpd vpcmpltq vpcmpleq vpcmpneqq vpcmpnltq vpcmpnleq vpcmpq vpcmpequd vpcmpltud vpcmpleud vpcmpnequd vpcmpnltud vpcmpnleud vpcmpud vpcmpequq vpcmpltuq vpcmpleuq vpcmpnequq vpcmpnltuq vpcmpnleuq vpcmpuq vpcompressd vpcompressq vpermi2d vpermi2pd vpermi2ps vpermi2q vpermt2d vpermt2pd vpermt2ps vpermt2q vpexpandd vpexpandq vpmaxsq vpmaxuq vpminsq vpminuq vpmovdb vpmovdw vpmovqb vpmovqd vpmovqw vpmovsdb vpmovsdw vpmovsqb vpmovsqd vpmovsqw vpmovusdb vpmovusdw vpmovusqb vpmovusqd vpmovusqw vpord vporq vprold vprolq vprolvd vprolvq vprord vprorq vprorvd vprorvq vpscatterdd vpscatterdq vpscatterqd vpscatterqq vpsraq vpsravq vpternlogd vpternlogq vptestmd vptestmq vptestnmd vptestnmq vpxord vpxorq vrcp14pd vrcp14ps vrcp14sd vrcp14ss vrndscalepd vrndscaleps vrndscalesd vrndscaless vrsqrt14pd vrsqrt14ps vrsqrt14sd vrsqrt14ss vscalefpd vscalefps vscalefsd vscalefss vscatterdpd vscatterdps vscatterqpd vscatterqps vshuff32x4 vshuff64x2 vshufi32x4 vshufi64x2 kandnw kandw kmovw knotw kortestw korw kshiftlw kshiftrw kunpckbw kxnorw kxorw vpbroadcastmb2q vpbroadcastmw2d vpconflictd vpconflictq vplzcntd vplzcntq vexp2pd vexp2ps vrcp28pd vrcp28ps vrcp28sd vrcp28ss vrsqrt28pd vrsqrt28ps vrsqrt28sd vrsqrt28ss vgatherpf0dpd vgatherpf0dps vgatherpf0qpd vgatherpf0qps vgatherpf1dpd vgatherpf1dps vgatherpf1qpd vgatherpf1qps vscatterpf0dpd vscatterpf0dps vscatterpf0qpd vscatterpf0qps vscatterpf1dpd vscatterpf1dps vscatterpf1qpd vscatterpf1qps prefetchwt1 bndmk bndcl bndcu bndcn bndmov bndldx bndstx sha1rnds4 sha1nexte sha1msg1 sha1msg2 sha256rnds2 sha256msg1 sha256msg2 hint_nop0 hint_nop1 hint_nop2 hint_nop3 hint_nop4 hint_nop5 hint_nop6 hint_nop7 hint_nop8 hint_nop9 hint_nop10 hint_nop11 hint_nop12 hint_nop13 hint_nop14 hint_nop15 hint_nop16 hint_nop17 hint_nop18 hint_nop19 hint_nop20 hint_nop21 hint_nop22 hint_nop23 hint_nop24 hint_nop25 hint_nop26 hint_nop27 hint_nop28 hint_nop29 hint_nop30 hint_nop31 hint_nop32 hint_nop33 hint_nop34 hint_nop35 hint_nop36 hint_nop37 hint_nop38 hint_nop39 hint_nop40 hint_nop41 hint_nop42 hint_nop43 hint_nop44 hint_nop45 hint_nop46 hint_nop47 hint_nop48 hint_nop49 hint_nop50 hint_nop51 hint_nop52 hint_nop53 hint_nop54 hint_nop55 hint_nop56 hint_nop57 hint_nop58 hint_nop59 hint_nop60 hint_nop61 hint_nop62 hint_nop63",built_in:"ip eip rip al ah bl bh cl ch dl dh sil dil bpl spl r8b r9b r10b r11b r12b r13b r14b r15b ax bx cx dx si di bp sp r8w r9w r10w r11w r12w r13w r14w r15w eax ebx ecx edx esi edi ebp esp eip r8d r9d r10d r11d r12d r13d r14d r15d rax rbx rcx rdx rsi rdi rbp rsp r8 r9 r10 r11 r12 r13 r14 r15 cs ds es fs gs ss st st0 st1 st2 st3 st4 st5 st6 st7 mm0 mm1 mm2 mm3 mm4 mm5 mm6 mm7 xmm0 xmm1 xmm2 xmm3 xmm4 xmm5 xmm6 xmm7 xmm8 xmm9 xmm10 xmm11 xmm12 xmm13 xmm14 xmm15 xmm16 xmm17 xmm18 xmm19 xmm20 xmm21 xmm22 xmm23 xmm24 xmm25 xmm26 xmm27 xmm28 xmm29 xmm30 xmm31 ymm0 ymm1 ymm2 ymm3 ymm4 ymm5 ymm6 ymm7 ymm8 ymm9 ymm10 ymm11 ymm12 ymm13 ymm14 ymm15 ymm16 ymm17 ymm18 ymm19 ymm20 ymm21 ymm22 ymm23 ymm24 ymm25 ymm26 ymm27 ymm28 ymm29 ymm30 ymm31 zmm0 zmm1 zmm2 zmm3 zmm4 zmm5 zmm6 zmm7 zmm8 zmm9 zmm10 zmm11 zmm12 zmm13 zmm14 zmm15 zmm16 zmm17 zmm18 zmm19 zmm20 zmm21 zmm22 zmm23 zmm24 zmm25 zmm26 zmm27 zmm28 zmm29 zmm30 zmm31 k0 k1 k2 k3 k4 k5 k6 k7 bnd0 bnd1 bnd2 bnd3 cr0 cr1 cr2 cr3 cr4 cr8 dr0 dr1 dr2 dr3 dr8 tr3 tr4 tr5 tr6 tr7 r0 r1 r2 r3 r4 r5 r6 r7 r0b r1b r2b r3b r4b r5b r6b r7b r0w r1w r2w r3w r4w r5w r6w r7w r0d r1d r2d r3d r4d r5d r6d r7d r0h r1h r2h r3h r0l r1l r2l r3l r4l r5l r6l r7l r8l r9l r10l r11l r12l r13l r14l r15l db dw dd dq dt ddq do dy dz resb resw resd resq rest resdq reso resy resz incbin equ times byte word dword qword nosplit rel abs seg wrt strict near far a32 ptr",meta:"%define %xdefine %+ %undef %defstr %deftok %assign %strcat %strlen %substr %rotate %elif %else %endif %if %ifmacro %ifctx %ifidn %ifidni %ifid %ifnum %ifstr %iftoken %ifempty %ifenv %error %warning %fatal %rep %endrep %include %push %pop %repl %pathsearch %depend %use %arg %stacksize %local %line %comment %endcomment .nolist __FILE__ __LINE__ __SECT__ __BITS__ __OUTPUT_FORMAT__ __DATE__ __TIME__ __DATE_NUM__ __TIME_NUM__ __UTC_DATE__ __UTC_TIME__ __UTC_DATE_NUM__ __UTC_TIME_NUM__ __PASS__ struc endstruc istruc at iend align alignb sectalign daz nodaz up down zero default option assume public bits use16 use32 use64 default section segment absolute extern global common cpu float __utf16__ __utf16le__ __utf16be__ __utf32__ __utf32le__ __utf32be__ __float8__ __float16__ __float32__ __float64__ __float80m__ __float80e__ __float128l__ __float128h__ __Infinity__ __QNaN__ __SNaN__ Inf NaN QNaN SNaN float8 float16 float32 float64 float80m float80e float128l float128h __FLOAT_DAZ__ __FLOAT_ROUND__ __FLOAT__"},contains:[s.COMMENT(";","$",{relevance:0}),{className:"number",variants:[{begin:"\\b(?:([0-9][0-9_]*)?\\.[0-9_]*(?:[eE][+-]?[0-9_]+)?|(0[Xx])?[0-9][0-9_]*\\.?[0-9_]*(?:[pP](?:[+-]?[0-9_]+)?)?)\\b",relevance:0},{begin:"\\$[0-9][0-9A-Fa-f]*",relevance:0},{begin:"\\b(?:[0-9A-Fa-f][0-9A-Fa-f_]*[Hh]|[0-9][0-9_]*[DdTt]?|[0-7][0-7_]*[QqOo]|[0-1][0-1_]*[BbYy])\\b"},{begin:"\\b(?:0[Xx][0-9A-Fa-f_]+|0[DdTt][0-9_]+|0[QqOo][0-7_]+|0[BbYy][0-1_]+)\\b"}]},s.QUOTE_STRING_MODE,{className:"string",variants:[{begin:"'",end:"[^\\\\]'"},{begin:"`",end:"[^\\\\]`"}],relevance:0},{className:"symbol",variants:[{begin:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)"},{begin:"^\\s*%%[A-Za-z0-9_$#@~.?]*:"}],relevance:0},{className:"subst",begin:"%[0-9]+",relevance:0},{className:"subst",begin:"%!S+",relevance:0},{className:"meta",begin:/^\s*\.[\w_-]+/}]}}}()); + +// mdBook's bundled highlight.js 10.1.1 (above), plus extra grammars (10.7.3) below. +// Regenerate by copying book/highlight-*.js then re-appending these registrations. +hljs.registerLanguage("ebnf",(()=>{"use strict";return e=>{ +const a=e.COMMENT(/\(\*/,/\*\)/);return{name:"Extended Backus-Naur Form", +illegal:/\S/,contains:[a,{className:"attribute", +begin:/^[ ]*[a-zA-Z]+([\s_-]+[a-zA-Z]+)*/},{begin:/=/,end:/[.;]/,contains:[a,{ +className:"meta",begin:/\?.*\?/},{className:"string", +variants:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"`",end:"`"}]}]}]}} +})()); +hljs.registerLanguage("protobuf",(()=>{"use strict";return e=>({ +name:"Protocol Buffers",keywords:{ +keyword:"package import option optional required repeated group oneof", +built_in:"double float int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 bool string bytes", +literal:"true false"}, +contains:[e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{ +className:"class",beginKeywords:"message enum service",end:/\{/,illegal:/\n/, +contains:[e.inherit(e.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},{ +className:"function",beginKeywords:"rpc",end:/[{;]/,excludeEnd:!0, +keywords:"rpc returns"},{begin:/^\s*[A-Z_]+(?=\s*=[^\n]+;$)/}]})})()); diff --git a/wiki/theme/pagetoc.js b/wiki/theme/pagetoc.js new file mode 100644 index 000000000..10660c476 --- /dev/null +++ b/wiki/theme/pagetoc.js @@ -0,0 +1,53 @@ +// Moves mdBook's generated "on this page" heading tree out of the sidebar and +// into a panel pinned to the top-right of the content area. +// +// mdBook's toc.js scans the page for h2-h6 on DOMContentLoaded and builds the +// tree, wrapped in div.on-this-page, inside the active sidebar item. This file +// is loaded after toc.js, so this listener is registered second and therefore +// runs second: the tree already exists by the time it fires. +// +// The tree is moved rather than rebuilt, which keeps mdBook's own anchor links, +// fold state and scroll-spy highlighting intact. Scroll-spy survives the move +// because toc.js looks up .header-in-summary and .current-header document-wide +// rather than scoping them to the sidebar. +// +// Below the breakpoint there is not enough room beside the content column, so +// the tree is put back in the sidebar instead of being hidden outright. +(function pageToc() { + const BREAKPOINT = '(min-width: 1500px)'; + + document.addEventListener('DOMContentLoaded', function () { + const tree = document.querySelector('.on-this-page'); + + if (tree === null) { + return; + } + + const panel = document.createElement('nav'); + panel.classList.add('pagetoc'); + panel.setAttribute('aria-label', 'On this page'); + + const title = document.createElement('div'); + title.classList.add('pagetoc-title'); + title.textContent = 'On this page'; + panel.appendChild(title); + document.body.appendChild(panel); + + // Remember where mdBook originally put the tree so it can go back. + const home = document.createComment('on-this-page'); + tree.before(home); + + const wideEnough = window.matchMedia(BREAKPOINT); + + function place() { + if (wideEnough.matches) { + panel.appendChild(tree); + } else { + home.after(tree); + } + } + + place(); + wideEnough.addEventListener('change', place); + }); +})(); diff --git a/wiki/theme/sidebar-fold.js b/wiki/theme/sidebar-fold.js new file mode 100644 index 000000000..cec66bc43 --- /dev/null +++ b/wiki/theme/sidebar-fold.js @@ -0,0 +1,198 @@ +// Sidebar fold state: per-session persistence, plus per-section defaults. +// +// mdBook persists nothing about the sidebar except its scroll offset, and each +// page is a full document load, so the fold tree resets on every navigation -- +// toc.js re-expands the ancestors of the current page and nothing else. This +// records the tree's shape whenever it changes and restores it on the next +// page, so a section the reader opened stays open as they move around. +// +// State lives in sessionStorage: it survives navigation within a tab and dies +// with it. A new tab starts from the defaults below. +// +// Setting or clearing the 'expanded' class is sufficient on its own: chrome.css +// hides folded children via .chapter li:not(.expanded) > ol and rotates the +// chevron off the same class. +(function sidebarFold() { + // Defaults for the first page loaded in a tab, matched as a substring + // against sidebar link hrefs. Directory names tend to outlive display + // labels, so they make the more durable handle. Note that toc.js rewrites + // hrefs relative to the current page, so these must stay prefix-free -- a + // leading "./" or "/" would not match on nested pages. + // + // 'aspnet/' does not match 'aspnet-core/' (the character after "aspnet" is + // "-", not "/"), so the two sections cannot be confused for one another. + // + // Expanding is one level deep: the section opens to show its groups, but + // those groups stay shut. The section is large enough that expanding it + // whole buries the rest of the book below the fold. + const EXPAND_ON_LOAD = ['aspnet-core/']; + const COLLAPSE_ON_LOAD = ['aspnet/']; + + // toc.js expands every ancestor of the current page before this runs. When + // true, a COLLAPSE_ON_LOAD section is folded shut even if it contains the + // page being viewed -- which is what "completely collapsed on load" means + // literally, at the cost of the sidebar no longer showing where you are. + // Set to false to leave the active section alone. First load only. + const COLLAPSE_EVEN_WHEN_ACTIVE = true; + + // Whether restoring saved state may re-open a section to show the current + // page. Only matters if the reader folds a section shut and then navigates + // into it by some route other than the sidebar -- the next/previous links, + // or a link in the page body. + const REVEAL_ACTIVE_PAGE = true; + + const STATE_KEY = 'sidebar-fold-state'; + + // Every node that can fold, in document order. Position is the identity + // used in storage: the sidebar markup is one constant string emitted into + // every page, so a node's index is stable across navigation. It is not + // stable across edits to SUMMARY.md, hence the size check in restore(). + // + // The pagetoc tree that toc.js builds for the current page is made of + // li.header-item, and it hangs off a div rather than a direct child ol, so + // neither the filter nor the count below can see it. + function foldables(chapter) { + return Array.from(chapter.querySelectorAll('li.chapter-item')) + .filter(li => li.querySelector(':scope > ol.section') !== null); + } + + function save(nodes, total) { + try { + sessionStorage.setItem(STATE_KEY, JSON.stringify({ + total: total, + folds: nodes.map(li => li.classList.contains('expanded') ? '1' : '0').join(''), + })); + } catch { + // Storage unavailable (file:// on some browsers, or storage + // blocked). Nothing to do: state stops persisting and every page + // falls back to the defaults, which is the old behaviour. + } + } + + // Returns true if saved state was applied. Anything unparseable or sized + // against a different SUMMARY.md is discarded rather than mapped onto the + // wrong nodes -- an edit that preserves both counts could still restore + // crooked, but the state is ephemeral and a new tab clears it. + function restore(nodes, total) { + let saved; + + try { + saved = JSON.parse(sessionStorage.getItem(STATE_KEY)); + } catch { + return false; + } + + if (saved === null + || typeof saved !== 'object' + || saved.total !== total + || typeof saved.folds !== 'string' + || saved.folds.length !== nodes.length) { + return false; + } + + nodes.forEach((li, i) => li.classList.toggle('expanded', saved.folds[i] === '1')); + return true; + } + + function revealActive(chapter) { + const active = chapter.querySelector('a.active'); + + if (active === null) { + return; + } + + for (let li = active.closest('li.chapter-item'); li !== null; li = li.parentElement.closest('li.chapter-item')) { + li.classList.add('expanded'); + } + } + + function applyDefaults(chapter) { + const topLevel = Array.from(chapter.children).filter(el => el.matches('li.chapter-item')); + + function sectionFor(marker) { + const section = topLevel.find( + li => li.querySelector('a[href*="' + marker + '"]') !== null); + + if (section === undefined) { + console.warn('sidebar-fold: no top-level section matched "' + marker + '"'); + } + + return section; + } + + for (const marker of EXPAND_ON_LOAD) { + const section = sectionFor(marker); + + if (section === undefined) { + continue; + } + + section.classList.add('expanded'); + + // Every group nested inside it closes, except the branch holding + // the current page -- revealing a section only to hide where you + // are in it would defeat the point of expanding it. + section.querySelectorAll('li.chapter-item') + .forEach(li => { + if (li.querySelector('a.active') === null) { + li.classList.remove('expanded'); + } + }); + } + + for (const marker of COLLAPSE_ON_LOAD) { + const section = sectionFor(marker); + + if (section === undefined) { + continue; + } + + if (!COLLAPSE_EVEN_WHEN_ACTIVE && section.querySelector('a.active') !== null) { + continue; + } + + section.classList.remove('expanded'); + section.querySelectorAll('li.chapter-item') + .forEach(li => li.classList.remove('expanded')); + } + } + + document.addEventListener('DOMContentLoaded', function () { + const chapter = document.querySelector('#mdbook-sidebar ol.chapter'); + + if (chapter === null) { + return; + } + + const nodes = foldables(chapter); + const total = chapter.querySelectorAll('li.chapter-item').length; + + if (restore(nodes, total)) { + if (REVEAL_ACTIVE_PAGE) { + revealActive(chapter); + } + } else { + applyDefaults(chapter); + } + + save(nodes, total); + + // Record the tree whenever the reader folds something. Listening on the + // chevrons rather than observing the subtree is deliberate: toc.js + // rewrites classes inside the sidebar on every scroll event to track + // the current heading, which a MutationObserver could not tell apart + // from a fold without filtering it back out. These handlers are + // registered after the ones toc.js installs in connectedCallback, so + // the class has already been toggled by the time they run. + // + // The pagetoc tree has chevrons of its own; they hang off li.header-item + // and are excluded here. + chapter.querySelectorAll('.chapter-fold-toggle').forEach(toggle => { + if (toggle.closest('li') === null || !toggle.closest('li').matches('li.chapter-item')) { + return; + } + + toggle.addEventListener('click', () => save(nodes, total)); + }); + }); +})(); diff --git a/wiki/wiki.msbuildproj b/wiki/wiki.msbuildproj new file mode 100644 index 000000000..857fba59f --- /dev/null +++ b/wiki/wiki.msbuildproj @@ -0,0 +1,10 @@ + + + netstandard1.0 + false + $(NoWarn);NETSDK1215 + + + + + \ No newline at end of file