diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml deleted file mode 100644 index 7642481a..00000000 --- a/.github/workflows/cla.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: 'CLA Assistant' -on: - issue_comment: - types: [created] - pull_request_target: - types: [opened, closed, synchronize] - -jobs: - cla-assistant: - runs-on: ubuntu-latest - permissions: - actions: read - checks: write - contents: write - issues: write - pull-requests: write - security-events: write - - steps: - - name: 'CLA Signature Check' - if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' - uses: cla-assistant/github-action@v2.1.3-beta - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PERSONAL_ACCESS_TOKEN : ${{ secrets.CLA_BOT_ACCESS_TOKEN }} - with: - branch: main - path-to-signatures: '.github/signatures/cla.json' - path-to-document: 'https://github.com/dotnet/aspnet-api-versioning/blob/main/docs/CLA.md' - allowlist: bot* diff --git a/asp.sln b/asp.sln index bfb4420c..31f7442c 100644 --- a/asp.sln +++ b/asp.sln @@ -11,7 +11,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "misc", "misc", "{65E93433-2 .gitattributes = .gitattributes .gitignore = .gitignore azure-pipelines.yml = azure-pipelines.yml - CONTRIBUTING.md = CONTRIBUTING.md LICENSE.txt = LICENSE.txt logo.svg = logo.svg nuget.config = nuget.config @@ -187,6 +186,10 @@ Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Common.OData.ApiExplorer.Te EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinimalOpenApiExample", "examples\AspNetCore\WebApi\MinimalOpenApiExample\MinimalOpenApiExample.csproj", "{124C18D1-F72A-4380-AE40-E7511AC16C62}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SomeODataOpenApiExample", "examples\AspNetCore\OData\SomeODataOpenApiExample\SomeODataOpenApiExample.csproj", "{94A9AF81-A7BE-4E6C-81B1-8BFF4B6E1B78}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SomeOpenApiODataWebApiExample", "examples\AspNet\OData\SomeOpenApiODataWebApiExample\SomeOpenApiODataWebApiExample.csproj", "{AC952FBF-D7DC-4DE4-AD1C-4CEA589034F5}" +EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution src\Common\test\Common.Acceptance.Tests\Common.Acceptance.Tests.projitems*{0be9efaa-3627-46fe-9861-9121ee8f0e26}*SharedItemsImports = 5 @@ -406,6 +409,14 @@ Global {124C18D1-F72A-4380-AE40-E7511AC16C62}.Debug|Any CPU.Build.0 = Debug|Any CPU {124C18D1-F72A-4380-AE40-E7511AC16C62}.Release|Any CPU.ActiveCfg = Release|Any CPU {124C18D1-F72A-4380-AE40-E7511AC16C62}.Release|Any CPU.Build.0 = Release|Any CPU + {94A9AF81-A7BE-4E6C-81B1-8BFF4B6E1B78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94A9AF81-A7BE-4E6C-81B1-8BFF4B6E1B78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94A9AF81-A7BE-4E6C-81B1-8BFF4B6E1B78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94A9AF81-A7BE-4E6C-81B1-8BFF4B6E1B78}.Release|Any CPU.Build.0 = Release|Any CPU + {AC952FBF-D7DC-4DE4-AD1C-4CEA589034F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC952FBF-D7DC-4DE4-AD1C-4CEA589034F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC952FBF-D7DC-4DE4-AD1C-4CEA589034F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC952FBF-D7DC-4DE4-AD1C-4CEA589034F5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -488,6 +499,8 @@ Global {B39C3FE5-227F-4403-B246-1277906ACF7D} = {49EA6476-901C-4D4F-8E45-98BC8A2780EB} {496A5B79-AFD2-45AC-AF9A-1CD28A7E1CDB} = {031927C1-BF12-42A9-A91D-6907E8C7F1C7} {124C18D1-F72A-4380-AE40-E7511AC16C62} = {E0E64F6F-FB0C-4534-B815-2217700B50BA} + {94A9AF81-A7BE-4E6C-81B1-8BFF4B6E1B78} = {49EA6476-901C-4D4F-8E45-98BC8A2780EB} + {AC952FBF-D7DC-4DE4-AD1C-4CEA589034F5} = {7BB01633-6E2C-4837-B618-C7F09B18E99E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {91FE116A-CEFB-4304-A8A6-CFF021C7453A} diff --git a/build/steps-ci.yml b/build/steps-ci.yml index 6509e7dd..6f245369 100644 --- a/build/steps-ci.yml +++ b/build/steps-ci.yml @@ -7,6 +7,12 @@ parameters: default: Release steps: +- task: UseDotNet@2 + displayName: Install .NET 3.1 + inputs: + packageType: sdk + version: 3.1.x + - task: UseDotNet@2 displayName: Install .NET SDK inputs: diff --git a/build/test.props b/build/test.props index 5fba8080..8983df0e 100644 --- a/build/test.props +++ b/build/test.props @@ -20,10 +20,6 @@ - - - - diff --git a/build/test.targets b/build/test.targets index 63a345f3..5c02fba6 100644 --- a/build/test.targets +++ b/build/test.targets @@ -2,14 +2,20 @@ - 6.5.1 - 5.10.3 + 6.6.0 + 5.10.3 + 6.0.4-* + 3.1.24 + + + + diff --git a/docs/CLA.md b/docs/CLA.md deleted file mode 100644 index 4c8bab1d..00000000 --- a/docs/CLA.md +++ /dev/null @@ -1,39 +0,0 @@ -### Contribution License Agreement - -This Contribution License Agreement (**"Agreement"**) is agreed to by the party signing below (**"You"**), and conveys certain license rights to the .NET Foundation (**".NET Foundation"**) for Your contributions to .NET Foundation open source projects. This Agreement is effective as of the latest signature date below. - -1. Definitions. - - **"Code"** means the computer software code, whether in human-readable or machine-executable form, that is delivered by You to .NET Foundation under this Agreement. - - **"Project"** means any of the projects owned or managed by .NET Foundation and offered under a license approved by the Open Source Initiative (www.opensource.org). - - **"Submit"** is the act of uploading, submitting, transmitting, or distributing code or other content to any Project, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of discussing and improving that Project, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Submission." - - **"Submission"** means the Code and any other copyrightable material Submitted by You, including any associated comments and documentation. - -**2. Your Submission.** You must agree to the terms of this Agreement before making a Submission to any Project. This Agreement covers any and all Submissions that You, now or in the future (except as described in Section 4 below), Submit to any Project. - -**3. Originality of Work.** You represent that each of Your Submissions is entirely Your original work. Should You wish to Submit materials that are not Your original work, You may Submit them separately to the Project if You (a) retain all copyright and license information that was in the materials as You received them, (b) in the description accompanying Your Submission, include the phrase "Submission containing materials of a third party:" followed by the names of the third party and any licenses or other restrictions of which You are aware, and (c) follow any other instructions in the Project's written guidelines concerning Submissions. - -**4. Your Employer.** References to "employer" in this Agreement include Your employer or anyone else for whom You are acting in making Your Submission, e.g. as a contractor, vendor, or agent. If Your Submission is made in the course of Your work for an employer or Your employer has intellectual property rights in Your Submission by contract or applicable law, You must secure permission from Your employer to make the Submission before signing this Agreement. In that case, the term "You" in this Agreement will refer to You and the employer collectively. If You change employers in the future and desire to Submit additional Submissions for the new employer, then You agree to sign a new Agreement and secure permission from the new employer before Submitting those Submissions. - -**5. Licenses.** - - **a. Copyright License.** You grant .NET Foundation, and those who receive the Submission directly or indirectly from .NET Foundation, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license in the Submission to reproduce, prepare derivative works of, publicly display, publicly perform, and distribute the Submission and such derivative works, and to sublicense any or all of the foregoing rights to third parties. - - **b. Patent License.** You grant .NET Foundation, and those who receive the Submission directly or indirectly from .NET Foundation, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license under Your patent claims that are necessarily infringed by the Submission or the combination of the Submission with the Project to which it was Submitted to make, have made, use, offer to sell, sell and import or otherwise dispose of the Submission alone or with the Project. - - **c. Other Rights Reserved.** Each party reserves all rights not expressly granted in this Agreement. No additional licenses or rights whatsoever (including, without limitation, any implied licenses) are granted by implication, exhaustion, estoppel or otherwise. - -**6. Representations and Warranties.** You represent that You are legally entitled to grant the above licenses. You represent that each of Your Submissions is entirely Your original work (except as You may have disclosed under Section 3). You represent that You have secured permission from Your employer to make the Submission in cases where Your Submission is made in the course of Your work for Your employer or Your employer has intellectual property rights in Your Submission by contract or applicable law. If You are signing this Agreement on behalf of Your employer, You represent and warrant that You have the necessary authority to bind the listed employer to the obligations contained in this Agreement. You are not expected to provide support for Your Submission, unless You choose to do so. UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, AND EXCEPT FOR THE WARRANTIES EXPRESSLY STATED IN SECTIONS 3, 4, AND 6, THE SUBMISSION PROVIDED UNDER THIS AGREEMENT IS PROVIDED WITHOUT WARRANTY OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY OF NONINFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. - -**7. Notice to .NET Foundation.** You agree to notify .NET Foundation in writing of any facts or circumstances of which You later become aware that would make Your representations in this Agreement inaccurate in any respect. - -**8. Information about Submissions.** You agree that contributions to Projects and information about contributions may be maintained indefinitely and disclosed publicly, including Your name and other information that You submit with Your Submission. - -**9. Governing Law/Jurisdiction.** This Agreement is governed by the laws of the State of Washington, and the parties consent to exclusive jurisdiction and venue in the federal courts sitting in King County, Washington, unless no federal subject matter jurisdiction exists, in which case the parties consent to exclusive jurisdiction and venue in the Superior Court of King County, Washington. The parties waive all defenses of lack of personal jurisdiction and forum non-conveniens. - -**10. Entire Agreement/Assignment.** This Agreement is the entire agreement between the parties, and supersedes any and all prior agreements, understandings or communications, written or oral, between the parties relating to the subject matter hereof. This Agreement may be assigned by .NET Foundation. - -By signing, You accept and agree to the terms of this Contribution License Agreement for Your present and future Submissions to .NET Foundation. \ No newline at end of file diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index af46c084..d137601f 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -18,7 +18,8 @@ If you would like to contribute to the repository, first identify the scale of w ### Submitting a pull request -You will need to sign a [Contributor License Agreement](CLA.md) when submitting your pull request. To complete the Contributor License Agreement (CLA), you will need to follow the instructions provided by the CLA bot when you send the pull request. This needs to only be done once for any .NET Foundation OSS project. +You will need to sign a [Contributor License Agreement](https://cla.dotnetfoundation.org/dotnet/aspnet-api-versioning) when submitting your pull request. To complete the Contributor License Agreement (CLA), you +will need to follow the instructions provided by the CLA bot when you send the pull request. This needs to only be done once for any .NET Foundation OSS project. If you don't know what a pull request is read this article: https://help.github.com/articles/using-pull-requests. Make sure that the repository can build and that all tests pass. Familiarize yourself with the project workflow and our coding conventions. The coding and style guidelines are described and enforced by `.editorconfig` as well as .NET Compiler Platform analyzers. These tools will automatically override your default settings without changing your environment. Violations will cause the build to fail. diff --git a/examples/AspNet/OData/AdvancedODataWebApiExample/Startup.cs b/examples/AspNet/OData/AdvancedODataWebApiExample/Startup.cs index eb139873..6cc24533 100644 --- a/examples/AspNet/OData/AdvancedODataWebApiExample/Startup.cs +++ b/examples/AspNet/OData/AdvancedODataWebApiExample/Startup.cs @@ -46,7 +46,7 @@ public void Configuration( IAppBuilder appBuilder ) // in the underlying routing system. the order of route registration is important as well. // // DO NOT use configuration.MapHttpAttributeRoutes(); - configuration.MapVersionedODataRoute( "odata", "api", modelBuilder.GetEdmModels() ); + configuration.MapVersionedODataRoute( "odata", "api", modelBuilder ); configuration.Routes.MapHttpRoute( "orders", "api/{controller}/{id}", new { id = Optional } ); configuration.Formatters.Remove( configuration.Formatters.XmlFormatter ); diff --git a/examples/AspNet/OData/OpenApiODataWebApiExample/Startup.cs b/examples/AspNet/OData/OpenApiODataWebApiExample/Startup.cs index b2be0a9c..c854a0fc 100644 --- a/examples/AspNet/OData/OpenApiODataWebApiExample/Startup.cs +++ b/examples/AspNet/OData/OpenApiODataWebApiExample/Startup.cs @@ -59,7 +59,6 @@ public void Configuration( IAppBuilder builder ) new SupplierConfiguration(), }, }; - var models = modelBuilder.GetEdmModels(); // global odata query options configuration.Count(); @@ -71,7 +70,7 @@ public void Configuration( IAppBuilder builder ) // INFO: only pass the route prefix to GetEdmModels if you want to split the models; otherwise, both routes contain all models // WHEN VERSIONING BY: query string, header, or media type - configuration.MapVersionedODataRoute( "odata", "api", models ); + configuration.MapVersionedODataRoute( "odata", "api", modelBuilder ); // WHEN VERSIONING BY: url segment // configuration.MapVersionedODataRoute( "odata-bypath", "api/v{apiVersion}", models ); diff --git a/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Book.cs b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Book.cs new file mode 100644 index 00000000..bd9fae58 --- /dev/null +++ b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Book.cs @@ -0,0 +1,31 @@ +namespace ApiVersioning.Examples; + +/// +/// Represents a book. +/// +public class Book +{ + /// + /// Gets or sets the book identifier. + /// + /// The International Standard Book Number (ISBN). + public string Id { get; set; } + + /// + /// Gets or sets the book author. + /// + /// The author of the book. + public string Author { get; set; } + + /// + /// Gets or sets the book title. + /// + /// The title of the book. + public string Title { get; set; } + + /// + /// Gets or sets the book publication year. + /// + /// The year the book was first published. + public int Published { get; set; } +} \ No newline at end of file diff --git a/examples/AspNet/OData/SomeOpenApiODataWebApiExample/BooksController.cs b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/BooksController.cs new file mode 100644 index 00000000..05dbf26b --- /dev/null +++ b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/BooksController.cs @@ -0,0 +1,61 @@ +namespace ApiVersioning.Examples; + +using Asp.Versioning; +using Microsoft.AspNet.OData.Query; +using System.Collections.Generic; +using System.Linq; +using System.Web.Http; +using System.Web.Http.Description; + +/// +/// Represents a RESTful service of books. +/// +[ApiVersion( 1.0 )] +[RoutePrefix( "api/books" )] +public class BooksController : ApiController +{ + private static readonly Book[] books = new Book[] + { + new() { Id = "9781847490599", Title = "Anna Karenina", Author = "Leo Tolstoy", Published = 1878 }, + new() { Id = "9780198800545", Title = "War and Peace", Author = "Leo Tolstoy", Published = 1869 }, + new() { Id = "9780684801520", Title = "The Great Gatsby", Author = "F. Scott Fitzgerald", Published = 1925 }, + new() { Id = "9780486280615", Title = "The Adventures of Huckleberry Finn", Author = "Mark Twain", Published = 1884 }, + new() { Id = "9780140430820", Title = "Moby Dick", Author = "Herman Melville", Published = 1851 }, + new() { Id = "9780060934347", Title = "Don Quixote", Author = "Miguel de Cervantes", Published = 1605 }, + }; + + /// + /// Gets all books. + /// + /// The current OData query options. + /// All available books. + /// The successfully retrieved books. + [HttpGet] + [Route] + [ResponseType( typeof( IEnumerable ) )] + public IHttpActionResult Get( ODataQueryOptions options ) => + Ok( options.ApplyTo( books.AsQueryable() ) ); + + /// + /// Gets a single book. + /// + /// The requested book identifier. + /// The current OData query options. + /// The requested book. + /// The book was successfully retrieved. + /// The book does not exist. + [HttpGet] + [Route( "{id}" )] + [ResponseType( typeof( Book ) )] + public IHttpActionResult Get( string id, ODataQueryOptions options ) + { + var book = books.FirstOrDefault( book => book.Id == id ); + + if ( book == null ) + { + return NotFound(); + } + + return Ok( options.ApplyTo( book, new ODataQuerySettings(), default ) ); + } +} \ No newline at end of file diff --git a/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Program.cs b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Program.cs new file mode 100644 index 00000000..ec656452 --- /dev/null +++ b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Program.cs @@ -0,0 +1,41 @@ +namespace ApiVersioning.Examples; + +using Microsoft.Owin.Hosting; +using System.Diagnostics; + +/// +/// Represents the current application. +/// +public class Program +{ + private const string Url = "http://localhost:9008/"; + private const string LaunchUrl = Url + "swagger"; + private static readonly ManualResetEvent resetEvent = new( false ); + + /// + /// The main entry point to the application. + /// + /// The arguments provided at start-up, if any. + public static void Main( string[] args ) + { + Console.CancelKeyPress += OnCancel; + + using ( WebApp.Start( Url ) ) + { + Console.WriteLine( "Content root path: " + Startup.ContentRootPath ); + Console.WriteLine( "Now listening on: " + Url ); + Console.WriteLine( "Application started. Press Ctrl+C to shut down." ); + Process.Start( LaunchUrl ); + resetEvent.WaitOne(); + } + + Console.CancelKeyPress -= OnCancel; + } + + private static void OnCancel( object sender, ConsoleCancelEventArgs e ) + { + Console.Write( "Application is shutting down..." ); + e.Cancel = true; + resetEvent.Set(); + } +} \ No newline at end of file diff --git a/examples/AspNet/OData/SomeOpenApiODataWebApiExample/SomeOpenApiODataWebApiExample.csproj b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/SomeOpenApiODataWebApiExample.csproj new file mode 100644 index 00000000..19a3f346 --- /dev/null +++ b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/SomeOpenApiODataWebApiExample.csproj @@ -0,0 +1,20 @@ + + + + net48 + bin\$(Configuration)\$(TargetFramework)\$(MSBuildThisFileName).xml + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Startup.cs b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Startup.cs new file mode 100644 index 00000000..930c793d --- /dev/null +++ b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Startup.cs @@ -0,0 +1,169 @@ +namespace ApiVersioning.Examples; + +using Asp.Versioning; +using Asp.Versioning.Conventions; +using Microsoft.AspNet.OData.Extensions; +using Microsoft.OData; +using Newtonsoft.Json.Serialization; +using Owin; +using Swashbuckle.Application; +using System.IO; +using System.Reflection; +using System.Text; +using System.Web.Http; +using System.Web.Http.Description; +using static Microsoft.AspNet.OData.Query.AllowedQueryOptions; + +/// +/// Represents the startup process for the application. +/// +public class Startup +{ + /// + /// Configures the application using the provided builder. + /// + /// The current application builder. + public void Configuration( IAppBuilder builder ) + { + var configuration = new HttpConfiguration(); + var httpServer = new HttpServer( configuration ); + + // note: this example application intentionally only illustrates the + // bare minimum configuration for OpenAPI with partial OData support. + // see the OpenAPI or OData OpenAPI examples for additional options. + + configuration.EnableDependencyInjection(); + configuration.Select(); + configuration.AddApiVersioning(); + + // note: this is required to make the default swagger json + // settings match the odata conventions applied by EnableLowerCamelCase() + configuration.Formatters.JsonFormatter.SerializerSettings.ContractResolver = + new CamelCasePropertyNamesContractResolver(); + + // NOTE: when you mix OData and non-Data controllers in Web API, it's RECOMMENDED to only use + // convention-based routing. using attribute routing may not work as expected due to limitations + // in the underlying routing system. the order of route registration is important as well. + // + // for example: + // + // configuration.MapVersionedODataRoute( "odata", "api", modelBuilder ); + // configuration.Routes.MapHttpRoute( "Default", "api/{controller}/{id}", new { id = RouteParameter.Optional } ); + // + // for more information see the advanced OData example + configuration.MapHttpAttributeRoutes(); + + // add the versioned IApiExplorer and capture the strongly-typed implementation (e.g. ODataApiExplorer vs IApiExplorer) + // note: the specified format code will format the version as "'v'major[.minor][-status]" + var apiExplorer = configuration.AddODataApiExplorer( + options => + { + // add the versioned api explorer, which also adds IApiVersionDescriptionProvider service + // note: the specified format code will format the version as "'v'major[.minor][-status]" + options.GroupNameFormat = "'v'VVV"; + + // configure query options (which cannot otherwise be configured by OData conventions) + options.QueryOptions.Controller() + .Action( c => c.Get( default ) ) + .Allow( Skip | Count ) + .AllowTop( 100 ) + .AllowOrderBy( "title", "published" ); + } ); + + configuration.EnableSwagger( + "{apiVersion}/swagger", + swagger => + { + // build a swagger document and endpoint for each discovered API version + swagger.MultipleApiVersions( + ( apiDescription, version ) => apiDescription.GetGroupName() == version, + info => + { + foreach ( var group in apiExplorer.ApiDescriptions ) + { + var description = new StringBuilder( "A sample application with some OData, OpenAPI, Swashbuckle, and API versioning." ); + + if ( group.IsDeprecated ) + { + description.Append( " This API version has been deprecated." ); + } + + if ( group.SunsetPolicy is SunsetPolicy policy ) + { + if ( policy.Date is DateTimeOffset when ) + { + description.Append( " The API will be sunset on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + + if ( policy.HasLinks ) + { + description.AppendLine(); + + for ( var i = 0; i < policy.Links.Count; i++ ) + { + var link = policy.Links[i]; + + if ( link.Type == "text/html" ) + { + description.AppendLine(); + + if ( link.Title.HasValue ) + { + description.Append( link.Title.Value ).Append( ": " ); + } + + description.Append( link.LinkTarget.OriginalString ); + } + } + } + } + + info.Version( group.Name, $"Sample API {group.ApiVersion}" ) + .Contact( c => c.Name( "Bill Mei" ).Email( "bill.mei@somewhere.com" ) ) + .Description( description.ToString() ) + .License( l => l.Name( "MIT" ).Url( "https://opensource.org/licenses/MIT" ) ) + .TermsOfService( "Shareware" ); + } + } ); + + // add a custom operation filter which documents the implicit API version parameter + swagger.OperationFilter(); + + // integrate xml comments + swagger.IncludeXmlComments( XmlCommentsFilePath ); + } ) + .EnableSwaggerUi( swagger => swagger.EnableDiscoveryUrlSelector() ); + + builder.UseWebApi( httpServer ); + } + + /// + /// Get the root content path. + /// + /// The root content path of the application. + public static string ContentRootPath + { + get + { + var app = AppDomain.CurrentDomain; + + if ( string.IsNullOrEmpty( app.RelativeSearchPath ) ) + { + return app.BaseDirectory; + } + + return app.RelativeSearchPath; + } + } + + private static string XmlCommentsFilePath + { + get + { + var fileName = typeof( Startup ).GetTypeInfo().Assembly.GetName().Name + ".xml"; + return Path.Combine( ContentRootPath, fileName ); + } + } +} \ No newline at end of file diff --git a/examples/AspNet/OData/SomeOpenApiODataWebApiExample/SwaggerDefaultValues.cs b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/SwaggerDefaultValues.cs new file mode 100644 index 00000000..67cd7607 --- /dev/null +++ b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/SwaggerDefaultValues.cs @@ -0,0 +1,46 @@ +namespace ApiVersioning.Examples; + +using Swashbuckle.Swagger; +using System.Web.Http.Description; + +/// +/// Represents the OpenAPI/Swashbuckle operation filter used to document the implicit API version parameter. +/// +/// This is only required due to bugs in the . +/// Once they are fixed and published, this class can be removed. +public class SwaggerDefaultValues : IOperationFilter +{ + /// + /// Applies the filter to the specified operation using the given context. + /// + /// The operation to apply the filter to. + /// The API schema registry. + /// The API description being filtered. + 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 ); + + // REF: https://github.com/domaindrivendev/Swashbuckle/issues/1101 + if ( parameter.description == null ) + { + parameter.description = description.Documentation; + } + + // REF: https://github.com/domaindrivendev/Swashbuckle/issues/1089 + // REF: https://github.com/domaindrivendev/Swashbuckle/pull/1090 + if ( parameter.@default == null ) + { + parameter.@default = description.ParameterDescriptor?.DefaultValue; + } + } + } +} \ No newline at end of file diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/Program.cs b/examples/AspNetCore/OData/ODataOpenApiExample/Program.cs index 72e109af..2aeeb5f0 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/Program.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/Program.cs @@ -19,6 +19,7 @@ options.Count().Select().OrderBy(); options.RouteOptions.EnableKeyInParenthesis = false; options.RouteOptions.EnableNonParenthesisForEmptyParameterFunction = true; + options.RouteOptions.EnablePropertyNameCaseInsensitive = true; options.RouteOptions.EnableQualifiedOperationCall = false; options.RouteOptions.EnableUnqualifiedOperationCall = true; } ); @@ -80,6 +81,12 @@ // Configure the HTTP request pipeline. +if ( app.Environment.IsDevelopment() ) +{ + // navigate to ~/$odata to determine whether any endpoints did not match an odata route template + app.UseODataRouteDebug(); +} + app.UseSwagger(); app.UseSwaggerUI( options => diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/V1/OrdersController.cs b/examples/AspNetCore/OData/ODataOpenApiExample/V1/OrdersController.cs index 81dc8e0c..6d5e2fbf 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/V1/OrdersController.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/V1/OrdersController.cs @@ -4,7 +4,6 @@ using Asp.Versioning; using Asp.Versioning.OData; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OData.Formatter; using Microsoft.AspNetCore.OData.Query; using Microsoft.AspNetCore.OData.Results; using Microsoft.AspNetCore.OData.Routing.Controllers; @@ -25,6 +24,7 @@ public class OrdersController : ODataController /// The requested order. /// The order was successfully retrieved. /// The order does not exist. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( Order ), Status200OK )] [ProducesResponseType( Status404NotFound )] @@ -39,6 +39,7 @@ public SingleResult Get( int key ) => /// The created order. /// The order was successfully placed. /// The order is invalid. + [HttpPost] [MapToApiVersion( 1.0 )] [Produces( "application/json" )] [ProducesResponseType( typeof( Order ), Status201Created )] @@ -93,7 +94,7 @@ public SingleResult MostExpensive( int key ) => /// The order line items. /// The line items were successfully retrieved. /// The order does not exist. - [HttpGet( "api/Orders/{key}/LineItems" )] + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataValue> ), Status200OK )] [ProducesResponseType( Status404NotFound )] diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/V1/PeopleController.cs b/examples/AspNetCore/OData/ODataOpenApiExample/V1/PeopleController.cs index 91e70e74..fa6f9aff 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/V1/PeopleController.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/V1/PeopleController.cs @@ -24,6 +24,7 @@ public class PeopleController : ODataController /// The requested person. /// The person was successfully retrieved. /// The person does not exist. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( Person ), Status200OK )] [ProducesResponseType( Status404NotFound )] @@ -62,16 +63,16 @@ public IActionResult Get( int key, ODataQueryOptions options ) [ProducesResponseType( Status404NotFound )] [EnableQuery( AllowedQueryOptions = Select )] public SingleResult MostExpensive( ODataQueryOptions options, CancellationToken ct ) => - SingleResult.Create( - new Person[] - { + SingleResult.Create( + new Person[] + { new() { - Id = 42, - FirstName = "Elon", + Id = 42, + FirstName = "Elon", LastName = "Musk", }, - }.AsQueryable() ); + }.AsQueryable() ); /// /// Gets the most expensive person. @@ -91,11 +92,11 @@ public SingleResult MostExpensive( CancellationToken ct ) => SingleResult.Create( new Person[] - { + { new() - { - Id = key, - FirstName = "John", + { + Id = key, + FirstName = "John", LastName = "Doe", }, }.AsQueryable() ); diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/V2/OrdersController.cs b/examples/AspNetCore/OData/ODataOpenApiExample/V2/OrdersController.cs index 7e67f5da..c502c0bf 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/V2/OrdersController.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/V2/OrdersController.cs @@ -23,6 +23,7 @@ public class OrdersController : ODataController /// /// All available orders. /// The successfully retrieved orders. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataValue> ), Status200OK )] [EnableQuery( MaxTop = 100, AllowedQueryOptions = Select | Top | Skip | Count )] @@ -45,7 +46,7 @@ public IQueryable Get() /// The requested order. /// The order was successfully retrieved. /// The order does not exist. - [HttpGet( "api/Orders/{key}" )] + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( Order ), Status200OK )] [ProducesResponseType( Status404NotFound )] @@ -60,6 +61,7 @@ public SingleResult Get( int key ) => /// The created order. /// The order was successfully placed. /// The order is invalid. + [HttpPost] [Produces( "application/json" )] [ProducesResponseType( typeof( Order ), Status201Created )] [ProducesResponseType( Status400BadRequest )] @@ -84,6 +86,7 @@ public IActionResult Post( [FromBody] Order order ) /// The order was successfully updated. /// The order is invalid. /// The order does not exist. + [HttpPatch] [Produces( "application/json" )] [ProducesResponseType( typeof( Order ), Status200OK )] [ProducesResponseType( Status204NoContent )] @@ -148,7 +151,7 @@ public IActionResult Rate( int key, [FromBody] ODataActionParameters parameters /// The order line items. /// The line items were successfully retrieved. /// The order does not exist. - [HttpGet( "api/Orders/{key}/LineItems" )] + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataValue> ), Status200OK )] [ProducesResponseType( Status404NotFound )] diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/V2/PeopleController.cs b/examples/AspNetCore/OData/ODataOpenApiExample/V2/PeopleController.cs index 4714cbac..08a2737d 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/V2/PeopleController.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/V2/PeopleController.cs @@ -23,6 +23,7 @@ public class PeopleController : ODataController /// The current OData query options. /// All available people. /// The successfully retrieved people. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataValue> ), Status200OK )] public IActionResult Get( ODataQueryOptions options ) @@ -83,6 +84,7 @@ public IActionResult Get( ODataQueryOptions options ) /// The requested person. /// The person was successfully retrieved. /// The person does not exist. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( Person ), Status200OK )] [ProducesResponseType( Status404NotFound )] @@ -128,7 +130,7 @@ public IActionResult Get( int key, ODataQueryOptions options ) /// The person's home address. /// The home address was successfully retrieved. /// The person does not exist. - [HttpGet( "api/People/{key}/HomeAddress" )] + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( Address ), Status200OK )] [ProducesResponseType( Status404NotFound )] diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/V3/AcmeController.cs b/examples/AspNetCore/OData/ODataOpenApiExample/V3/AcmeController.cs index 516a4e18..cb12d0ac 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/V3/AcmeController.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/V3/AcmeController.cs @@ -19,6 +19,7 @@ public class AcmeController : ODataController /// /// All available suppliers. /// The supplier successfully retrieved. + [HttpGet] [EnableQuery] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataValue ), Status200OK )] @@ -28,7 +29,7 @@ public class AcmeController : ODataController /// Gets the products associated with the supplier. /// /// The associated supplier products. - [HttpGet( "api/Acme/Products" )] + [HttpGet] [EnableQuery] public IQueryable GetProducts() => NewSupplier().Products.AsQueryable(); @@ -38,6 +39,7 @@ public class AcmeController : ODataController /// The name of the related navigation property. /// The related entity identifier. /// None + [HttpPut] [ProducesResponseType( Status204NoContent )] [ProducesResponseType( Status404NotFound )] public IActionResult CreateRef( @@ -50,6 +52,7 @@ public IActionResult CreateRef( /// The related entity identifier. /// The name of the related navigation property. /// None + [HttpDelete] [ProducesResponseType( Status204NoContent )] [ProducesResponseType( Status404NotFound )] public IActionResult DeleteRef( diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/V3/OrdersController.cs b/examples/AspNetCore/OData/ODataOpenApiExample/V3/OrdersController.cs index 1f6e15a3..90eaffd6 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/V3/OrdersController.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/V3/OrdersController.cs @@ -24,6 +24,7 @@ public class OrdersController : ODataController /// All available orders. /// Orders successfully retrieved. /// The order is invalid. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataValue> ), Status200OK )] [EnableQuery( MaxTop = 100, AllowedQueryOptions = Select | Top | Skip | Count )] @@ -46,6 +47,7 @@ public IQueryable Get() /// The requested order. /// The order was successfully retrieved. /// The order does not exist. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( Order ), Status200OK )] [ProducesResponseType( Status404NotFound )] @@ -60,6 +62,7 @@ public SingleResult Get( int key ) => /// The created order. /// The order was successfully placed. /// The order is invalid. + [HttpPost] [ProducesResponseType( typeof( Order ), Status201Created )] [ProducesResponseType( Status400BadRequest )] public IActionResult Post( [FromBody] Order order ) @@ -83,6 +86,7 @@ public IActionResult Post( [FromBody] Order order ) /// The order was successfully updated. /// The order is invalid. /// The order does not exist. + [HttpPatch] [Produces( "application/json" )] [ProducesResponseType( typeof( Order ), Status200OK )] [ProducesResponseType( Status204NoContent )] @@ -110,6 +114,7 @@ public IActionResult Patch( int key, [FromBody] Delta delta ) /// None /// The order was successfully canceled. /// The order does not exist. + [HttpDelete] [ProducesResponseType( Status204NoContent )] [ProducesResponseType( Status404NotFound )] public IActionResult Delete( int key, bool suspendOnly ) => NoContent(); @@ -120,6 +125,7 @@ public IActionResult Patch( int key, [FromBody] Delta delta ) /// The most expensive order. /// The order was successfully retrieved. /// The no orders exist. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( Order ), Status200OK )] [ProducesResponseType( Status404NotFound )] @@ -136,6 +142,7 @@ public SingleResult MostExpensive() => /// The order was successfully rated. /// The parameters are invalid. /// The order does not exist. + [HttpPost] [ProducesResponseType( Status204NoContent )] [ProducesResponseType( Status400BadRequest )] [ProducesResponseType( Status404NotFound )] @@ -157,7 +164,7 @@ public IActionResult Rate( int key, [FromBody] ODataActionParameters parameters /// The order line items. /// The line items were successfully retrieved. /// The order does not exist. - [HttpGet( "api/Orders/{key}/LineItems" )] + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataValue> ), Status200OK )] [ProducesResponseType( Status404NotFound )] diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/V3/PeopleController.cs b/examples/AspNetCore/OData/ODataOpenApiExample/V3/PeopleController.cs index 459367f7..fee451f6 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/V3/PeopleController.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/V3/PeopleController.cs @@ -24,6 +24,7 @@ public class PeopleController : ODataController /// The current OData query options. /// All available people. /// The successfully retrieved people. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataValue> ), Status200OK )] public IActionResult Get( ODataQueryOptions options ) @@ -87,6 +88,7 @@ public IActionResult Get( ODataQueryOptions options ) /// The requested person. /// The person was successfully retrieved. /// The person does not exist. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( Person ), Status200OK )] [ProducesResponseType( Status404NotFound )] @@ -121,6 +123,7 @@ public IActionResult Get( int key, ODataQueryOptions options ) /// The created person. /// The person was successfully created. /// The person was invalid. + [HttpPost] [Produces( "application/json" )] [ProducesResponseType( typeof( Person ), Status201Created )] [ProducesResponseType( Status400BadRequest )] @@ -179,7 +182,7 @@ public IActionResult Promote( int key, [FromBody] ODataActionParameters paramete /// The person's home address. /// The home address was successfully retrieved. /// The person does not exist. - [HttpGet( "api/People/{key}/HomeAddress" )] + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( Address ), Status200OK )] [ProducesResponseType( Status404NotFound )] @@ -200,7 +203,7 @@ public IActionResult GetHomeAddress( int key ) => /// The person's work address. /// The work address was successfully retrieved. /// The person does not exist. - [HttpGet( "api/People/{key}/WorkAddress" )] + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( Address ), Status200OK )] [ProducesResponseType( Status404NotFound )] diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/V3/ProductsController.cs b/examples/AspNetCore/OData/ODataOpenApiExample/V3/ProductsController.cs index 2b4dfad9..83457577 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/V3/ProductsController.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/V3/ProductsController.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OData.Deltas; using Microsoft.AspNetCore.OData.Extensions; -using Microsoft.AspNetCore.OData.Formatter; using Microsoft.AspNetCore.OData.Query; using Microsoft.AspNetCore.OData.Results; using Microsoft.AspNetCore.OData.Routing.Controllers; @@ -31,6 +30,7 @@ public class ProductsController : ODataController /// /// All available products. /// Products successfully retrieved. + [HttpGet] [EnableQuery] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataValue> ), Status200OK )] @@ -43,6 +43,7 @@ public class ProductsController : ODataController /// The requested product. /// The product was successfully retrieved. /// The product does not exist. + [HttpGet] [EnableQuery] [Produces( "application/json" )] [ProducesResponseType( typeof( Product ), Status200OK )] @@ -58,6 +59,7 @@ public SingleResult Get( int key ) => /// The product was successfully created. /// The product was successfully created. /// The product is invalid. + [HttpPost] [Produces( "application/json" )] [ProducesResponseType( typeof( Product ), Status201Created )] [ProducesResponseType( Status204NoContent )] @@ -84,6 +86,7 @@ public IActionResult Post( [FromBody] Product product ) /// The product was successfully updated. /// The product is invalid. /// The product does not exist. + [HttpPatch] [Produces( "application/json" )] [ProducesResponseType( typeof( Product ), Status200OK )] [ProducesResponseType( Status204NoContent )] @@ -113,6 +116,7 @@ public IActionResult Patch( int key, [FromBody] Delta delta ) /// The product was successfully updated. /// The product is invalid. /// The product does not exist. + [HttpPut] [Produces( "application/json" )] [ProducesResponseType( typeof( Product ), Status200OK )] [ProducesResponseType( Status204NoContent )] @@ -134,6 +138,7 @@ public IActionResult Put( int key, [FromBody] Product update ) /// The product to delete. /// None /// The product was successfully deleted. + [HttpDelete] [ProducesResponseType( Status204NoContent )] [ProducesResponseType( Status404NotFound )] public IActionResult Delete( int key ) => NoContent(); @@ -144,7 +149,7 @@ public IActionResult Put( int key, [FromBody] Product update ) /// The product identifier. /// The supplier /// The requested supplier. - [HttpGet( "api/Products/{key}/Supplier" )] + [HttpGet] [EnableQuery] [Produces( "application/json" )] [ProducesResponseType( typeof( Supplier ), Status200OK )] @@ -158,6 +163,7 @@ public SingleResult GetSupplier( int key ) => /// The product identifier. /// The name of the related navigation property. /// The supplier link. + [HttpGet] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataId ), Status200OK )] [ProducesResponseType( Status404NotFound )] @@ -181,6 +187,7 @@ public IActionResult GetRef( int key, string navigationProperty ) /// The name of the related navigation property. /// The related entity identifier. /// None + [HttpPut] [ProducesResponseType( Status204NoContent )] [ProducesResponseType( Status404NotFound )] public IActionResult CreateRef( @@ -195,6 +202,7 @@ public IActionResult CreateRef( /// The name of the related navigation property. /// The related entity identifier. /// None + [HttpDelete] [ProducesResponseType( Status204NoContent )] [ProducesResponseType( Status404NotFound )] public IActionResult DeleteRef( diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/V3/SuppliersController.cs b/examples/AspNetCore/OData/ODataOpenApiExample/V3/SuppliersController.cs index d04fc60d..67b5456b 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/V3/SuppliersController.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/V3/SuppliersController.cs @@ -5,7 +5,6 @@ using Asp.Versioning.OData; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OData.Deltas; -using Microsoft.AspNetCore.OData.Formatter; using Microsoft.AspNetCore.OData.Query; using Microsoft.AspNetCore.OData.Results; using Microsoft.AspNetCore.OData.Routing.Controllers; @@ -29,6 +28,7 @@ public class SuppliersController : ODataController /// /// All available suppliers. /// Suppliers successfully retrieved. + [HttpGet] [EnableQuery] [Produces( "application/json" )] [ProducesResponseType( typeof( ODataValue> ), Status200OK )] @@ -41,6 +41,7 @@ public class SuppliersController : ODataController /// The requested supplier. /// The supplier was successfully retrieved. /// The supplier does not exist. + [HttpGet] [EnableQuery] [Produces( "application/json" )] [ProducesResponseType( typeof( Supplier ), Status200OK )] @@ -56,6 +57,7 @@ public SingleResult Get( int key ) => /// The supplier was successfully created. /// The supplier was successfully created. /// The supplier is invalid. + [HttpPost] [Produces( "application/json" )] [ProducesResponseType( typeof( Supplier ), Status201Created )] [ProducesResponseType( Status204NoContent )] @@ -82,6 +84,7 @@ public IActionResult Post( [FromBody] Supplier supplier ) /// The supplier was successfully updated. /// The supplier is invalid. /// The supplier does not exist. + [HttpPatch] [Produces( "application/json" )] [ProducesResponseType( typeof( Supplier ), Status200OK )] [ProducesResponseType( Status204NoContent )] @@ -111,6 +114,7 @@ public IActionResult Patch( int key, [FromBody] Delta delta ) /// The supplier was successfully updated. /// The supplier is invalid. /// The supplier does not exist. + [HttpPut] [Produces( "application/json" )] [ProducesResponseType( typeof( Supplier ), Status200OK )] [ProducesResponseType( Status204NoContent )] @@ -132,6 +136,7 @@ public IActionResult Put( int key, [FromBody] Supplier update ) /// The supplier to delete. /// None /// The supplier was successfully deleted. + [HttpDelete] [ProducesResponseType( Status204NoContent )] [ProducesResponseType( Status404NotFound )] public IActionResult Delete( int key ) => NoContent(); @@ -141,7 +146,7 @@ public IActionResult Put( int key, [FromBody] Supplier update ) /// /// The supplier identifier. /// The associated supplier products. - [HttpGet( "api/Suppliers/{key}/Products" )] + [HttpGet] [EnableQuery] public IQueryable GetProducts( int key ) => suppliers.Where( s => s.Id == key ).SelectMany( s => s.Products ); @@ -153,6 +158,7 @@ public IQueryable GetProducts( int key ) => /// The name of the related navigation property. /// The related entity identifier. /// None + [HttpPut] [ProducesResponseType( Status204NoContent )] [ProducesResponseType( Status404NotFound )] public IActionResult CreateRef( @@ -167,6 +173,7 @@ public IActionResult CreateRef( /// The related entity identifier. /// The name of the related navigation property. /// None + [HttpDelete] [ProducesResponseType( Status204NoContent )] [ProducesResponseType( Status404NotFound )] public IActionResult DeleteRef( diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExample/Book.cs b/examples/AspNetCore/OData/SomeODataOpenApiExample/Book.cs new file mode 100644 index 00000000..bd9fae58 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExample/Book.cs @@ -0,0 +1,31 @@ +namespace ApiVersioning.Examples; + +/// +/// Represents a book. +/// +public class Book +{ + /// + /// Gets or sets the book identifier. + /// + /// The International Standard Book Number (ISBN). + public string Id { get; set; } + + /// + /// Gets or sets the book author. + /// + /// The author of the book. + public string Author { get; set; } + + /// + /// Gets or sets the book title. + /// + /// The title of the book. + public string Title { get; set; } + + /// + /// Gets or sets the book publication year. + /// + /// The year the book was first published. + public int Published { get; set; } +} \ No newline at end of file diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExample/BooksController.cs b/examples/AspNetCore/OData/SomeODataOpenApiExample/BooksController.cs new file mode 100644 index 00000000..6266bc41 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExample/BooksController.cs @@ -0,0 +1,61 @@ +namespace ApiVersioning.Examples; + +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using static Microsoft.AspNetCore.Http.StatusCodes; + +/// +/// Represents a RESTful service of books. +/// +[ApiVersion( 1.0 )] +[ApiController] +[Route( "api/[controller]" )] +public class BooksController : ControllerBase +{ + private static readonly Book[] books = new Book[] + { + new() { Id = "9781847490599", Title = "Anna Karenina", Author = "Leo Tolstoy", Published = 1878 }, + new() { Id = "9780198800545", Title = "War and Peace", Author = "Leo Tolstoy", Published = 1869 }, + new() { Id = "9780684801520", Title = "The Great Gatsby", Author = "F. Scott Fitzgerald", Published = 1925 }, + new() { Id = "9780486280615", Title = "The Adventures of Huckleberry Finn", Author = "Mark Twain", Published = 1884 }, + new() { Id = "9780140430820", Title = "Moby Dick", Author = "Herman Melville", Published = 1851 }, + new() { Id = "9780060934347", Title = "Don Quixote", Author = "Miguel de Cervantes", Published = 1605 }, + }; + + /// + /// Gets all books. + /// + /// The current OData query options. + /// All available books. + /// The successfully retrieved books. + [HttpGet] + [Produces( "application/json" )] + [ProducesResponseType( typeof( IEnumerable ), Status200OK )] + public IActionResult Get( ODataQueryOptions options ) => + Ok( options.ApplyTo( books.AsQueryable() ) ); + + /// + /// Gets a single book. + /// + /// The requested book identifier. + /// The current OData query options. + /// The requested book. + /// The book was successfully retrieved. + /// The book does not exist. + [HttpGet( "{id}" )] + [Produces( "application/json" )] + [ProducesResponseType( typeof( Book ), Status200OK )] + [ProducesResponseType( Status404NotFound )] + public IActionResult Get( string id, ODataQueryOptions options ) + { + var book = books.FirstOrDefault( book => book.Id == id ); + + if ( book == null ) + { + return NotFound(); + } + + return Ok( options.ApplyTo( book, new ODataQuerySettings(), default ) ); + } +} \ No newline at end of file diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExample/ConfigureSwaggerOptions.cs b/examples/AspNetCore/OData/SomeODataOpenApiExample/ConfigureSwaggerOptions.cs new file mode 100644 index 00000000..987145a1 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExample/ConfigureSwaggerOptions.cs @@ -0,0 +1,89 @@ +namespace ApiVersioning.Examples; + +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text; + +/// +/// Configures the Swagger generation options. +/// +/// This allows API versioning to define a Swagger document per API version after the +/// service has been resolved from the service container. +public class ConfigureSwaggerOptions : IConfigureOptions +{ + private readonly IApiVersionDescriptionProvider provider; + + /// + /// Initializes a new instance of the class. + /// + /// The provider used to generate Swagger documents. + public ConfigureSwaggerOptions( IApiVersionDescriptionProvider provider ) => this.provider = provider; + + /// + public void Configure( SwaggerGenOptions options ) + { + // add a swagger document for each discovered API version + // note: you might choose to skip or document deprecated API versions differently + foreach ( var description in provider.ApiVersionDescriptions ) + { + options.SwaggerDoc( description.GroupName, CreateInfoForApiVersion( description ) ); + } + } + + private static OpenApiInfo CreateInfoForApiVersion( ApiVersionDescription description ) + { + var text = new StringBuilder( "An example application with some OData, OpenAPI, Swashbuckle, and API versioning." ); + var info = new OpenApiInfo() + { + Title = "Sample API", + Version = description.ApiVersion.ToString(), + Contact = new OpenApiContact() { Name = "Bill Mei", Email = "bill.mei@somewhere.com" }, + License = new OpenApiLicense() { Name = "MIT", Url = new Uri( "https://opensource.org/licenses/MIT" ) } + }; + + if ( description.IsDeprecated ) + { + text.Append( " This API version has been deprecated." ); + } + + if ( description.SunsetPolicy is SunsetPolicy policy ) + { + if ( policy.Date is DateTimeOffset when ) + { + text.Append( " The API will be sunset on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + + if ( policy.HasLinks ) + { + text.AppendLine(); + + for ( var i = 0; i < policy.Links.Count; i++ ) + { + var link = policy.Links[i]; + + if ( link.Type == "text/html" ) + { + text.AppendLine(); + + if ( link.Title.HasValue ) + { + text.Append( link.Title.Value ).Append( ": " ); + } + + text.Append( link.LinkTarget.OriginalString ); + } + } + } + } + + info.Description = text.ToString(); + + return info; + } +} \ No newline at end of file diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExample/Program.cs b/examples/AspNetCore/OData/SomeODataOpenApiExample/Program.cs new file mode 100644 index 00000000..a20f2ae0 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExample/Program.cs @@ -0,0 +1,83 @@ +using ApiVersioning.Examples; +using Asp.Versioning; +using Asp.Versioning.Conventions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; +using static Microsoft.AspNetCore.OData.Query.AllowedQueryOptions; +using static System.Text.Json.JsonNamingPolicy; + +var builder = WebApplication.CreateBuilder( args ); + +// Add services to the container. + +// note: this example application intentionally only illustrates the +// bare minimum configuration for OpenAPI with partial OData support. +// see the OpenAPI or OData OpenAPI examples for additional options. + +builder.Services.Configure( + options => + { + // odata projection operations (ex: $select) use a dictionary, but for good + // measure set the default property naming policy for any other use cases + options.JsonSerializerOptions.PropertyNamingPolicy = CamelCase; + options.JsonSerializerOptions.DictionaryKeyPolicy = CamelCase; + } ); + +builder.Services.AddControllers() + .AddOData( options => options.Select() ); +builder.Services.AddApiVersioning() + .AddODataApiExplorer( + options => + { + // add the versioned api explorer, which also adds IApiVersionDescriptionProvider service + // note: the specified format code will format the version as "'v'major[.minor][-status]" + options.GroupNameFormat = "'v'VVV"; + + // configure query options (which cannot otherwise be configured by OData conventions) + options.QueryOptions.Controller() + .Action( c => c.Get( default ) ) + .Allow( Skip | Count ) + .AllowTop( 100 ) + .AllowOrderBy( "title", "published" ); + } ); + +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddTransient, ConfigureSwaggerOptions>(); +builder.Services.AddSwaggerGen( + options => + { + // add a custom operation filter which sets default values + options.OperationFilter(); + + var fileName = typeof( Program ).Assembly.GetName().Name + ".xml"; + var filePath = Path.Combine( AppContext.BaseDirectory, fileName ); + + // integrate xml comments + options.IncludeXmlComments( filePath ); + } ); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseSwagger(); +app.UseSwaggerUI( + options => + { + var descriptions = app.DescribeApiVersions(); + + // build a swagger endpoint for each discovered API version + foreach ( var description in descriptions ) + { + var url = $"/swagger/{description.GroupName}/swagger.json"; + var name = description.GroupName.ToUpperInvariant(); + options.SwaggerEndpoint( url, name ); + } + } ); + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExample/Properties/launchSettings.json b/examples/AspNetCore/OData/SomeODataOpenApiExample/Properties/launchSettings.json new file mode 100644 index 00000000..3c74a485 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExample/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "SomeODataOpenApiExample": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:64762;http://localhost:64763" + } + } +} \ No newline at end of file diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExample/SomeODataOpenApiExample.csproj b/examples/AspNetCore/OData/SomeODataOpenApiExample/SomeODataOpenApiExample.csproj new file mode 100644 index 00000000..9310fba4 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExample/SomeODataOpenApiExample.csproj @@ -0,0 +1,16 @@ + + + + net6.0 + true + + + + + + + + + + + \ No newline at end of file diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExample/SwaggerDefaultValues.cs b/examples/AspNetCore/OData/SomeODataOpenApiExample/SwaggerDefaultValues.cs new file mode 100644 index 00000000..34369a0f --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExample/SwaggerDefaultValues.cs @@ -0,0 +1,68 @@ +namespace ApiVersioning.Examples; + +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text.Json; + +/// +/// Represents the OpenAPI/Swashbuckle operation filter used to document the implicit API version parameter. +/// +/// This is only required due to bugs in the . +/// Once they are fixed and published, this class can be removed. +public class SwaggerDefaultValues : IOperationFilter +{ + /// + /// Applies the filter to the specified operation using the given context. + /// + /// The operation to apply the filter to. + /// The current operation filter context. + public void Apply( OpenApiOperation operation, OperationFilterContext context ) + { + var apiDescription = context.ApiDescription; + + operation.Deprecated |= apiDescription.IsDeprecated(); + + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077 + foreach ( var responseType in context.ApiDescription.SupportedResponseTypes ) + { + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387 + 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; + } + + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412 + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413 + foreach ( var parameter in operation.Parameters ) + { + var description = apiDescription.ParameterDescriptions.First( p => p.Name == parameter.Name ); + + if ( parameter.Description == null ) + { + parameter.Description = description.ModelMetadata?.Description; + } + + if ( parameter.Schema.Default == null && description.DefaultValue != null ) + { + // REF: https://github.com/Microsoft/aspnet-api-versioning/issues/429#issuecomment-605402330 + var json = JsonSerializer.Serialize( description.DefaultValue, description.ModelMetadata.ModelType ); + parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson( json ); + } + + parameter.Required |= description.IsRequired; + } + } +} \ No newline at end of file diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExample/appsettings.json b/examples/AspNetCore/OData/SomeODataOpenApiExample/appsettings.json new file mode 100644 index 00000000..03b4c0de --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExample/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs b/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs index 6d868692..41f0d97c 100644 --- a/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs +++ b/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs @@ -6,6 +6,11 @@ builder.Services.AddApiVersioning(); var app = builder.Build(); +var versionSet = app.NewApiVersionSet() + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .ReportApiVersions() + .Build(); // Configure the HTTP request pipeline. @@ -14,46 +19,43 @@ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; -app.DefineApi() - .HasApiVersion( 1.0 ) - .HasApiVersion( 2.0 ) - .ReportApiVersions() - .HasMapping( api => +// GET /weatherforecast?api-version=1.0 +app.MapGet( "/weatherforecast", () => { - // GET /weatherforecast?api-version=1.0 - api.MapGet( "/weatherforecast", () => - { - return Enumerable.Range( 1, 5 ).Select( index => - new WeatherForecast - ( - DateTime.Now.AddDays( index ), - Random.Shared.Next( -20, 55 ), - summaries[Random.Shared.Next( summaries.Length )] - ) ); - } ) - .MapToApiVersion( 1.0 ); - - // GET /weatherforecast?api-version=2.0 - api.MapGet( "/weatherforecast", () => - { - return Enumerable.Range( 0, summaries.Length ).Select( index => - new WeatherForecast - ( - DateTime.Now.AddDays( index ), - Random.Shared.Next( -20, 55 ), - summaries[Random.Shared.Next( summaries.Length )] - ) ); - } ) - .MapToApiVersion( 2.0 ); - - // POST /weatherforecast?api-version=2.0 - api.MapPost( "/weatherforecast", ( WeatherForecast forecast ) => { } ) - .MapToApiVersion( 2.0 ); - - // DELETE /weatherforecast - api.MapDelete( "/weatherforecast", () => { } ) - .IsApiVersionNeutral(); - } ); + return Enumerable.Range( 1, 5 ).Select( index => + new WeatherForecast + ( + DateTime.Now.AddDays( index ), + Random.Shared.Next( -20, 55 ), + summaries[Random.Shared.Next( summaries.Length )] + ) ); + } ) + .WithApiVersionSet( versionSet ) + .MapToApiVersion( 1.0 ); + +// GET /weatherforecast?api-version=2.0 +app.MapGet( "/weatherforecast", () => + { + return Enumerable.Range( 0, summaries.Length ).Select( index => + new WeatherForecast + ( + DateTime.Now.AddDays( index ), + Random.Shared.Next( -20, 55 ), + summaries[Random.Shared.Next( summaries.Length )] + ) ); + } ) + .WithApiVersionSet( versionSet ) + .MapToApiVersion( 2.0 ); + +// POST /weatherforecast?api-version=2.0 +app.MapPost( "/weatherforecast", ( WeatherForecast forecast ) => { } ) + .WithApiVersionSet( versionSet ) + .MapToApiVersion( 2.0 ); + +// DELETE /weatherforecast +app.MapDelete( "/weatherforecast", () => { } ) + .WithApiVersionSet( versionSet ) + .IsApiVersionNeutral(); app.Run(); diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs index 1f94073c..5e612391 100644 --- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs +++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs @@ -44,224 +44,235 @@ // Configure the HTTP request pipeline. var app = builder.Build(); +var orders = app.NewApiVersionSet( "Orders" ).Build(); +var people = app.NewApiVersionSet( "People" ).Build(); -app.DefineApi( "Orders" ) - .HasMapping( api => - { - // 1.0 - api.MapGet( "/api/orders/{id:int}", ( int id ) => new OrderV1() { Id = id, Customer = "John Doe" } ) - .Produces( response => response.Body() ) - .Produces( 404 ) - .HasDeprecatedApiVersion( 0.9 ) - .HasApiVersion( 1.0 ); +// 1.0 +app.MapGet( "/api/orders/{id:int}", ( int id ) => new OrderV1() { Id = id, Customer = "John Doe" } ) + .Produces() + .Produces( 404 ) + .WithApiVersionSet( orders ) + .HasDeprecatedApiVersion( 0.9 ) + .HasApiVersion( 1.0 ); - api.MapPost( "/api/orders", ( HttpRequest request, OrderV1 order ) => - { - order.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); - return Results.Created( location, order ); - } ) - .Accepts( request => request.Body() ) - .Produces( response => response.Body(), 201 ) - .Produces( 400 ) - .HasApiVersion( 1.0 ); +app.MapPost( "/api/orders", ( HttpRequest request, OrderV1 order ) => + { + order.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); + return Results.Created( location, order ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ) + .WithApiVersionSet( orders ) + .HasApiVersion( 1.0 ); - api.MapPatch( "/api/orders/{id:int}", ( int id, OrderV1 order ) => Results.NoContent() ) - .Accepts( request => request.Body() ) - .Produces( 204 ) - .Produces( 400 ) - .Produces( 404 ) - .HasApiVersion( 1.0 ); +app.MapMethods( "/api/orders/{id:int}", new[] { HttpMethod.Patch.Method }, ( int id, OrderV1 order ) => Results.NoContent() ) + .Accepts( "application/json" ) + .Produces( 204 ) + .Produces( 400 ) + .Produces( 404 ) + .WithApiVersionSet( orders ) + .HasApiVersion( 1.0 ); - // 2.0 - api.MapGet( "/api/orders", () => - new OrderV2[] - { +// 2.0 +app.MapGet( "/api/orders", () => + new OrderV2[] + { new(){ Id = 1, Customer = "John Doe" }, new(){ Id = 2, Customer = "Bob Smith" }, new(){ Id = 3, Customer = "Jane Doe", EffectiveDate = DateTimeOffset.UtcNow.AddDays( 7d ) }, - } ) - .Produces( response => response.Body>() ) - .Produces( 404 ) - .HasApiVersion( 2.0 ); + } ) + .Produces>() + .Produces( 404 ) + .WithApiVersionSet( orders ) + .HasApiVersion( 2.0 ); - api.MapGet( "/api/orders/{id:int}", ( int id ) => new OrderV2() { Id = id, Customer = "John Doe" } ) - .Produces( response => response.Body() ) - .Produces( 404 ) - .HasApiVersion( 2.0 ); +app.MapGet( "/api/orders/{id:int}", ( int id ) => new OrderV2() { Id = id, Customer = "John Doe" } ) + .Produces() + .Produces( 404 ) + .WithApiVersionSet( orders ) + .HasApiVersion( 2.0 ); - api.MapPost( "/api/orders", ( HttpRequest request, OrderV2 order ) => - { - order.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); - return Results.Created( location, order ); - } ) - .Accepts( request => request.Body() ) - .Produces( response => response.Body(), 201 ) - .Produces( 400 ) - .HasApiVersion( 2.0 ); +app.MapPost( "/api/orders", ( HttpRequest request, OrderV2 order ) => + { + order.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); + return Results.Created( location, order ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ) + .WithApiVersionSet( orders ) + .HasApiVersion( 2.0 ); - api.MapPatch( "/api/orders/{id:int}", ( int id, OrderV2 order ) => Results.NoContent() ) - .Accepts( request => request.Body() ) - .Produces( 204 ) - .Produces( 400 ) - .Produces( 404 ) - .HasApiVersion( 2.0 ); +app.MapMethods( "/api/orders/{id:int}", new[] { HttpMethod.Patch.Method }, ( int id, OrderV2 order ) => Results.NoContent() ) + .Accepts( "application/json" ) + .Produces( 204 ) + .Produces( 400 ) + .Produces( 404 ) + .WithApiVersionSet( orders ) + .HasApiVersion( 2.0 ); - // 3.0 - api.MapGet( "/api/orders", () => - new OrderV3[] - { +// 3.0 +app.MapGet( "/api/orders", () => + new OrderV3[] + { new(){ Id = 1, Customer = "John Doe" }, new(){ Id = 2, Customer = "Bob Smith" }, new(){ Id = 3, Customer = "Jane Doe", EffectiveDate = DateTimeOffset.UtcNow.AddDays( 7d ) }, - } ) - .Produces( response => response.Body>() ) - .HasApiVersion( 3.0 ); + } ) + .Produces>() + .WithApiVersionSet( orders ) + .HasApiVersion( 3.0 ); - api.MapGet( "/api/orders/{id:int}", ( int id ) => new OrderV3() { Id = id, Customer = "John Doe" } ) - .Produces( response => response.Body() ) - .Produces( 404 ) - .HasApiVersion( 3.0 ); +app.MapGet( "/api/orders/{id:int}", ( int id ) => new OrderV3() { Id = id, Customer = "John Doe" } ) + .Produces() + .Produces( 404 ) + .WithApiVersionSet( orders ) + .HasApiVersion( 3.0 ); - api.MapPost( "/api/orders", ( HttpRequest request, OrderV3 order ) => - { - order.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); - return Results.Created( location, order ); - } ) - .Accepts( request => request.Body() ) - .Produces( response => response.Body(), 201 ) - .Produces( 400 ) - .HasApiVersion( 3.0 ); +app.MapPost( "/api/orders", ( HttpRequest request, OrderV3 order ) => + { + order.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); + return Results.Created( location, order ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ) + .WithApiVersionSet( orders ) + .HasApiVersion( 3.0 ); - api.MapDelete( "/api/orders/{id:int}", ( int id ) => Results.NoContent() ) - .Produces( 204 ) - .HasApiVersion( 3.0 ); - } ); +app.MapDelete( "/api/orders/{id:int}", ( int id ) => Results.NoContent() ) + .Produces( 204 ) + .WithApiVersionSet( orders ) + .HasApiVersion( 3.0 ); -app.DefineApi( "People" ) - .HasMapping( api => - { - // 1.0 - api.MapGet( "/api/v{version:apiVersion}/people/{id:int}", ( int id ) => - new PersonV1() - { - Id = id, - FirstName = "John", - LastName = "Doe", - } ) - .Produces( response => response.Body() ) - .Produces( 404 ) - .HasDeprecatedApiVersion( 0.9 ) - .HasApiVersion( 1.0 ); +// 1.0 +app.MapGet( "/api/v{version:apiVersion}/people/{id:int}", ( int id ) => + new PersonV1() + { + Id = id, + FirstName = "John", + LastName = "Doe", + } ) + .Produces() + .Produces( 404 ) + .WithApiVersionSet( people ) + .HasDeprecatedApiVersion( 0.9 ) + .HasApiVersion( 1.0 ); - // 2.0 - api.MapGet( "/api/v{version:apiVersion}/people", () => - new PersonV2[] - { - new() - { - Id = 1, - FirstName = "John", - LastName = "Doe", - Email = "john.doe@somewhere.com", - }, - new() - { - Id = 2, - FirstName = "Bob", - LastName = "Smith", - Email = "bob.smith@somewhere.com", - }, - new() - { - Id = 3, - FirstName = "Jane", - LastName = "Doe", - Email = "jane.doe@somewhere.com", - }, - } ) - .Produces( response => response.Body>() ) - .HasApiVersion( 2.0 ); +// 2.0 +app.MapGet( "/api/v{version:apiVersion}/people", () => + new PersonV2[] + { + new() + { + Id = 1, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@somewhere.com", + }, + new() + { + Id = 2, + FirstName = "Bob", + LastName = "Smith", + Email = "bob.smith@somewhere.com", + }, + new() + { + Id = 3, + FirstName = "Jane", + LastName = "Doe", + Email = "jane.doe@somewhere.com", + }, + } ) + .Produces>() + .WithApiVersionSet( people ) + .HasApiVersion( 2.0 ); - api.MapGet( "/api/v{version:apiVersion}/people/{id:int}", ( int id ) => - new PersonV2() - { - Id = id, - FirstName = "John", - LastName = "Doe", - Email = "john.doe@somewhere.com", - } ) - .Produces( response => response.Body() ) - .Produces( 404 ) - .HasApiVersion( 2.0 ); +app.MapGet( "/api/v{version:apiVersion}/people/{id:int}", ( int id ) => + new PersonV2() + { + Id = id, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@somewhere.com", + } ) + .Produces() + .Produces( 404 ) + .WithApiVersionSet( people ) + .HasApiVersion( 2.0 ); - // 3.0 - api.MapGet( "/api/v{version:apiVersion}/people", () => - new PersonV3[] - { - new() - { - Id = 1, - FirstName = "John", - LastName = "Doe", - Email = "john.doe@somewhere.com", - Phone = "555-987-1234", - }, - new() - { - Id = 2, - FirstName = "Bob", - LastName = "Smith", - Email = "bob.smith@somewhere.com", - Phone = "555-654-4321", - }, - new() - { - Id = 3, - FirstName = "Jane", - LastName = "Doe", - Email = "jane.doe@somewhere.com", - Phone = "555-789-3456", - }, - } ) - .Produces( response => response.Body>() ) - .HasApiVersion( 3.0 ); +// 3.0 +app.MapGet( "/api/v{version:apiVersion}/people", () => + new PersonV3[] + { + new() + { + Id = 1, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@somewhere.com", + Phone = "555-987-1234", + }, + new() + { + Id = 2, + FirstName = "Bob", + LastName = "Smith", + Email = "bob.smith@somewhere.com", + Phone = "555-654-4321", + }, + new() + { + Id = 3, + FirstName = "Jane", + LastName = "Doe", + Email = "jane.doe@somewhere.com", + Phone = "555-789-3456", + }, + } ) + .Produces>() + .WithApiVersionSet( people ) + .HasApiVersion( 3.0 ); - api.MapGet( "/api/v{version:apiVersion}/people/{id:int}", ( int id ) => - new PersonV3() - { - Id = id, - FirstName = "John", - LastName = "Doe", - Email = "john.doe@somewhere.com", - Phone = "555-987-1234", - } ) - .Produces( response => response.Body() ) - .Produces( 404 ) - .HasApiVersion( 3.0 ); +app.MapGet( "/api/v{version:apiVersion}/people/{id:int}", ( int id ) => + new PersonV3() + { + Id = id, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@somewhere.com", + Phone = "555-987-1234", + } ) + .Produces() + .Produces( 404 ) + .WithApiVersionSet( people ) + .HasApiVersion( 3.0 ); - api.MapPost( "/api/v{version:apiVersion}/people", ( HttpRequest request, PersonV3 person ) => - { - person.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var version = request.HttpContext.GetRequestedApiVersion(); - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/v{version}/api/people/{person.Id}" ); - return Results.Created( location, person ); - } ) - .Accepts( request => request.Body() ) - .Produces( response => response.Body(), 201 ) - .Produces( 400 ) - .HasApiVersion( 3.0 ); - } ); +app.MapPost( "/api/v{version:apiVersion}/people", ( HttpRequest request, PersonV3 person ) => + { + person.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var version = request.HttpContext.GetRequestedApiVersion(); + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/v{version}/api/people/{person.Id}" ); + return Results.Created( location, person ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ) + .WithApiVersionSet( people ) + .HasApiVersion( 3.0 ); app.UseSwagger(); app.UseSwaggerUI( diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersion.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersion.cs index 7aaecb82..d6e68873 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersion.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersion.cs @@ -57,13 +57,24 @@ private ApiVersion() /// The version number. /// The optional version status. public ApiVersion( double version, string? status = default ) + : this( version, status, IsValidStatus ) { } + + /// + /// Initializes a new instance of the class. + /// + /// The version number. + /// The optional version status. + /// The function used to valid status. + protected ApiVersion( double version, string? status, Func isValidStatus ) { if ( version < 0d || double.IsNaN( version ) || double.IsInfinity( version ) ) { throw new ArgumentOutOfRangeException( nameof( version ) ); } - Status = ValidateStatus( status ); + Status = ValidateStatus( + status, + isValidStatus ?? throw new ArgumentNullException( nameof( isValidStatus ) ) ); var number = new decimal( version ); var bits = decimal.GetBits( number ); @@ -75,7 +86,21 @@ private ApiVersion() MinorVersion = minor; } - internal ApiVersion( DateOnly? groupVersion, int? majorVersion, int? minorVersion, string? status ) + /// + /// Initializes a new instance of the class. + /// + /// The optional group version. + /// The optional major version. + /// The optional minor version. + /// The optional version status. + /// The optional function used to valid status. + /// The default value is . + protected internal ApiVersion( + DateOnly? groupVersion, + int? majorVersion, + int? minorVersion, + string? status, + Func? isValidStatus = default ) { if ( majorVersion.HasValue && majorVersion.Value < 0 ) { @@ -87,7 +112,7 @@ internal ApiVersion( DateOnly? groupVersion, int? majorVersion, int? minorVersio throw new ArgumentOutOfRangeException( nameof( minorVersion ) ); } - Status = ValidateStatus( status ); + Status = ValidateStatus( status, isValidStatus ?? IsValidStatus ); GroupVersion = groupVersion; MajorVersion = majorVersion; MinorVersion = minorVersion; @@ -303,14 +328,9 @@ public virtual string ToString( string? format, IFormatProvider? formatProvider #pragma warning restore CA1062 // Validate arguments of public methods } - private static string? ValidateStatus( string? status ) + private static string? ValidateStatus( string? status, Func isValid ) { - if ( status is not null && status.Length == 0 ) - { - return default; - } - - if ( IsValidStatus( status ) ) + if ( isValid( status ) ) { return status; } diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyManager.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyManager.cs index c384574c..77a175a7 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyManager.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyManager.cs @@ -22,7 +22,7 @@ bool TryGetPolicy( string? name, ApiVersion? apiVersion, #if !NETSTANDARD - [MaybeNullWhen(false)] + [MaybeNullWhen( false )] #endif out SunsetPolicy sunsetPolicy ); } \ No newline at end of file diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Asp.Versioning.WebApi.Acceptance.Tests.csproj b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Asp.Versioning.WebApi.Acceptance.Tests.csproj index d42a94b5..4a958c4f 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Asp.Versioning.WebApi.Acceptance.Tests.csproj +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Asp.Versioning.WebApi.Acceptance.Tests.csproj @@ -1,7 +1,7 @@  - net452 + net452;net472 Asp.Versioning diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs index b9166b80..5fcfaac1 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs @@ -7,6 +7,7 @@ namespace Asp.Versioning.ApiExplorer; using Asp.Versioning.OData; using Asp.Versioning.Routing; using Microsoft.AspNet.OData; +using Microsoft.AspNet.OData.Extensions; using Microsoft.AspNet.OData.Formatter; using Microsoft.AspNet.OData.Routing; using Microsoft.AspNet.OData.Routing.Template; @@ -63,15 +64,12 @@ public ODataApiExplorer( HttpConfiguration configuration, ODataApiExplorerOption protected virtual IModelTypeBuilder ModelTypeBuilder => modelTypeBuilder ??= Configuration.DependencyResolver.GetModelTypeBuilder(); - /// - /// Determines whether the action should be considered. - /// - /// The action route parameter value. - /// The associated action descriptor. - /// The associated route. - /// The API version to consider the controller for. - /// True if the action should be explored; otherwise, false. - protected override bool ShouldExploreAction( string actionRouteParameterValue, HttpActionDescriptor actionDescriptor, IHttpRoute route, ApiVersion apiVersion ) + /// + protected override bool ShouldExploreAction( + string actionRouteParameterValue, + HttpActionDescriptor actionDescriptor, + IHttpRoute route, + ApiVersion apiVersion ) { if ( actionDescriptor == null ) { @@ -96,15 +94,12 @@ protected override bool ShouldExploreAction( string actionRouteParameterValue, H return actionDescriptor.GetApiVersionMetadata().IsMappedTo( apiVersion ); } - /// - /// Determines whether the controller should be considered. - /// - /// The controller route parameter value. - /// The associated controller descriptor. - /// The associated route. - /// The API version to consider the controller for. - /// True if the controller should be explored; otherwise, false. - protected override bool ShouldExploreController( string controllerRouteParameterValue, HttpControllerDescriptor controllerDescriptor, IHttpRoute route, ApiVersion apiVersion ) + /// + protected override bool ShouldExploreController( + string controllerRouteParameterValue, + HttpControllerDescriptor controllerDescriptor, + IHttpRoute route, + ApiVersion apiVersion ) { if ( controllerDescriptor == null ) { @@ -141,26 +136,26 @@ protected override bool ShouldExploreController( string controllerRouteParameter return true; } - /// - /// Explores controllers that do not use direct routes (aka "attribute" routing). - /// - /// The collection of controller mappings. - /// The route to explore. - /// The API version to explore. - /// The collection of discovered API descriptions. - protected override Collection ExploreRouteControllers( IDictionary controllerMappings, IHttpRoute route, ApiVersion apiVersion ) + /// + protected override Collection ExploreRouteControllers( + IDictionary controllerMappings, + IHttpRoute route, + ApiVersion apiVersion ) { if ( controllerMappings == null ) { throw new ArgumentNullException( nameof( controllerMappings ) ); } + Collection apiDescriptions; + if ( route is not ODataRoute ) { - return base.ExploreRouteControllers( controllerMappings, route, apiVersion ); + apiDescriptions = base.ExploreRouteControllers( controllerMappings, route, apiVersion ); + return ExploreQueryOptions( route, apiDescriptions ); } - var apiDescriptions = new Collection(); + apiDescriptions = new(); var modelSelector = Configuration.GetODataRootContainer( route ).GetRequiredService(); var edmModel = modelSelector.SelectModel( apiVersion ); @@ -184,9 +179,18 @@ protected override Collection ExploreRouteControllers( } } - ExploreQueryOptions( apiDescriptions, Configuration.GetODataRootContainer( route ).GetRequiredService() ); + return ExploreQueryOptions( route, apiDescriptions ); + } - return apiDescriptions; + /// + protected override Collection ExploreDirectRouteControllers( + HttpControllerDescriptor controllerDescriptor, + IReadOnlyList candidateActionDescriptors, + IHttpRoute route, + ApiVersion apiVersion ) + { + var apiDescriptions = base.ExploreDirectRouteControllers( controllerDescriptor, candidateActionDescriptors, route, apiVersion ); + return ExploreQueryOptions( route, apiDescriptions ); } /// @@ -194,7 +198,9 @@ protected override Collection ExploreRouteControllers( /// /// The sequence of API descriptions to explore. /// The associated OData URI resolver. - protected virtual void ExploreQueryOptions( IEnumerable apiDescriptions, ODataUriResolver uriResolver ) + protected virtual void ExploreQueryOptions( + IEnumerable apiDescriptions, + ODataUriResolver uriResolver ) { if ( uriResolver == null ) { @@ -206,12 +212,32 @@ protected virtual void ExploreQueryOptions( IEnumerable { NoDollarPrefix = uriResolver.EnableNoDollarQueryOptions, DescriptionProvider = queryOptions.DescriptionProvider, + DefaultQuerySettings = Configuration.GetDefaultQuerySettings(), }; queryOptions.ApplyTo( apiDescriptions, settings ); } - private ResponseDescription CreateResponseDescriptionWithRoute( HttpActionDescriptor actionDescriptor, IHttpRoute route, ApiVersion apiVersion ) + private Collection ExploreQueryOptions( + IHttpRoute route, + Collection apiDescriptions ) + { + if ( apiDescriptions.Count == 0 ) + { + return apiDescriptions; + } + + var uriResolver = Configuration.GetODataRootContainer( route ).GetRequiredService(); + + ExploreQueryOptions( apiDescriptions, uriResolver ); + + return apiDescriptions; + } + + private ResponseDescription CreateResponseDescriptionWithRoute( + HttpActionDescriptor actionDescriptor, + IHttpRoute route, + ApiVersion apiVersion ) { var description = CreateResponseDescription( actionDescriptor ); var serviceProvider = actionDescriptor.Configuration.GetODataRootContainer( route ); @@ -347,7 +373,10 @@ private static bool WillReadUri( HttpParameterBinding parameterBinding ) return willReadUri; } - private ApiParameterDescription CreateParameterDescriptionFromBinding( HttpParameterBinding parameterBinding, IServiceProvider serviceProvider, ApiVersion apiVersion ) + private ApiParameterDescription CreateParameterDescriptionFromBinding( + HttpParameterBinding parameterBinding, + IServiceProvider serviceProvider, + ApiVersion apiVersion ) { var descriptor = parameterBinding.Descriptor; var description = CreateParameterDescription( descriptor ); @@ -378,7 +407,10 @@ private ApiParameterDescription CreateParameterDescriptionFromBinding( HttpParam return description; } - private IList CreateParameterDescriptions( HttpActionDescriptor actionDescriptor, IHttpRoute route, ApiVersion apiVersion ) + private IList CreateParameterDescriptions( + HttpActionDescriptor actionDescriptor, + IHttpRoute route, + ApiVersion apiVersion ) { var list = new List(); var actionBinding = GetActionBinding( actionDescriptor ); @@ -422,7 +454,8 @@ private IList CreateParameterDescriptions( HttpActionDe return list; } - private static IEnumerable GetInnerFormatters( IEnumerable mediaTypeFormatters ) => mediaTypeFormatters.Select( Decorator.GetInner ); + private static IEnumerable GetInnerFormatters( IEnumerable mediaTypeFormatters ) => + mediaTypeFormatters.Select( Decorator.GetInner ); private static void PopulateMediaTypeFormatters( HttpActionDescriptor actionDescriptor, diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs index c1801aba..04f8cf6d 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs @@ -16,6 +16,18 @@ private static Type GetController( ApiDescription apiDescription ) => apiDescription.ActionDescriptor.ControllerDescriptor.ControllerType; [MethodImpl( MethodImplOptions.AggressiveInlining )] - private static bool IsODataLike( ApiDescription description ) => - description.ActionDescriptor.GetCustomAttributes( inherit: true ).Count > 0; + private static bool IsODataLike( ApiDescription description ) + { + var parameters = description.ParameterDescriptions; + + for ( var i = 0; i < parameters.Count; i++ ) + { + if ( parameters[i].ParameterDescriptor.ParameterType.IsODataQueryOptions() ) + { + return true; + } + } + + return false; + } } \ No newline at end of file diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/System.Web.Http/HttpConfigurationExtensions.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/System.Web.Http/HttpConfigurationExtensions.cs index 082c2923..d47fdb70 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/System.Web.Http/HttpConfigurationExtensions.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/System.Web.Http/HttpConfigurationExtensions.cs @@ -4,6 +4,7 @@ namespace System.Web.Http; using Asp.Versioning; using Asp.Versioning.ApiExplorer; +using Microsoft.AspNet.OData.Routing; using Microsoft.OData; using System.Collections.Concurrent; using System.Web.Http.Description; @@ -15,6 +16,7 @@ namespace System.Web.Http; public static class HttpConfigurationExtensions { private const string RootContainerMappingsKey = "Microsoft.AspNet.OData.RootContainerMappingsKey"; + private const string NonODataRootContainerKey = "Microsoft.AspNet.OData.NonODataRootContainerKey"; private const string UrlKeyDelimiterKey = "Microsoft.AspNet.OData.UrlKeyDelimiterKey"; /// @@ -69,7 +71,8 @@ private static ODataApiExplorer AddODataApiExplorer( this HttpConfiguration conf internal static IServiceProvider GetODataRootContainer( this HttpConfiguration configuration, IHttpRoute route ) { - var containers = (ConcurrentDictionary) configuration.Properties.GetOrAdd( RootContainerMappingsKey, key => new ConcurrentDictionary() ); + var properties = configuration.Properties; + var containers = (ConcurrentDictionary) properties.GetOrAdd( RootContainerMappingsKey, key => new ConcurrentDictionary() ); var routeName = configuration.Routes.GetRouteName( route ); if ( !string.IsNullOrEmpty( routeName ) && containers.TryGetValue( routeName!, out var serviceProvider ) ) @@ -77,6 +80,13 @@ internal static IServiceProvider GetODataRootContainer( this HttpConfiguration c return serviceProvider; } + if ( route is not ODataRoute && + properties.TryGetValue( NonODataRootContainerKey, out var value ) && + ( serviceProvider = value as IServiceProvider ) is not null ) + { + return serviceProvider; + } + throw new InvalidOperationException( ODataExpSR.NullContainer ); } diff --git a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs index 01473da8..1a255dad 100644 --- a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs +++ b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs @@ -3,6 +3,8 @@ namespace Asp.Versioning.Conventions; using Asp.Versioning.Description; +using Asp.Versioning.Simulators.Models; +using Asp.Versioning.Simulators.V1; using Microsoft.AspNet.OData; using Microsoft.AspNet.OData.Builder; using Microsoft.AspNet.OData.Extensions; @@ -10,6 +12,7 @@ namespace Asp.Versioning.Conventions; using Microsoft.OData.Edm; using System.Collections.ObjectModel; using System.Net.Http; +using System.Reflection; using System.Web.Http; using System.Web.Http.Controllers; using System.Web.Http.Description; @@ -457,6 +460,97 @@ public void apply_to_should_use_model_bound_query_attributes() options => options.ExcludingMissingMembers() ); } + [Fact] + public void apply_to_should_process_odataX2Dlike_api_description() + { + // arrange + var controllerType = typeof( BooksController ); + var controllerName = controllerType.Name.Substring( 0, controllerType.Name.Length - 10 ); + var action = controllerType.GetRuntimeMethods() + .First( m => m.Name == "Get" && m.GetParameters().Length == 1 ); + var configuration = new HttpConfiguration(); + var controllerDescriptor = new HttpControllerDescriptor( configuration, controllerName, controllerType ); + var actionDescriptor = new ReflectedHttpActionDescriptor( controllerDescriptor, action ) { Configuration = configuration }; + var parameter = actionDescriptor.GetParameters()[0]; + var description = new VersionedApiDescription() + { + ActionDescriptor = actionDescriptor, + HttpMethod = HttpMethod.Get, + ParameterDescriptions = + { + new() + { + Name = parameter.ParameterName, + ParameterDescriptor = parameter, + Source = Unknown, + }, + }, + ResponseDescription = new() { ResponseType = typeof( IEnumerable ) }, + }; + var builder = new ODataQueryOptionsConventionBuilder(); + var settings = new ODataQueryOptionSettings() + { + DescriptionProvider = builder.DescriptionProvider, + DefaultQuerySettings = new(), + }; + + configuration.EnableDependencyInjection(); + builder.Controller() + .Action( c => c.Get( default ) ) + .Allow( Select | Count ) + .AllowOrderBy( "title", "published" ); + + // act + builder.ApplyTo( new[] { description }, settings ); + + // assert + description.ParameterDescriptions.RemoveAt( 0 ); + description.ParameterDescriptions.Should().BeEquivalentTo( + new[] + { + new + { + Name = "$select", + Source = FromUri, + ParameterDescriptor = new + { + ParameterName = "$select", + ParameterType = typeof( string ), + Prefix = "$", + IsOptional = true, + DefaultValue = default( object ), + }, + }, + new + { + Name = "$orderby", + Source = FromUri, + ParameterDescriptor = new + { + ParameterName = "$orderby", + ParameterType = typeof( string ), + Prefix = "$", + IsOptional = true, + DefaultValue = default( object ), + }, + }, + new + { + Name = "$count", + Source = FromUri, + ParameterDescriptor = new + { + ParameterName = "$count", + ParameterType = typeof( bool ), + Prefix = "$", + IsOptional = true, + DefaultValue = (object) false, + }, + }, + }, + options => options.ExcludingMissingMembers() ); + } + public static IEnumerable EnableQueryAttributeData { get @@ -495,7 +589,8 @@ private static ApiDescription NewApiDescription( Type controllerType ) => private static ApiDescription NewApiDescription( Type controllerType, Type responseType, IEdmModel model ) { var configuration = new HttpConfiguration(); - var controllerDescriptor = new HttpControllerDescriptor( configuration, "Orders", controllerType ); + var controllerName = controllerType.Name.Substring( 0, controllerType.Name.Length - 10 ); + var controllerDescriptor = new HttpControllerDescriptor( configuration, controllerName, controllerType ); var method = controllerType.GetMethod( "Get" ); var actionDescriptor = new ReflectedHttpActionDescriptor( controllerDescriptor, method ) { Configuration = configuration }; diff --git a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/Models/Book.cs b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/Models/Book.cs new file mode 100644 index 00000000..404bf071 --- /dev/null +++ b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/Models/Book.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Simulators.Models; + +public class Book +{ + public string Id { get; set; } + + public string Author { get; set; } + + public string Title { get; set; } + + public int Published { get; set; } +} \ No newline at end of file diff --git a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/V1/BooksController.cs b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/V1/BooksController.cs new file mode 100644 index 00000000..e6e24820 --- /dev/null +++ b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/V1/BooksController.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Simulators.V1; + +using Asp.Versioning; +using Asp.Versioning.Simulators.Models; +using Microsoft.AspNet.OData.Query; +using System.Collections.Generic; +using System.Linq; +using System.Web.Http; +using System.Web.Http.Description; + +/// +/// Represents a RESTful service of books. +/// +[ApiVersion( 1.0 )] +public class BooksController : ApiController +{ + private static readonly Book[] books = new Book[] + { + new() { Id = "9781847490599", Title = "Anna Karenina", Author = "Leo Tolstoy", Published = 1878 }, + new() { Id = "9780198800545", Title = "War and Peace", Author = "Leo Tolstoy", Published = 1869 }, + new() { Id = "9780684801520", Title = "The Great Gatsby", Author = "F. Scott Fitzgerald", Published = 1925 }, + new() { Id = "9780486280615", Title = "The Adventures of Huckleberry Finn", Author = "Mark Twain", Published = 1884 }, + new() { Id = "9780140430820", Title = "Moby Dick", Author = "Herman Melville", Published = 1851 }, + new() { Id = "9780060934347", Title = "Don Quixote", Author = "Miguel de Cervantes", Published = 1605 }, + }; + + /// + /// Gets all books. + /// + /// The current OData query options. + /// All available books. + /// The successfully retrieved books. + [HttpGet] + [ResponseType( typeof( IEnumerable ) )] + public IHttpActionResult Get( ODataQueryOptions options ) => + Ok( options.ApplyTo( books.AsQueryable() ) ); + + /// + /// Gets a single book. + /// + /// The requested book identifier. + /// The current OData query options. + /// The requested book. + /// The book was successfully retrieved. + /// The book does not exist. + [HttpGet] + [ResponseType( typeof( Book ) )] + public IHttpActionResult Get( string id, ODataQueryOptions options ) + { + var book = books.FirstOrDefault( book => book.Id == id ); + + if ( book == null ) + { + return NotFound(); + } + + return Ok( options.ApplyTo( book, new ODataQuerySettings(), default ) ); + } +} \ No newline at end of file diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ApiExplorer/VersionedApiExplorer.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ApiExplorer/VersionedApiExplorer.cs index b1b2c03a..2514a470 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ApiExplorer/VersionedApiExplorer.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ApiExplorer/VersionedApiExplorer.cs @@ -37,7 +37,8 @@ public class VersionedApiExplorer : IApiExplorer /// Initializes a new instance of the class. /// /// The current HTTP configuration. - public VersionedApiExplorer( HttpConfiguration configuration ) : this( configuration, new ApiExplorerOptions( configuration ) ) { } + public VersionedApiExplorer( HttpConfiguration configuration ) + : this( configuration, new ApiExplorerOptions( configuration ) ) { } /// /// Initializes a new instance of the class. @@ -109,7 +110,9 @@ protected ISunsetPolicyManager SunsetPolicyManager /// The associated route. /// The action descriptor to get the HTTP methods for. /// A collection of HTTP method. - protected virtual Collection GetHttpMethodsSupportedByAction( IHttpRoute route, HttpActionDescriptor actionDescriptor ) + protected virtual Collection GetHttpMethodsSupportedByAction( + IHttpRoute route, + HttpActionDescriptor actionDescriptor ) { if ( route == null ) { @@ -132,7 +135,11 @@ protected virtual Collection GetHttpMethodsSupportedByAction( IHttpR /// The associated route. /// The API version to consider the controller for. /// True if the action should be explored; otherwise, false. - protected virtual bool ShouldExploreAction( string actionRouteParameterValue, HttpActionDescriptor actionDescriptor, IHttpRoute route, ApiVersion apiVersion ) + protected virtual bool ShouldExploreAction( + string actionRouteParameterValue, + HttpActionDescriptor actionDescriptor, + IHttpRoute route, + ApiVersion apiVersion ) { if ( actionDescriptor == null ) { @@ -162,7 +169,11 @@ protected virtual bool ShouldExploreAction( string actionRouteParameterValue, Ht /// The associated route. /// The API version to consider the controller for. /// True if the controller should be explored; otherwise, false. - protected virtual bool ShouldExploreController( string controllerRouteParameterValue, HttpControllerDescriptor controllerDescriptor, IHttpRoute route, ApiVersion apiVersion ) + protected virtual bool ShouldExploreController( + string controllerRouteParameterValue, + HttpControllerDescriptor controllerDescriptor, + IHttpRoute route, + ApiVersion apiVersion ) { if ( controllerDescriptor == null ) { @@ -546,7 +557,9 @@ private IEnumerable FlattenApiVersions( IDictionary controllerDescriptors, Type controllerType ) + private static HttpControllerDescriptor? FindControllerDescriptor( + IEnumerable controllerDescriptors, + Type controllerType ) { foreach ( var controllerDescriptor in controllerDescriptors ) { @@ -680,7 +693,10 @@ protected virtual Collection ExploreDirectRouteControll /// The route to explore. /// The API version to explore. /// The collection of discovered API descriptions. - protected virtual Collection ExploreRouteControllers( IDictionary controllerMappings, IHttpRoute route, ApiVersion apiVersion ) + protected virtual Collection ExploreRouteControllers( + IDictionary controllerMappings, + IHttpRoute route, + ApiVersion apiVersion ) { if ( controllerMappings == null ) { @@ -930,7 +946,10 @@ private static Type GetCollectionElementType( Type collectionType ) return elementType; } - private static void AddPlaceholderForProperties( Dictionary parameterValuesForRoute, IEnumerable properties, string prefix ) + private static void AddPlaceholderForProperties( + Dictionary parameterValuesForRoute, + IEnumerable properties, + string prefix ) { foreach ( var property in properties ) { @@ -953,7 +972,10 @@ private static void AddPlaceholder( IDictionary parameterValuesF } } - private IList CreateParameterDescriptions( HttpActionDescriptor actionDescriptor, IParsedRoute parsedRoute, IDictionary routeDefaults ) + private IList CreateParameterDescriptions( + HttpActionDescriptor actionDescriptor, + IParsedRoute parsedRoute, + IDictionary routeDefaults ) { IList parameterDescriptions = new List(); var actionBinding = GetActionBinding( actionDescriptor ); @@ -991,7 +1013,10 @@ private IList CreateParameterDescriptions( HttpActionDe return parameterDescriptions; } - private static void AddUndeclaredRouteParameters( IParsedRoute parsedRoute, IDictionary routeDefaults, IList parameterDescriptions ) + private static void AddUndeclaredRouteParameters( + IParsedRoute parsedRoute, + IDictionary routeDefaults, + IList parameterDescriptions ) { for ( var i = 0; i < parsedRoute.PathSegments.Count; i++ ) { @@ -1061,7 +1086,9 @@ private ApiParameterDescription CreateParameterDescriptionFromBinding( HttpParam return parameterDescription; } - private static Collection RemoveInvalidApiDescriptions( Collection apiDescriptions, ApiVersion apiVersion ) + private static Collection RemoveInvalidApiDescriptions( + Collection apiDescriptions, + ApiVersion apiVersion ) { var filteredDescriptions = new Dictionary( StringComparer.OrdinalIgnoreCase ); diff --git a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.ApiExplorer.Tests/ApiExplorer/VersionedApiExplorerTest.cs b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.ApiExplorer.Tests/ApiExplorer/VersionedApiExplorerTest.cs index 0ebbad31..e42d2651 100644 --- a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.ApiExplorer.Tests/ApiExplorer/VersionedApiExplorerTest.cs +++ b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.ApiExplorer.Tests/ApiExplorer/VersionedApiExplorerTest.cs @@ -37,7 +37,7 @@ public void api_descriptions_should_recognize_direct_routes() var descriptions = apiExplorer.ApiDescriptions; // assert - descriptions.Single().Should().Should().BeEquivalentTo( + descriptions.Single().Should().BeEquivalentTo( new { HttpMethod = Get, RelativePath = routeTemplate, ActionDescriptor = action }, options => options.ExcludingMissingMembers() ); } @@ -70,7 +70,7 @@ public void api_descriptions_should_ignore_api_for_direct_route_action() var descriptions = apiExplorer.ApiDescriptions; // assert - descriptions.Single().Should().Should().BeEquivalentTo( + descriptions.Single().Should().BeEquivalentTo( new { HttpMethod = Get, RelativePath = routeTemplate }, options => options.ExcludingMissingMembers() ); } @@ -124,7 +124,7 @@ public void api_descriptions_should_recognize_composite_routes() var descriptions = apiExplorer.ApiDescriptions; // assert - descriptions.Single().Should().Should().BeEquivalentTo( + descriptions.Single().Should().BeEquivalentTo( new { HttpMethod = Get, RelativePath = routeTemplate, ActionDescriptor = action }, options => options.ExcludingMissingMembers() ); } @@ -208,7 +208,7 @@ public void api_descriptions_should_recognize_mixedX2Dcase_parameters() { // arrange var configuration = new HttpConfiguration(); - var routeTemplate = "api/values/{id}"; + var routeTemplate = "api/values/{Id}"; var metadata = new ApiVersionMetadata( ApiVersionModel.Empty, new ApiVersionModel( new ApiVersion( 1, 0 ) ) ); var controllerDescriptor = new HttpControllerDescriptor( configuration, "ApiExplorerValues", typeof( DuplicatedIdController ) ); var action = new ReflectedHttpActionDescriptor( controllerDescriptor, typeof( DuplicatedIdController ).GetMethod( "Get" ) ) @@ -225,7 +225,7 @@ public void api_descriptions_should_recognize_mixedX2Dcase_parameters() var descriptions = apiExplorer.ApiDescriptions; // assert - descriptions.Single().Should().Should().BeEquivalentTo( + descriptions.Single().Should().BeEquivalentTo( new { HttpMethod = Get, RelativePath = routeTemplate, ActionDescriptor = action }, options => options.ExcludingMissingMembers() ); } diff --git a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.ApiExplorer.Tests/Asp.Versioning.WebApi.ApiExplorer.Tests.csproj b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.ApiExplorer.Tests/Asp.Versioning.WebApi.ApiExplorer.Tests.csproj index e276d02f..f62bb398 100644 --- a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.ApiExplorer.Tests/Asp.Versioning.WebApi.ApiExplorer.Tests.csproj +++ b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.ApiExplorer.Tests/Asp.Versioning.WebApi.ApiExplorer.Tests.csproj @@ -1,7 +1,7 @@ - net452 + net452;net472 Asp.Versioning diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Asp.Versioning.Mvc.Acceptance.Tests.csproj b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Asp.Versioning.Mvc.Acceptance.Tests.csproj index b1cf5110..c3a90d5d 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Asp.Versioning.Mvc.Acceptance.Tests.csproj +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Asp.Versioning.Mvc.Acceptance.Tests.csproj @@ -1,13 +1,27 @@  - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning + + 6.0.4-* + 3.1.24 + + - + + + + + + + + + + diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs index 8b9b2ded..208c5130 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs @@ -4,51 +4,76 @@ namespace Asp.Versioning; using Asp.Versioning.Conventions; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; public class MinimalApiFixture : HttpServerFixture { protected override void OnConfigureEndpoints( IEndpointRouteBuilder endpoints ) { - endpoints.DefineApi() - .IsApiVersionNeutral() - .HasMapping( api => api.MapGet( "api/ping", () => { } ) ); + var values = endpoints.NewApiVersionSet( "Values" ) + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .ReportApiVersions() + .Build(); - endpoints.DefineApi( "Values" ) + var helloWorld = endpoints.NewApiVersionSet( "Hello World" ) + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .ReportApiVersions() + .Build(); + + var orders = endpoints.NewApiVersionSet( "Orders" ).Build(); + + endpoints.MapGet( "api/ping", () => Results.NoContent() ) + .WithApiVersionSet( endpoints.NewApiVersionSet().Build() ) + .IsApiVersionNeutral(); + + endpoints.MapGet( "api/values", () => "Value 1" ) + .WithApiVersionSet( values ) + .MapToApiVersion( 1.0 ); + + endpoints.MapGet( "api/values", () => "Value 2" ) + .WithApiVersionSet( values ) + .MapToApiVersion( 2.0 ); + + endpoints.MapGet( "api/v{version:apiVersion}/hello", () => "Hello world!" ) + .WithApiVersionSet( helloWorld ) + .MapToApiVersion( 1.0 ); + + endpoints.MapGet( "api/v{version:apiVersion}/hello/{text}", ( string text ) => text ) + .WithApiVersionSet( helloWorld ) + .MapToApiVersion( 1.0 ); + + endpoints.MapGet( "api/v{version:apiVersion}/hello", () => "Hello world! (v2)" ) + .WithApiVersionSet( helloWorld ) + .MapToApiVersion( 2.0 ); + + endpoints.MapGet( "api/v{version:apiVersion}/hello/{text}", ( string text ) => text + " (v2)" ) + .WithApiVersionSet( helloWorld ) + .MapToApiVersion( 2.0 ); + + endpoints.MapPost( "api/v{version:apiVersion}/hello", () => { } ) + .WithApiVersionSet( helloWorld ); + + endpoints.MapGet( "api/order", () => { } ) + .WithApiVersionSet( orders ) .HasApiVersion( 1.0 ) - .HasApiVersion( 2.0 ) - .ReportApiVersions() - .HasMapping( - api => - { - api.MapGet( "api/values", () => "Value 1" ).MapToApiVersion( 1.0 ); - api.MapGet( "api/values", () => "Value 2" ).MapToApiVersion( 2.0 ); - } ); - - endpoints.DefineApi( "Hello World" ) + .HasApiVersion( 2.0 ); + + endpoints.MapGet( "api/order/{id}", ( int id ) => { } ) + .WithApiVersionSet( orders ) + .HasDeprecatedApiVersion( 0.9 ) .HasApiVersion( 1.0 ) - .HasApiVersion( 2.0 ) - .ReportApiVersions() - .HasMapping( - api => - { - api.MapGet( "api/v{version:apiVersion}/hello", () => "Hello world!" ).MapToApiVersion( 1.0 ); - api.MapGet( "api/v{version:apiVersion}/hello/{text}", ( string text ) => text ).MapToApiVersion( 1.0 ); - - api.MapGet( "api/v{version:apiVersion}/hello", () => "Hello world! (v2)" ).MapToApiVersion( 2.0 ); - api.MapGet( "api/v{version:apiVersion}/hello/{text}", ( string text ) => text + " (v2)" ).MapToApiVersion( 2.0 ); - - api.MapPost( "api/v{version:apiVersion}/hello", () => { } ); - } ); - - endpoints.DefineApi( "Orders" ) - .HasMapping( - api => - { - api.MapGet( "api/order", () => { } ).HasApiVersion( 1.0 ).HasApiVersion( 2.0 ); - api.MapGet( "api/order/{id}", ( int id ) => { } ).HasDeprecatedApiVersion( 0.9 ).HasApiVersion( 1.0 ).HasApiVersion( 2.0 ); - api.MapPost( "api/order", () => { } ).HasApiVersion( 1.0 ).HasApiVersion( 2.0 ); - api.MapDelete( "api/order/{id}", ( int id ) => { } ).IsApiVersionNeutral(); - } ); + .HasApiVersion( 2.0 ); + + endpoints.MapPost( "api/order", () => { } ) + .WithApiVersionSet( orders ) + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ); + + endpoints.MapDelete( "api/order/{id}", ( int id ) => { } ) + .WithApiVersionSet( orders ) + .IsApiVersionNeutral(); } } \ No newline at end of file diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/HttpServerFixture.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/HttpServerFixture.cs index fbe5b850..4d584bbc 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/HttpServerFixture.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/HttpServerFixture.cs @@ -13,6 +13,7 @@ namespace Asp.Versioning; using Microsoft.Extensions.DependencyInjection; using System.IO; using System.Reflection; +using System.Text; using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor; public abstract partial class HttpServerFixture @@ -35,14 +36,34 @@ protected virtual void OnAddApiVersioning( IApiVersioningBuilder builder ) { } private static string GenerateEndpointDirectedGraph( IServiceProvider services ) { + const int MaxUriLength = 65519; var dfa = services.GetRequiredService(); var dataSource = services.GetRequiredService(); - using var writer = new StringWriter(); + string graph; - dfa.Write( dataSource, writer ); - writer.Flush(); + using ( var writer = new StringWriter() ) + { + dfa.Write( dataSource, writer ); + writer.Flush(); + graph = writer.ToString(); + } + + var count = graph.Length / MaxUriLength; + var fragment = new StringBuilder(); + + for ( var i = 0; i <= count; i++ ) + { + if ( i < count ) + { + fragment.Append( Uri.EscapeDataString( graph.Substring( MaxUriLength * i, MaxUriLength ) ) ); + } + else + { + fragment.Append( Uri.EscapeDataString( graph[( MaxUriLength * i )..] ) ); + } + } - return "https://edotor.net/?engine=dot#" + Uri.EscapeDataString( writer.ToString() ); + return "https://edotor.net/?engine=dot#" + fragment.ToString(); } private TestServer CreateServer() diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/Controllers/WeatherForecastsController.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/Controllers/WeatherForecastsController.cs index 0c9fc35e..e138f931 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/Controllers/WeatherForecastsController.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/Controllers/WeatherForecastsController.cs @@ -1,5 +1,6 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. +#pragma warning disable IDE0060 // Remove unused parameter #pragma warning disable CA1822 // Mark members as static namespace Asp.Versioning.OData.Basic.Controllers; diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs index 1cdad01e..a42aeb03 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs @@ -107,8 +107,15 @@ public async Task then_get_should_return_400_for_an_unspecified_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert + + // change from 3.1 to 6.0; DELETE is version-neutral + // and the only candidate, so GET returns 405 +#if NETCOREAPP3_1 + response.StatusCode.Should().Be( MethodNotAllowed ); +#else response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unspecified.Type ); +#endif } public when_using_a_query_string_and_split_into_two_types( BasicFixture fixture, ITestOutputHelper console ) diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs index 4e8cce19..94fa7576 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs @@ -47,8 +47,15 @@ public async Task then_get_should_return_400_for_an_unspecified_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert + + // change from 3.1 to 6.0; DELETE is version-neutral + // and the only candidate, so GET returns 405 +#if NETCOREAPP3_1 + response.StatusCode.Should().Be( MethodNotAllowed ); +#else response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unspecified.Type ); +#endif } public when_using_a_query_string( BasicFixture fixture, ITestOutputHelper console ) diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs index 6525c4d1..012ab31d 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs @@ -88,14 +88,20 @@ public async Task then_get_should_return_400_for_an_unspecified_version() { // arrange - // act var response = await GetAsync( "api/people" ); var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert + + // change from 3.1 to 6.0; DELETE is version-neutral + // and the only candidate, so GET returns 405 +#if NETCOREAPP3_1 + response.StatusCode.Should().Be( MethodNotAllowed ); +#else response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unspecified.Type ); +#endif } public when_using_a_query_string_and_split_into_two_types( ConventionsFixture fixture ) : base( fixture ) { } diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs index c02c0755..64dd1dea 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs @@ -17,7 +17,7 @@ public async Task then_get_should_return_200( string requestUrl ) // act - var response = (await GetAsync( requestUrl )).EnsureSuccessStatusCode(); + var response = ( await GetAsync( requestUrl ) ).EnsureSuccessStatusCode(); // assert response.Headers.GetValues( "api-supported-versions" ).Single().Should().Be( "1.0" ); @@ -47,8 +47,15 @@ public async Task then_get_should_return_400_for_an_unspecified_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert + + // change from 3.1 to 6.0; DELETE is version-neutral + // and the only candidate, so GET returns 405 +#if NETCOREAPP3_1 + response.StatusCode.Should().Be( MethodNotAllowed ); +#else response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unspecified.Type ); +#endif } public when_using_a_query_string( ConventionsFixture fixture ) : base( fixture ) { } diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs index 2fb9d875..a157768e 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs @@ -102,7 +102,14 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) for ( var i = results.Count - 1; i >= 0; i-- ) { var result = results[i]; - var metadata = result.ActionDescriptor.EndpointMetadata.OfType(); + var metadata = result.ActionDescriptor.EndpointMetadata.OfType().ToArray(); + var notOData = metadata.Length == 0; + + if ( notOData ) + { + RemoveODataOptions( result ); + continue; + } if ( !TryMatchModelVersion( result, metadata, out var matched ) || IsServiceDocumentOrMetadata( matched.Template ) || @@ -172,13 +179,14 @@ private static bool IsNavigationPropertyLink( ODataPathTemplate template ) => private static bool TryMatchModelVersion( ApiDescription description, - IEnumerable items, + IReadOnlyList items, [NotNullWhen( true )] out IODataRoutingMetadata? metadata ) { var apiVersion = description.GetApiVersion()!; - foreach ( var item in items ) + for ( var i = 0; i < items.Count; i++ ) { + var item = items[i]; var otherApiVersion = item.Model.GetAnnotationValue( item.Model ).ApiVersion; if ( apiVersion.Equals( otherApiVersion ) ) @@ -236,6 +244,20 @@ private static void RemoveProperties( ApiDescription description, params string[ } } + private static void RemoveODataOptions( ApiDescription description ) + { + var parameters = description.ParameterDescriptions; + + for ( var i = 0; i < parameters.Count; i++ ) + { + if ( parameters[i].Type.IsODataQueryOptions() ) + { + parameters.RemoveAt( i ); + break; + } + } + } + private void ExpandNavigationPropertyLinks( ICollection descriptions, ApiDescription description, 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 70e46f30..c0c19a92 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 @@ -3,7 +3,7 @@ 6.0.0 6.0.0.0 - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning ASP.NET Core API Versioning API Explorer for OData v4.0 The API Explorer extensions for ASP.NET Core API Versioning and OData v4.0. diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs index bd54df4a..aef2fdbf 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs @@ -22,7 +22,14 @@ public partial class ODataQueryOptionDescriptionContext return default; } - var items = description.ActionDescriptor.EndpointMetadata.OfType(); + var metadata = description.ActionDescriptor.EndpointMetadata; + + if ( metadata == null ) + { + return default; + } + + var items = metadata.OfType(); foreach ( var item in items ) { diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionSettings.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionSettings.cs index b80e4b94..c66d040a 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionSettings.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionSettings.cs @@ -3,7 +3,6 @@ namespace Asp.Versioning.Conventions; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.OData.ModelBuilder.Config; /// /// Provides additional implementation specific to Microsoft ASP.NET Core. @@ -11,12 +10,6 @@ namespace Asp.Versioning.Conventions; [CLSCompliant( false )] public partial class ODataQueryOptionSettings { - /// - /// Gets or sets the default OData query settings. - /// - /// The default OData query settings. - public DefaultQuerySettings? DefaultQuerySettings { get; set; } - /// /// Gets or sets the configured model metadata provider. /// diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs index 0f9237aa..9a91c9fa 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs @@ -4,7 +4,6 @@ namespace Asp.Versioning.Conventions; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Controllers; -using Microsoft.AspNetCore.OData.Query; /// /// Provides additional implementation specific to Microsoft ASP.NET Core. @@ -24,12 +23,14 @@ private static Type GetController( ApiDescription apiDescription ) private static bool IsODataLike( ApiDescription description ) { - if ( description.ActionDescriptor is ControllerActionDescriptor action ) + var parameters = description.ActionDescriptor.Parameters; + + for ( var i = 0; i < parameters.Count; i++ ) { - return Attribute.IsDefined( - action.ControllerTypeInfo, - typeof( EnableQueryAttribute ), - inherit: true ); + if ( parameters[i].ParameterType.IsODataQueryOptions() ) + { + return true; + } } return false; 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 613fb73f..29d88a6d 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 @@ -3,7 +3,7 @@ 6.0.0 6.0.0.0 - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning ASP.NET Core API Versioning with OData v4.0 A service API versioning library for Microsoft ASP.NET Core with OData v4.0. @@ -14,8 +14,18 @@ + + + + + + + + + + - + diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IApiVersioningBuilderExtensions.cs index c097c8ad..9b9967ee 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -21,6 +21,9 @@ namespace Microsoft.Extensions.DependencyInjection; /// /// Provides ASP.NET Core OData specific extension methods for . /// +#if NETCOREAPP3_1 +[CLSCompliant( false )] +#endif public static class IApiVersioningBuilderExtensions { /// diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataApiVersioningOptions.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataApiVersioningOptions.cs index 1ca2b8d9..65ddd175 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataApiVersioningOptions.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataApiVersioningOptions.cs @@ -9,7 +9,7 @@ namespace Asp.Versioning.OData; /// Represents the possible API versioning options for OData services. /// [CLSCompliant( false )] -public class ODataApiVersioningOptions +public partial class ODataApiVersioningOptions { private Dictionary>? configurations; diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataApplicationModelProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataApplicationModelProvider.cs index e825eb6a..0d9cdcc0 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataApplicationModelProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataApplicationModelProvider.cs @@ -57,15 +57,7 @@ public ODataApplicationModelProvider( private static int BeforeOData { get; } = ODataMultiModelApplicationModelProvider.DefaultODataOrder - 50; /// - public virtual void OnProvidersExecuted( ApplicationModelProviderContext context ) - { - if ( context == null ) - { - throw new ArgumentNullException( nameof( context ) ); - } - - EnsureEndpointMetadata( context.Result ); - } + public virtual void OnProvidersExecuted( ApplicationModelProviderContext context ) { } /// public virtual void OnProvidersExecuting( ApplicationModelProviderContext context ) @@ -193,46 +185,6 @@ private static return bestController; } - private static void EnsureEndpointMetadata( ApplicationModel application ) - { - var controllers = application.Controllers; - - for ( var i = 0; i < controllers.Count; i++ ) - { - var controller = controllers[i]; - - if ( !controller.ControllerType.IsMetadataController() && - !ODataControllerSpecification.IsSatisfiedBy( controller ) ) - { - continue; - } - - var actions = controller.Actions; - - for ( var j = 0; j < actions.Count; j++ ) - { - var selectors = actions[j].Selectors; - var metadata = default( ApiVersionMetadata ); - var endpointMetadata = selectors[0].EndpointMetadata; - - for ( var k = 0; metadata == null && k < selectors.Count; k++ ) - { - metadata = endpointMetadata[k] as ApiVersionMetadata; - } - - if ( metadata == null ) - { - continue; - } - - for ( var k = 1; k < selectors.Count; k++ ) - { - selectors[k].EndpointMetadata.Add( metadata ); - } - } - } - } - private void ApplyMetadataControllerConventions( List? metadataControllers, SortedSet? supported, diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs index d6cf73e3..3656e40d 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs @@ -110,6 +110,22 @@ static void NoConfig( IServiceCollection sc ) provider.OnProvidersExecuted( context ); } + // HACK: there are intrinsically a couple of issues here: + // + // 1. ASP.NET Core creates an ActionDescriptor per SelectorModel in an ActionModel + // 2. OData adds a SelectorModel per EDM + // 3. ApiVersionMetadata has already be computed and added to EndpointMetadata + // + // this only becomes a problem when there are multiple EDMs and a single action implementation + // maps to more than one EDM. + // + // REF: https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Core/src/ApplicationModels/ActionAttributeRouteModel.cs + // REF: https://github.com/OData/AspNetCoreOData/blob/main/src/Microsoft.AspNetCore.OData/Extensions/ActionModelExtensions.cs#L148 + if ( mapping.Count > 1 ) + { + CopyApiVersionEndpointMetadata( context.Result.Controllers ); + } + versionedODataOptions.Mapping = mapping; } @@ -165,4 +181,52 @@ private static int FindAttributeRouteConvention( ODataOptions options ) return -1; } + + private static void CopyApiVersionEndpointMetadata( IList controllers ) + { + for ( var i = 0; i < controllers.Count; i++ ) + { + var actions = controllers[i].Actions; + + for ( var j = 0; j < actions.Count; j++ ) + { + var selectors = actions[j].Selectors; + + if ( selectors.Count < 2 ) + { + continue; + } + + var metadata = selectors[0].EndpointMetadata.OfType().FirstOrDefault(); + + if ( metadata is null ) + { + continue; + } + + for ( var k = 1; k < selectors.Count; k++ ) + { + var endpointMetadata = selectors[k].EndpointMetadata; + var found = false; + + for ( var l = 0; l < endpointMetadata.Count; l++ ) + { + if ( endpointMetadata[l] is not ApiVersionMetadata ) + { + continue; + } + + endpointMetadata[l] = metadata; + found = true; + break; + } + + if ( !found ) + { + endpointMetadata.Add( metadata ); + } + } + } + } + } } \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataApiVersioningOptionsFactory.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/net6.0/OData/ODataApiVersioningOptionsFactory.cs similarity index 100% rename from src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataApiVersioningOptionsFactory.cs rename to src/AspNetCore/OData/src/Asp.Versioning.OData/net6.0/OData/ODataApiVersioningOptionsFactory.cs diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/netcoreapp3.1/OData/ODataApiVersioningOptions.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/netcoreapp3.1/OData/ODataApiVersioningOptions.cs new file mode 100644 index 00000000..ede6ff7e --- /dev/null +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/netcoreapp3.1/OData/ODataApiVersioningOptions.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.OData; + +using Microsoft.Extensions.Options; +using System.ComponentModel; + +/// +/// Additional implementation specific ASP.NET Core 3.1. +/// +public partial class ODataApiVersioningOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// This constructor is meant to serve the public parameter type constraint of + /// , but should not be used. + [EditorBrowsable( EditorBrowsableState.Never )] + public ODataApiVersioningOptions() : + this( new( new ODataApiVersionCollectionProvider(), Enumerable.Empty() ) ) + { + } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/netcoreapp3.1/OData/ODataApiVersioningOptionsFactory.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/netcoreapp3.1/OData/ODataApiVersioningOptionsFactory.cs new file mode 100644 index 00000000..9085e65c --- /dev/null +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/netcoreapp3.1/OData/ODataApiVersioningOptionsFactory.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.OData; + +using Microsoft.Extensions.Options; + +/// +/// Represents a factory to create API versioning options specific to OData. +/// +[CLSCompliant( false )] +public class ODataApiVersioningOptionsFactory : IOptionsFactory +{ + private readonly IConfigureOptions[] setups; + private readonly IPostConfigureOptions[] postConfigures; + private readonly VersionedODataModelBuilder modelBuilder; + + /// + /// Initializes a new instance of the class. + /// + /// The associated model builder. + /// The sequence of + /// configuration actions to run. + /// The sequence of + /// initialization actions to run. + public ODataApiVersioningOptionsFactory( + VersionedODataModelBuilder modelBuilder, + IEnumerable> setups, + IEnumerable> postConfigures ) + { + this.setups = setups as IConfigureOptions[] ?? new List>( setups ).ToArray(); + this.postConfigures = postConfigures as IPostConfigureOptions[] ?? new List>( postConfigures ).ToArray(); + this.modelBuilder = modelBuilder ?? throw new ArgumentNullException( nameof( modelBuilder ) ); + } + + /// + public virtual ODataApiVersioningOptions Create( string name ) + { + var options = new ODataApiVersioningOptions( modelBuilder ); + + foreach ( var setup in setups ) + { + if ( setup is IConfigureNamedOptions namedSetup ) + { + namedSetup.Configure( name, options ); + } + else if ( name == Options.DefaultName ) + { + setup.Configure( options ); + } + } + + foreach ( var post in postConfigures ) + { + post.PostConfigure( name, options ); + } + + return options; + } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs index 9d3c587d..3f5f3477 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs @@ -87,6 +87,8 @@ private void AssertVersion1( ApiDescriptionGroup group ) items.Should().BeEquivalentTo( new[] { + new { HttpMethod = "GET", GroupName, RelativePath = "api/Books" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Books/{id}" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" }, new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/{key}" }, @@ -126,48 +128,49 @@ private void AssertVersion3( ApiDescriptionGroup group ) { const string GroupName = "v3"; var items = group.Items.OrderBy( i => i.RelativePath ).ThenBy( i => i.HttpMethod ).ToArray(); + var expected = new[] + { + new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders" }, + new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders" }, + new { HttpMethod = "DELETE", GroupName, RelativePath = "api/Orders/{key}" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/{key}" }, + new { HttpMethod = "PATCH", GroupName, RelativePath = "api/Orders/{key}" }, + new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders/{key}/Rate" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/$count" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/MostExpensive" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/People" }, + new { HttpMethod = "POST", GroupName, RelativePath = "api/People" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/People/{key}" }, + new { HttpMethod = "POST", GroupName, RelativePath = "api/People/{key}/Promote" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/People/$count" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/People/NewHires(Since={since})" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Products" }, + new { HttpMethod = "POST", GroupName, RelativePath = "api/Products" }, + new { HttpMethod = "DELETE", GroupName, RelativePath = "api/Products/{key}" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Products/{key}" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Products/$count" }, + new { HttpMethod = "PATCH", GroupName, RelativePath = "api/Products/{key}" }, + new { HttpMethod = "PUT", GroupName, RelativePath = "api/Products/{key}" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Products/{key}/Supplier" }, + new { HttpMethod = "DELETE", GroupName, RelativePath = "api/Products/{key}/supplier/{relatedKey}/$ref" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Products/{key}/supplier/$ref" }, + new { HttpMethod = "PUT", GroupName, RelativePath = "api/Products/{key}/supplier/$ref" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Suppliers" }, + new { HttpMethod = "POST", GroupName, RelativePath = "api/Suppliers" }, + new { HttpMethod = "DELETE", GroupName, RelativePath = "api/Suppliers/{key}" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Suppliers/{key}" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Suppliers/$count" }, + new { HttpMethod = "PATCH", GroupName, RelativePath = "api/Suppliers/{key}" }, + new { HttpMethod = "PUT", GroupName, RelativePath = "api/Suppliers/{key}" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Suppliers/{key}/Products" }, + new { HttpMethod = "DELETE", GroupName, RelativePath = "api/Suppliers/{key}/products/{relatedKey}/$ref" }, + new { HttpMethod = "PUT", GroupName, RelativePath = "api/Suppliers/{key}/products/$ref" }, + }; PrintGroup( items ); group.GroupName.Should().Be( GroupName ); - items.Should().BeEquivalentTo( - new[] - { - new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders" }, - new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders" }, - new { HttpMethod = "DELETE", GroupName, RelativePath = "api/Orders/{key}" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/{key}" }, - new { HttpMethod = "PATCH", GroupName, RelativePath = "api/Orders/{key}" }, - new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders/{key}/Rate" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/$count" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/MostExpensive" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/People" }, - new { HttpMethod = "POST", GroupName, RelativePath = "api/People" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/People/{key}" }, - new { HttpMethod = "POST", GroupName, RelativePath = "api/People/{key}/Promote" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/People/$count" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/People/NewHires(Since={since})" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Products" }, - new { HttpMethod = "POST", GroupName, RelativePath = "api/Products" }, - new { HttpMethod = "DELETE", GroupName, RelativePath = "api/Products/{key}" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Products/{key}" }, - new { HttpMethod = "PATCH", GroupName, RelativePath = "api/Products/{key}" }, - new { HttpMethod = "PUT", GroupName, RelativePath = "api/Products/{key}" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Products/{key}/Supplier" }, - new { HttpMethod = "DELETE", GroupName, RelativePath = "api/Products/{key}/supplier/{relatedKey}/$ref" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Products/{key}/supplier/$ref" }, - new { HttpMethod = "PUT", GroupName, RelativePath = "api/Products/{key}/supplier/$ref" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Suppliers" }, - new { HttpMethod = "POST", GroupName, RelativePath = "api/Suppliers" }, - new { HttpMethod = "DELETE", GroupName, RelativePath = "api/Suppliers/{key}" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Suppliers/{key}" }, - new { HttpMethod = "PATCH", GroupName, RelativePath = "api/Suppliers/{key}" }, - new { HttpMethod = "PUT", GroupName, RelativePath = "api/Suppliers/{key}" }, - new { HttpMethod = "GET", GroupName, RelativePath = "api/Suppliers/{key}/Products" }, - new { HttpMethod = "DELETE", GroupName, RelativePath = "api/Suppliers/{key}/products/{relatedKey}/$ref" }, - new { HttpMethod = "PUT", GroupName, RelativePath = "api/Suppliers/{key}/products/$ref" }, - }, - options => options.ExcludingMissingMembers() ); + items.Should().BeEquivalentTo( expected, options => options.ExcludingMissingMembers() ); } private void PrintGroup( IReadOnlyList items ) diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Asp.Versioning.OData.ApiExplorer.Tests.csproj b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Asp.Versioning.OData.ApiExplorer.Tests.csproj index fc8d6c66..f8e27acf 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Asp.Versioning.OData.ApiExplorer.Tests.csproj +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Asp.Versioning.OData.ApiExplorer.Tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataQueryOptionsConventionBuilderTest.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataQueryOptionsConventionBuilderTest.cs index fd404f1e..cd72258f 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataQueryOptionsConventionBuilderTest.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataQueryOptionsConventionBuilderTest.cs @@ -19,7 +19,10 @@ public void apply_should_apply_configured_conventions() ActionDescriptor = new ControllerActionDescriptor() { ControllerTypeInfo = typeof( StubController ).GetTypeInfo(), - MethodInfo = typeof( StubController ).GetTypeInfo().GetRuntimeMethod( nameof( StubController.Get ), Type.EmptyTypes ), + MethodInfo = typeof( StubController ).GetTypeInfo() + .GetRuntimeMethod( + nameof( StubController.Get ), + Type.EmptyTypes ), }, HttpMethod = "GET", }; diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs index d4c0de2b..e5cd95ce 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs @@ -3,7 +3,10 @@ namespace Asp.Versioning.Conventions; using Asp.Versioning.OData; +using Asp.Versioning.Simulators.Models; +using Asp.Versioning.Simulators.V1; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -466,6 +469,103 @@ public void apply_to_should_use_model_bound_query_attributes() options => options.ExcludingMissingMembers() ); } + [Fact] + public void apply_to_should_process_odataX2Dlike_api_description() + { + // arrange + var controllerType = typeof( BooksController ); + var action = controllerType.GetRuntimeMethods() + .First( m => m.Name == "Get" && m.GetParameters().Length == 1 ); + var parameter = action.GetParameters()[0]; + var description = new ApiDescription() + { + ActionDescriptor = new ControllerActionDescriptor() + { + ControllerTypeInfo = controllerType.GetTypeInfo(), + MethodInfo = action, + Parameters = new ParameterDescriptor[] + { + new() + { + Name = parameter.Name, + ParameterType = parameter.ParameterType, + }, + }, + }, + HttpMethod = "GET", + SupportedResponseTypes = + { + new() + { + Type = typeof( IEnumerable ), + StatusCode = Status200OK, + }, + }, + Properties = { [typeof( ApiVersion )] = ApiVersion.Default }, + }; + var builder = new ODataQueryOptionsConventionBuilder(); + var settings = new ODataQueryOptionSettings() + { + DescriptionProvider = builder.DescriptionProvider, + DefaultQuerySettings = new(), + ModelMetadataProvider = Mock.Of(), + }; + + builder.Controller() + .Action( c => c.Get( default ) ) + .Allow( Select | Count ) + .AllowOrderBy( "title", "published" ); + + // act + builder.ApplyTo( new[] { description }, settings ); + + // assert + description.ParameterDescriptions.Should().BeEquivalentTo( + new[] + { + new + { + Name = "$select", + Source = Query, + Type = typeof( string ), + DefaultValue = default( object ), + IsRequired = false, + ParameterDescriptor = new + { + Name = "$select", + ParameterType = typeof( string ), + }, + }, + new + { + Name = "$orderby", + Source = Query, + Type = typeof( string ), + DefaultValue = default( object ), + IsRequired = false, + ParameterDescriptor = new + { + Name = "$orderby", + ParameterType = typeof( string ), + }, + }, + new + { + Name = "$count", + Source = Query, + Type = typeof( bool ), + DefaultValue = (object) false, + IsRequired = false, + ParameterDescriptor = new + { + Name = "$count", + ParameterType = typeof( bool ), + }, + }, + }, + options => options.ExcludingMissingMembers() ); + } + public static IEnumerable EnableQueryAttributeData { get @@ -519,9 +619,9 @@ private static ApiDescription NewApiDescription( Type controllerType, Type respo ControllerTypeInfo = controllerType.GetTypeInfo(), MethodInfo = controllerType.GetRuntimeMethods().Single( m => m.Name == "Get" ), EndpointMetadata = new object[] -{ + { new ODataRoutingMetadata( string.Empty, model, new() ), -}, + }, }, HttpMethod = "GET", SupportedResponseTypes = diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Book.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Book.cs new file mode 100644 index 00000000..00fec7f1 --- /dev/null +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Book.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Simulators.Models; + +/// +/// Represents a book. +/// +public class Book +{ + /// + /// Gets or sets the book identifier. + /// + /// The International Standard Book Number (ISBN). + public string Id { get; set; } + + /// + /// Gets or sets the book author. + /// + /// The author of the book. + public string Author { get; set; } + + /// + /// Gets or sets the book title. + /// + /// The title of the book. + public string Title { get; set; } + + /// + /// Gets or sets the book publication year. + /// + /// The year the book was first published. + public int Published { get; set; } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/V1/BooksController.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/V1/BooksController.cs new file mode 100644 index 00000000..438a0099 --- /dev/null +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/V1/BooksController.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Simulators.V1; + +using Asp.Versioning; +using Asp.Versioning.Simulators.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using static Microsoft.AspNetCore.Http.StatusCodes; + +/// +/// Represents a RESTful service of books. +/// +[ApiVersion( 1.0 )] +[ApiController] +[Route( "api/[controller]" )] +public class BooksController : ControllerBase +{ + private static readonly Book[] books = new Book[] + { + new() { Id = "9781847490599", Title = "Anna Karenina", Author = "Leo Tolstoy", Published = 1878 }, + new() { Id = "9780198800545", Title = "War and Peace", Author = "Leo Tolstoy", Published = 1869 }, + new() { Id = "9780684801520", Title = "The Great Gatsby", Author = "F. Scott Fitzgerald", Published = 1925 }, + new() { Id = "9780486280615", Title = "The Adventures of Huckleberry Finn", Author = "Mark Twain", Published = 1884 }, + new() { Id = "9780140430820", Title = "Moby Dick", Author = "Herman Melville", Published = 1851 }, + new() { Id = "9780060934347", Title = "Don Quixote", Author = "Miguel de Cervantes", Published = 1605 }, + }; + + /// + /// Gets all books. + /// + /// The current OData query options. + /// All available books. + /// The successfully retrieved books. + [HttpGet] + [Produces( "application/json" )] + [ProducesResponseType( typeof( IEnumerable ), Status200OK )] + public IActionResult Get( ODataQueryOptions options ) => + Ok( options.ApplyTo( books.AsQueryable() ) ); + + /// + /// Gets a single book. + /// + /// The requested book identifier. + /// The current OData query options. + /// The requested book. + /// The book was successfully retrieved. + /// The book does not exist. + [HttpGet( "{id}" )] + [Produces( "application/json" )] + [ProducesResponseType( typeof( Book ), Status200OK )] + [ProducesResponseType( Status404NotFound )] + public IActionResult Get( string id, ODataQueryOptions options ) + { + var book = books.FirstOrDefault( book => book.Id == id ); + + if ( book == null ) + { + return NotFound(); + } + + return Ok( options.ApplyTo( book, new ODataQuerySettings(), default ) ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/Asp.Versioning.OData.Tests.csproj b/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/Asp.Versioning.OData.Tests.csproj index 97a09d52..5e218495 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/Asp.Versioning.OData.Tests.csproj +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/Asp.Versioning.OData.Tests.csproj @@ -1,7 +1,7 @@  - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning 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 fc7a8a4c..2031c06e 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 @@ -3,7 +3,7 @@ 6.0.0 6.0.0.0 - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning ASP.NET Core API Versioning A service API versioning library for Microsoft ASP.NET Core. @@ -14,6 +14,16 @@ + + + + + + + + + + diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/AcceptsMetadataBuilder{TBuilder}.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/AcceptsMetadataBuilder{TBuilder}.cs deleted file mode 100644 index 702a3f96..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/AcceptsMetadataBuilder{TBuilder}.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Metadata; - -/// -/// Represents a builder for . -/// -/// The type of . -[CLSCompliant( false )] -public class AcceptsMetadataBuilder - where TBuilder : IEndpointConventionBuilder -{ - private Type? requestType; - private string? contentType; - private string[]? additionalContentTypes; - - /// - /// Initializes a new instance of the class. - /// - /// The associated endpoint builder. - /// Sets a value that determines if the request body is optional. - public AcceptsMetadataBuilder( TBuilder builder, bool isOptional ) - { - Builder = builder; - IsOptional = isOptional; - } - - /// - /// Gets the associated endpoint builder. - /// - /// The associated endpoint builder. - protected TBuilder Builder { get; } - - /// - /// Gets a value indicating whether the request body is optional. - /// - /// True if the request body is optional; otherwise, false. - protected bool IsOptional { get; } - - /// - /// Adds the type of response that will be returned. - /// - /// The type of request body. - /// The original instance. - public virtual AcceptsMetadataBuilder Body() where TBody : notnull - { - requestType = typeof( TBody ); - return this; - } - - /// - /// Adds the content types that the request can be formatted as. - /// - /// The request content type that endpoint accepts. - /// Additional request content types the endpoint accepts. - /// The original instance. - public virtual AcceptsMetadataBuilder FormattedAs( - string contentType, - params string[] additionalContentTypes ) - { - this.contentType = contentType; - this.additionalContentTypes = additionalContentTypes; - return this; - } - - /// - /// Builds the underlying . - /// - public virtual void Build() => - Builder.Accepts( - requestType ?? throw new InvalidOperationException( SR.RequestTypeUnconfigured ), - IsOptional, - contentType ?? "application/json", - additionalContentTypes ?? Array.Empty() ); -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/ApiExplorerBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/ApiExplorerBuilderExtensions.cs deleted file mode 100644 index da96ac1a..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/ApiExplorerBuilderExtensions.cs +++ /dev/null @@ -1,304 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -// NOTE: if the ASP.NET team fixes the design in .NET 7, then this entire file and class should go away -// REF: https://github.com/dotnet/aspnetcore/issues/39604 -namespace Microsoft.AspNetCore.Http; - -using Asp.Versioning; -using Asp.Versioning.Builder; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http.Metadata; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using static System.Linq.Expressions.Expression; - -/// -/// Provides API Explorer extension methods for . -/// -[CLSCompliant( false )] -public static class ApiExplorerBuilderExtensions -{ - private static ExcludeFromDescriptionAttribute? excludeFromDescriptionMetadataAttribute; - private static Func? newProducesResponseTypeMetadata2; - private static Func? newProducesResponseTypeMetadata4; - private static Func? newAcceptsMetadata3; - - /// - /// Adds the to for all endpoints. - /// - /// The type of . - /// The extended endpoint convention builder. - /// The original . - public static TBuilder ExcludeFromDescription( this TBuilder builder ) - where TBuilder : IEndpointConventionBuilder - { - excludeFromDescriptionMetadataAttribute ??= new(); - builder.WithMetadata( excludeFromDescriptionMetadataAttribute ); - return builder; - } - - /// - /// Adds an to for all endpoints. - /// - /// The type of . - /// The extended endpoint convention builder. - /// The used to build the response metadata. - /// The response status code. Defaults to . - /// The original . - public static TBuilder Produces( - this TBuilder builder, - Action> build, - int statusCode = StatusCodes.Status200OK ) - where TBuilder : IEndpointConventionBuilder - { - if ( build == null ) - { - throw new ArgumentNullException( nameof( build ) ); - } - - var metadata = new ProducesResponseMetadataBuilder( builder, statusCode ); - build( metadata ); - metadata.Build(); - return builder; - } - - /// - /// Adds an to for all endpoints. - /// - /// The type of . - /// The extended endpoint convention builder. - /// The response status code. - /// The type of the response. Defaults to null. - /// The response content type. Defaults to "application/json" if responseType is not null, otherwise defaults to null. - /// Additional response content types the endpoint produces for the supplied status code. - /// The original . - public static TBuilder Produces( - this TBuilder builder, - int statusCode, - Type? responseType = null, - string? contentType = null, - params string[] additionalContentTypes ) - where TBuilder : IEndpointConventionBuilder - { - if ( responseType is not null && string.IsNullOrEmpty( contentType ) ) - { - contentType = "application/json"; - } - - responseType ??= typeof( void ); - IProducesResponseTypeMetadata metadata; - - if ( contentType is null ) - { - newProducesResponseTypeMetadata2 ??= NewProducesResponseTypeMetadataFunc2(); - metadata = newProducesResponseTypeMetadata2( responseType, statusCode ); - } - else - { - newProducesResponseTypeMetadata4 ??= NewProducesResponseTypeMetadataFunc4(); - metadata = newProducesResponseTypeMetadata4( responseType, statusCode, contentType, additionalContentTypes ); - } - - builder.WithMetadata( metadata ); - return builder; - } - - /// - /// Adds an with a type - /// to for all endpoints. - /// - /// The type of . - /// The extended endpoint convention builder. - /// The response status code. - /// The response content type. Defaults to "application/problem+json". - /// The original . - public static TBuilder ProducesProblem( - this TBuilder builder, - int statusCode, - string? contentType = null ) - where TBuilder : IEndpointConventionBuilder - { - if ( string.IsNullOrEmpty( contentType ) ) - { - contentType = ProblemDetailsDefaults.MediaType.Json; - } - - return Produces( builder, statusCode, typeof( ProblemDetails ), contentType ); - } - - /// - /// Adds an with a type - /// to for all endpoints. - /// - /// The type of . - /// The extended endpoint convention builder. - /// The response status code. Defaults to . - /// The response content type. Defaults to "application/problem+json". - /// The original . - public static TBuilder ProducesValidationProblem( - this TBuilder builder, - int statusCode = StatusCodes.Status400BadRequest, - string? contentType = null ) - where TBuilder : IEndpointConventionBuilder - { - if ( string.IsNullOrEmpty( contentType ) ) - { - contentType = ProblemDetailsDefaults.MediaType.Json; - } - - return Produces( builder, statusCode, typeof( HttpValidationProblemDetails ), contentType ); - } - - /// - /// Adds the to for all endpoints. - /// - /// The type of . - /// The extended endpoint convention builder. - /// A collection of tags to be associated with the endpoint. - /// The original . - /// When used with OpenAPI, the specification supports a tags classification to categorize - /// operations into related groups. These tags are typically included in the generated specification - /// and are typically used to group operations by tags in the UI. - public static TBuilder WithTags( this TBuilder builder, params string[] tags ) - where TBuilder : IEndpointConventionBuilder => builder.WithMetadata( new TagsAttribute( tags ) ); - - /// - /// Adds to for all endpoints. - /// - /// The type of . - /// The extended endpoint convention builder. - /// The used to build the request metadata. - /// Sets a value that determines if the request body is optional. - /// The original . - public static TBuilder Accepts( - this TBuilder builder, - Action> build, - bool isOptional = false) - where TBuilder : IEndpointConventionBuilder - { - if ( build == null ) - { - throw new ArgumentNullException( nameof( build ) ); - } - - var metadata = new AcceptsMetadataBuilder( builder, isOptional ); - build( metadata ); - metadata.Build(); - return builder; - } - - /// - /// Adds to for all endpoints. - /// - /// The type of . - /// The extended endpoint convention builder. - /// The type of the request body. - /// The request content type that the endpoint accepts. - /// The list of additional request content types that the endpoint accepts. - /// The original . - public static TBuilder Accepts( - this TBuilder builder, - Type requestType, - string contentType, - params string[] additionalContentTypes ) - where TBuilder : IEndpointConventionBuilder - { - newAcceptsMetadata3 ??= NewAcceptsMetadataFunc3(); - - var allContentTypes = GetAllContentTypes( contentType, additionalContentTypes ?? Array.Empty() ); - var metadata = newAcceptsMetadata3( requestType, false, allContentTypes ); - - return builder.WithMetadata( metadata ); - } - - /// - /// Adds to for all endpoints - /// produced by . - /// - /// The type of . - /// The extended endpoint convention builder. - /// The type of the request body. - /// Sets a value that determines if the request body is optional. - /// The request content type that the endpoint accepts. - /// The list of additional request content types that the endpoint accepts. - /// The original . - public static TBuilder Accepts( - this TBuilder builder, - Type requestType, - bool isOptional, - string contentType, - params string[] additionalContentTypes ) - where TBuilder : IEndpointConventionBuilder - { - newAcceptsMetadata3 ??= NewAcceptsMetadataFunc3(); - - var allContentTypes = GetAllContentTypes( contentType, additionalContentTypes ?? Array.Empty() ); - var metadata = newAcceptsMetadata3( requestType, isOptional, allContentTypes ); - - return builder.WithMetadata( metadata ); - } - - private static string[] GetAllContentTypes( string contentType, string[] additionalContentTypes ) - { - var allContentTypes = new string[additionalContentTypes.Length + 1]; - allContentTypes[0] = contentType; - - for ( var i = 0; i < additionalContentTypes.Length; i++ ) - { - allContentTypes[i + 1] = additionalContentTypes[i]; - } - - return allContentTypes; - } - - // HACK: >_< these are internal types and can't be forked due to internal logic and members - // REF: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs - // REF: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/RoutingMetadata/AcceptsMetadata.cs - private static class TypeNames - { - private const string Assembly = "Microsoft.AspNetCore.Routing"; - private const string Namespace = "Microsoft.AspNetCore.Http"; - - public const string ProducesResponseTypeMetadata = $"{Namespace}.{nameof( ProducesResponseTypeMetadata )}, {Assembly}"; - public const string AcceptsMetadata = $"{Namespace}.Metadata.{nameof( AcceptsMetadata )}, {Assembly}"; - } - - private static Func NewProducesResponseTypeMetadataFunc2() - { - var @class = Type.GetType( TypeNames.ProducesResponseTypeMetadata, throwOnError: true, ignoreCase: false )!; - var type = Parameter( typeof( Type ), "type" ); - var statusCode = Parameter( typeof( int ), "statusCode" ); - var ctor = @class.GetConstructor( new[] { typeof( Type ), typeof( int ) } )!; - var body = New( ctor, type, statusCode ); - var lambda = Lambda>( body, type, statusCode ); - - return lambda.Compile(); - } - - private static Func NewProducesResponseTypeMetadataFunc4() - { - var @class = Type.GetType( TypeNames.ProducesResponseTypeMetadata, throwOnError: true, ignoreCase: false )!; - var type = Parameter( typeof( Type ), "type" ); - var statusCode = Parameter( typeof( int ), "statusCode" ); - var contentType = Parameter( typeof( string ), "contentType" ); - var additionalContentTypes = Parameter( typeof( string[] ), "additionalContentTypes" ); - var ctor = @class.GetConstructor( new[] { typeof( Type ), typeof( int ), typeof( string ), typeof( string[] ) } )!; - var body = New( ctor, type, statusCode, contentType, additionalContentTypes ); - var lambda = Lambda>( body, type, statusCode, contentType, additionalContentTypes ); - - return lambda.Compile(); - } - - private static Func NewAcceptsMetadataFunc3() - { - var @class = Type.GetType( TypeNames.AcceptsMetadata, throwOnError: true, ignoreCase: false )!; - var type = Parameter( typeof( Type ), "type" ); - var isOptional = Parameter( typeof( bool ), "isOptional" ); - var contentTypes = Parameter( typeof( string[] ), "contentTypes" ); - var ctor = @class.GetConstructor( new[] { typeof( Type ), typeof( bool ), typeof( string[] ) } )!; - var body = New( ctor, type, isOptional, contentTypes ); - var lambda = Lambda>( body, type, isOptional, contentTypes ); - - return lambda.Compile(); - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/DefaultEndpointConventionBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/DefaultEndpointConventionBuilder.cs deleted file mode 100644 index a7958558..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/DefaultEndpointConventionBuilder.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; - -internal sealed class DefaultEndpointConventionBuilder : IEndpointConventionBuilder -{ - private readonly EndpointBuilder endpointBuilder; - private List>? original = new(); - - public DefaultEndpointConventionBuilder( EndpointBuilder endpointBuilder ) => this.endpointBuilder = endpointBuilder; - - public void Add( Action convention ) - { - var conventions = original ?? throw new InvalidOperationException( SR.ConventionAddedAfterEndpointBuilt ); - conventions.Add( convention ); - } - - public Endpoint Build() - { - if ( Interlocked.Exchange( ref original, null ) is List> conventions ) - { - for ( var i = 0; i < conventions.Count; i++ ) - { - conventions[i]( endpointBuilder ); - } - } - - return endpointBuilder.Build(); - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointMetadataBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointMetadataBuilderExtensions.cs deleted file mode 100644 index 30e32f1f..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointMetadataBuilderExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -/// -/// Provides extension methods for endpoint metadata builders. -/// -[CLSCompliant( false )] -public static class IEndpointMetadataBuilderExtensions -{ - /// - /// Configures the API to report compatible versions. - /// - /// The extended type of . - /// The extended Minimal API convention builder. - /// The original . - public static T ReportApiVersions( this T builder ) - where T : notnull, IEndpointMetadataBuilder - { - builder.ReportApiVersions = true; - return builder; - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IVersionedApiBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IVersionedApiBuilder.cs deleted file mode 100644 index ae071bf1..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IVersionedApiBuilder.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Asp.Versioning.Conventions; -using Microsoft.AspNetCore.Routing; - -/// -/// Defines the behavior of a versioned API builder. -/// -[CLSCompliant( false )] -public interface IVersionedApiBuilder : IDeclareApiVersionConventionBuilder -{ - /// - /// Gets the provider used to resolve services. - /// - /// The used to resolve services. - IServiceProvider ServiceProvider { get; } - - /// - /// Gets a collection of endpoint data sources. - /// - /// A collection of endpoint data sources. - ICollection DataSources { get; } - - /// - /// Gets the name of the API. - /// - /// The API name, if specified. - string? Name { get; } - - /// - /// Gets or sets a value indicating whether requests report the API version compatibility information in responses. - /// - /// True if API versions are reported; otherwise, false. - bool ReportApiVersions { get; set; } - - /// - /// Configures the endpoint mappings for a versioned API. - /// - /// The builder used to map endpoints. - void HasMapping( Action api ); - - /// - /// Builds and returns a new API version model. - /// - /// A new API version model. - ApiVersionModel Build(); -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IVersionedEndpointBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IVersionedEndpointBuilder.cs deleted file mode 100644 index 0c057cac..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IVersionedEndpointBuilder.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing.Patterns; - -/// -/// Defines the behavior of a builder for versioned endpoints. -/// -[CLSCompliant( false )] -public interface IVersionedEndpointBuilder -{ - /// - /// Adds an endpoint to the collection that matches HTTP requests for the specified pattern. - /// - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - IEndpointMetadataBuilder Map( RoutePattern pattern, RequestDelegate requestDelegate ); - - /// - /// Adds an endpoint to the collection that matches HTTP requests for the specified HTTP methods and pattern. - /// - /// The route pattern. - /// HTTP methods that the endpoint will match. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - IEndpointMetadataBuilder MapMethods( string pattern, IEnumerable httpMethods, Delegate handler ); -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/ProducesResponseMetadataBuilder{TBuilder}.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/ProducesResponseMetadataBuilder{TBuilder}.cs deleted file mode 100644 index e419c521..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/ProducesResponseMetadataBuilder{TBuilder}.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Metadata; - -/// -/// Represents a builder for . -/// -/// The type of . -[CLSCompliant( false )] -public class ProducesResponseMetadataBuilder - where TBuilder : IEndpointConventionBuilder -{ - private Type? responseType; - private string? contentType; - private string[]? additionalContentTypes; - - /// - /// Initializes a new instance of the class. - /// - /// The associated endpoint builder. - /// The configured status code. - public ProducesResponseMetadataBuilder( TBuilder builder, int statusCode ) - { - Builder = builder; - StatusCode = statusCode; - } - - /// - /// Gets the associated endpoint builder. - /// - /// The associated endpoint builder. - protected TBuilder Builder { get; } - - /// - /// Gets the configured status code. - /// - /// The configured status code. - protected int StatusCode { get; } - - /// - /// Adds the type of response that will be returned. - /// - /// The type of response body. - /// The original instance. - public virtual ProducesResponseMetadataBuilder Body() - { - responseType = typeof( TBody ); - return this; - } - - /// - /// Adds the content types that the response will be formatted as. - /// - /// The response content type. Defaults to "application/json". - /// Additional response content types the endpoint produces. - /// The original instance. - public virtual ProducesResponseMetadataBuilder FormattedAs( - string contentType, - params string[] additionalContentTypes ) - { - this.contentType = contentType; - this.additionalContentTypes = additionalContentTypes; - return this; - } - - /// - /// Builds the underlying . - /// - public virtual void Build() => - Builder.Produces( - StatusCode, - responseType, - contentType, - additionalContentTypes ?? Array.Empty() ); -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedApiBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedApiBuilder.cs deleted file mode 100644 index adeb7610..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedApiBuilder.cs +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Asp.Versioning; -using Asp.Versioning.Conventions; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -/// -/// Represents a builder for versions applied to an API. -/// -[CLSCompliant( false )] -public class VersionedApiBuilder : ApiVersionConventionBuilderBase, IVersionedApiBuilder -{ - /// - /// Initializes a new instance of the class. - /// - /// The underlying endpoint route builder. - /// The optional display name of the API. - public VersionedApiBuilder( IEndpointRouteBuilder routeBuilder, string? name = default ) - { - RouteBuilder = routeBuilder; - Name = name; - } - - /// - public IServiceProvider ServiceProvider => RouteBuilder.ServiceProvider; - - /// - public ICollection DataSources => RouteBuilder.DataSources; - - /// - public bool ReportApiVersions { get; set; } - - /// - public string? Name { get; } - - /// - /// Gets the underlying endpoint route builder. - /// - /// The underlying endpoint route builder. - protected IEndpointRouteBuilder RouteBuilder { get; } - - /// - /// Configures the endpoint mappings for a versioned API. - /// - /// The action used to map endpoints using the provided - /// builder. - public virtual void HasMapping( Action map ) - { - if ( map == null ) - { - throw new ArgumentNullException( nameof( map ) ); - } - - map( NewEndpointBuilder() ); - } - - /// - public virtual ApiVersionModel Build() - { - if ( VersionNeutral ) - { - return ApiVersionModel.Neutral; - } - - if ( IsEmpty ) - { - var options = RouteBuilder.ServiceProvider.GetRequiredService>().Value; - return new( options.DefaultApiVersion ); - } - - return new( - declaredVersions: SupportedVersions.Union( DeprecatedVersions ), - SupportedVersions, - DeprecatedVersions, - AdvertisedVersions, - DeprecatedAdvertisedVersions ); - } - - /// - /// Indicates that the controller is API version-neutral. - /// - /// The original . - public virtual VersionedApiBuilder IsApiVersionNeutral() - { - VersionNeutral = true; - return this; - } - - /// - /// Indicates that the specified API version is supported by the configured controller. - /// - /// The supported API version implemented by the controller. - /// The original . - public virtual VersionedApiBuilder HasApiVersion( ApiVersion apiVersion ) - { - SupportedVersions.Add( apiVersion ); - return this; - } - - /// - /// Indicates that the specified API version is deprecated by the configured controller. - /// - /// The deprecated API version implemented by the controller. - /// The original . - public virtual VersionedApiBuilder HasDeprecatedApiVersion( ApiVersion apiVersion ) - { - DeprecatedVersions.Add( apiVersion ); - return this; - } - - /// - /// Indicates that the specified API version is advertised by the configured controller. - /// - /// The advertised API version not directly implemented by the controller. - /// The original . - public virtual VersionedApiBuilder AdvertisesApiVersion( ApiVersion apiVersion ) - { - AdvertisedVersions.Add( apiVersion ); - return this; - } - - /// - /// Indicates that the specified API version is advertised and deprecated by the configured controller. - /// - /// The advertised, but deprecated API version not directly implemented by the controller. - /// The original . - public virtual VersionedApiBuilder AdvertisesDeprecatedApiVersion( ApiVersion apiVersion ) - { - DeprecatedAdvertisedVersions.Add( apiVersion ); - return this; - } - - /// - /// Creates and returns a new endpoint builder. - /// - /// A new Minimal API endpoint builder. - protected virtual VersionedEndpointBuilder NewEndpointBuilder() => new( this ); - - void IDeclareApiVersionConventionBuilder.IsApiVersionNeutral() => IsApiVersionNeutral(); - - void IDeclareApiVersionConventionBuilder.HasApiVersion( ApiVersion apiVersion ) => HasApiVersion( apiVersion ); - - void IDeclareApiVersionConventionBuilder.HasDeprecatedApiVersion( ApiVersion apiVersion ) => HasDeprecatedApiVersion( apiVersion ); - - void IDeclareApiVersionConventionBuilder.AdvertisesApiVersion( ApiVersion apiVersion ) => AdvertisesApiVersion( apiVersion ); - - void IDeclareApiVersionConventionBuilder.AdvertisesDeprecatedApiVersion( ApiVersion apiVersion ) => AdvertisesDeprecatedApiVersion( apiVersion ); - - void IVersionedApiBuilder.HasMapping( Action map ) - { - if ( map == null ) - { - throw new ArgumentNullException( nameof( map ) ); - } - - map( NewEndpointBuilder() ); - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointBuilder.cs deleted file mode 100644 index 1dc9a1b3..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointBuilder.cs +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.Patterns; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using System; -using System.Reflection; -using System.Runtime.CompilerServices; - -/// -/// Represents a Minimal API endpoint builder. -/// -[CLSCompliant( false )] -public class VersionedEndpointBuilder : IVersionedEndpointBuilder -{ - /// - /// Initializes a new instance of the class. - /// - /// The underlying Minimal API builder. - public VersionedEndpointBuilder( IVersionedApiBuilder builder ) => ApiBuilder = builder; - - /// - /// Gets the underlying Minimal API builder. - /// - /// The underlying Minimal API builder. - protected IVersionedApiBuilder ApiBuilder { get; } - - private VersionedEndpointDataSource DataSource - { - get - { - var sources = ApiBuilder.DataSources; - - if ( sources.OfType().FirstOrDefault() is not VersionedEndpointDataSource source ) - { - sources.Add( source = new() ); - } - - return source; - } - } - - /// - /// Adds an endpoint to the collection that matches HTTP requests for the specified pattern. - /// - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public virtual EndpointMetadataBuilder Map( RoutePattern pattern, RequestDelegate requestDelegate ) - { - if ( requestDelegate == null ) - { - throw new ArgumentNullException( nameof( requestDelegate ) ); - } - - if ( pattern == null ) - { - throw new ArgumentNullException( nameof( pattern ) ); - } - - var endpointBuilder = new RouteEndpointBuilder( requestDelegate, pattern, order: default ) - { - DisplayName = ApiBuilder.Name ?? pattern.RawText, - }; - var conventionBuilder = DataSource.Add( endpointBuilder ); - var metadataBuilder = new EndpointMetadataBuilder( ApiBuilder, conventionBuilder ); - - conventionBuilder.Add( new VersionedEndpointMetadataConvention( metadataBuilder ) ); - - if ( requestDelegate.Method.GetCustomAttributes() is IEnumerable attributes ) - { - foreach ( var attribute in attributes ) - { - endpointBuilder.Metadata.Add( attribute ); - } - } - - return metadataBuilder; - } - - /// - /// Adds an endpoint to the collection that matches HTTP requests for the specified HTTP methods and pattern. - /// - /// The route pattern. - /// HTTP methods that the endpoint will match. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public virtual EndpointMetadataBuilder MapMethods( string pattern, IEnumerable httpMethods, Delegate handler ) - { - if ( httpMethods is null ) - { - throw new ArgumentNullException( nameof( httpMethods ) ); - } - - if ( handler is null ) - { - throw new ArgumentNullException( nameof( handler ) ); - } - - var routePattern = RoutePatternFactory.Parse( pattern ); - var routeParams = new List( routePattern.Parameters.Count ); - - for ( var i = 0; i < routePattern.Parameters.Count; i++ ) - { - routeParams.Add( routePattern.Parameters[i].Name ); - } - - var services = ApiBuilder.ServiceProvider; - var routeHandlerOptions = services.GetService>(); - var disableInferBodyFromParameters = httpMethods.Any( ShouldDisableInferredBody ); - var options = new RequestDelegateFactoryOptions() - { - ServiceProvider = services, - RouteParameterNames = routeParams, - ThrowOnBadRequest = routeHandlerOptions?.Value.ThrowOnBadRequest ?? false, - DisableInferBodyFromParameters = disableInferBodyFromParameters, - }; - var requestDelegateResult = RequestDelegateFactory.Create( handler, options ); - var endpointBuilder = new RouteEndpointBuilder( requestDelegateResult.RequestDelegate, routePattern, order: default ) - { - DisplayName = ApiBuilder.Name ?? routePattern.RawText ?? pattern, - }; - var conventionBuilder = DataSource.Add( endpointBuilder ); - var metadataBuilder = new EndpointMetadataBuilder( ApiBuilder, conventionBuilder ); - - conventionBuilder.Add( new VersionedEndpointMetadataConvention( metadataBuilder ) ); - endpointBuilder.Metadata.Add( handler.Method ); - endpointBuilder.Metadata.Add( new HttpMethodMetadata( httpMethods ) ); - - for ( var i = 0; i < requestDelegateResult.EndpointMetadata.Count; i++ ) - { - endpointBuilder.Metadata.Add( requestDelegateResult.EndpointMetadata[i] ); - } - - if ( handler.Method.GetCustomAttributes() is IEnumerable attributes ) - { - foreach ( var attribute in attributes ) - { - endpointBuilder.Metadata.Add( attribute ); - } - } - - return metadataBuilder; - } - - /// - /// Determines whether inferring the HTTP body should be disabled. - /// - /// The HTTP method to evaluate. - /// True if the HTTP body should not be inferred; otherwise, false. - protected static bool ShouldDisableInferredBody( string method ) - { - if ( string.IsNullOrEmpty( method ) ) - { - throw new ArgumentNullException( nameof( method ) ); - } - - // GET, DELETE, HEAD, CONNECT, TRACE, and OPTIONS normally do not contain bodies - return method.Equals( HttpMethods.Get, StringComparison.Ordinal ) || - method.Equals( HttpMethods.Delete, StringComparison.Ordinal ) || - method.Equals( HttpMethods.Head, StringComparison.Ordinal ) || - method.Equals( HttpMethods.Options, StringComparison.Ordinal ) || - method.Equals( HttpMethods.Trace, StringComparison.Ordinal ) || - method.Equals( HttpMethods.Connect, StringComparison.Ordinal ); - } - - /// - /// Gets the endpoint name for the specified method. - /// - /// The method to get the name for. - /// The endpoint name. - protected static string GetEndpointName( MethodInfo method ) - { - if ( method == null ) - { - throw new ArgumentNullException( nameof( method ) ); - } - - if ( TryParseLocalFunctionName( method.Name, out var endpointName ) ) - { - return endpointName; - } - - return method.Name; - } - - private static bool TryParseLocalFunctionName( string generatedName, [NotNullWhen( true )] out string? originalName ) - { - var startIndex = generatedName.LastIndexOf( ">g__", StringComparison.Ordinal ); - var endIndex = generatedName.LastIndexOf( "|", StringComparison.Ordinal ); - - if ( startIndex >= 0 && endIndex >= 0 && endIndex - startIndex > 4 ) - { - originalName = generatedName.Substring( startIndex + 4, endIndex - startIndex - 4 ); - return true; - } - - originalName = null; - return false; - } - - /// - /// Determines whether the specified method is compiler-generated. - /// - /// The method to evaluate. - /// True if the is compiler-generated; otherwise, false. - protected static bool IsCompilerGeneratedMethod( MethodInfo method ) - { - if ( method == null ) - { - throw new ArgumentNullException( nameof( method ) ); - } - - return Attribute.IsDefined( method, typeof( CompilerGeneratedAttribute ) ) || IsCompilerGeneratedType( method.DeclaringType ); - } - - private static bool IsCompilerGeneratedType( Type? type = null ) => - type is not null && ( Attribute.IsDefined( type, typeof( CompilerGeneratedAttribute ) ) || IsCompilerGeneratedType( type.DeclaringType ) ); - - IEndpointMetadataBuilder IVersionedEndpointBuilder.Map( RoutePattern pattern, RequestDelegate requestDelegate ) => Map( pattern, requestDelegate ); - - IEndpointMetadataBuilder IVersionedEndpointBuilder.MapMethods( string pattern, IEnumerable httpMethods, Delegate handler ) => MapMethods( pattern, httpMethods, handler ); -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointBuilderExtensions.cs deleted file mode 100644 index e5dc9af9..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointBuilderExtensions.cs +++ /dev/null @@ -1,217 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Microsoft.AspNetCore.Builder; - -using Asp.Versioning.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -/// -/// Provides extension methods for endpoint route builders. -/// -[CLSCompliant( false )] -public static class VersionedEndpointBuilderExtensions -{ - private static readonly string[] GetVerb = new[] { HttpMethods.Get }; - private static readonly string[] PostVerb = new[] { HttpMethods.Post }; - private static readonly string[] PutVerb = new[] { HttpMethods.Put }; - private static readonly string[] DeleteVerb = new[] { HttpMethods.Delete }; - private static readonly string[] PatchVerb = new[] { HttpMethods.Patch }; - - /// - /// Defines a new API using the specified endpoint route builder. - /// - /// The extend endpoint route builder. - /// The optional name of the API. - /// A new Minimal API convention builder. - public static VersionedApiBuilder DefineApi( this IEndpointRouteBuilder endpoints, string? name = default ) => new( endpoints, name ); - - /// - /// Configures the API to report compatible versions. - /// - /// The extended type of . - /// The extended Minimal API convention builder. - /// The original . - public static T ReportApiVersions( this T builder ) - where T : notnull, IVersionedApiBuilder - { - builder.ReportApiVersions = true; - return builder; - } - - /// - /// Adds an endpoint to the builder that matches HTTP GET requests for the specified pattern. - /// - /// the builder to add the route to. - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static EndpointMetadataBuilder MapGet( this VersionedEndpointBuilder builder, string pattern, RequestDelegate requestDelegate ) - { - if ( builder == null ) - { - throw new ArgumentNullException( nameof( builder ) ); - } - - return builder.MapMethods( pattern, GetVerb, requestDelegate ); - } - - /// - /// Adds an endpoint to the builder that matches HTTP GET requests for the specified pattern. - /// - /// the builder to add the route to. - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static EndpointMetadataBuilder MapGet( this VersionedEndpointBuilder builder, string pattern, Delegate handler ) - { - if ( builder == null ) - { - throw new ArgumentNullException( nameof( builder ) ); - } - - return builder.MapMethods( pattern, GetVerb, handler ); - } - - /// - /// Adds an endpoint to the builder that matches HTTP POST requests for the specified pattern. - /// - /// the builder to add the route to. - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static EndpointMetadataBuilder MapPost( this VersionedEndpointBuilder builder, string pattern, RequestDelegate requestDelegate ) - { - if ( builder == null ) - { - throw new ArgumentNullException( nameof( builder ) ); - } - - return builder.MapMethods( pattern, PostVerb, requestDelegate ); - } - - /// - /// Adds an endpoint to the builder that matches HTTP POST requests for the specified pattern. - /// - /// the builder to add the route to. - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static EndpointMetadataBuilder MapPost( this VersionedEndpointBuilder builder, string pattern, Delegate handler ) - { - if ( builder == null ) - { - throw new ArgumentNullException( nameof( builder ) ); - } - - return builder.MapMethods( pattern, PostVerb, handler ); - } - - /// - /// Adds an endpoint to the builder that matches HTTP PUT requests - /// for the specified pattern. - /// - /// the builder to add the route to. - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static EndpointMetadataBuilder MapPut( this VersionedEndpointBuilder builder, string pattern, RequestDelegate requestDelegate ) - { - if ( builder == null ) - { - throw new ArgumentNullException( nameof( builder ) ); - } - - return builder.MapMethods( pattern, PutVerb, requestDelegate ); - } - - /// - /// Adds an endpoint to the builder that matches HTTP PUT requests - /// for the specified pattern. - /// - /// the builder to add the route to. - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static EndpointMetadataBuilder MapPut( this VersionedEndpointBuilder builder, string pattern, Delegate handler ) - { - if ( builder == null ) - { - throw new ArgumentNullException( nameof( builder ) ); - } - - return builder.MapMethods( pattern, PutVerb, handler ); - } - - /// - /// Adds an endpoint to the builder that matches HTTP DELETE requests - /// for the specified pattern. - /// - /// the builder to add the route to. - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static EndpointMetadataBuilder MapDelete( this VersionedEndpointBuilder builder, string pattern, RequestDelegate requestDelegate ) - { - if ( builder == null ) - { - throw new ArgumentNullException( nameof( builder ) ); - } - - return builder.MapMethods( pattern, DeleteVerb, requestDelegate ); - } - - /// - /// Adds an endpoint to the builder that matches HTTP DELETE requests - /// for the specified pattern. - /// - /// the builder to add the route to. - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static EndpointMetadataBuilder MapDelete( this VersionedEndpointBuilder builder, string pattern, Delegate handler ) - { - if ( builder == null ) - { - throw new ArgumentNullException( nameof( builder ) ); - } - - return builder.MapMethods( pattern, DeleteVerb, handler ); - } - - /// - /// Adds an endpoint to the builder that matches HTTP PATCH requests - /// for the specified pattern. - /// - /// the builder to add the route to. - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static EndpointMetadataBuilder MapPatch( this VersionedEndpointBuilder builder, string pattern, RequestDelegate requestDelegate ) - { - if ( builder == null ) - { - throw new ArgumentNullException( nameof( builder ) ); - } - - return builder.MapMethods( pattern, PatchVerb, requestDelegate ); - } - - /// - /// Adds an endpoint to the builder that matches HTTP PATCH requests - /// for the specified pattern. - /// - /// the builder to add the route to. - /// The route pattern. - /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static EndpointMetadataBuilder MapPatch( this VersionedEndpointBuilder builder, string pattern, Delegate handler ) - { - if ( builder == null ) - { - throw new ArgumentNullException( nameof( builder ) ); - } - - return builder.MapMethods( pattern, PatchVerb, handler ); - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointDataSource.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointDataSource.cs deleted file mode 100644 index 2a5081e6..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointDataSource.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Primitives; - -internal sealed class VersionedEndpointDataSource : EndpointDataSource -{ - private List? builders; - - internal IEndpointConventionBuilder Add( EndpointBuilder endpointBuilder ) - { - var builder = new DefaultEndpointConventionBuilder( endpointBuilder ); - - builders ??= new(); - builders.Add( builder ); - - return builder; - } - - public override IReadOnlyList Endpoints - { - get - { - if ( builders == null ) - { - return Array.Empty(); - } - - return builders.Select( b => b.Build() ).ToArray(); - } - } - - public override IChangeToken GetChangeToken() => NullChangeToken.Singleton; -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointMetadataConvention.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointMetadataConvention.cs deleted file mode 100644 index 4e74fb71..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointMetadataConvention.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Asp.Versioning.Routing; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using System.Globalization; -using static Asp.Versioning.ApiVersionParameterLocation; - -internal sealed class VersionedEndpointMetadataConvention -{ - private readonly IEndpointMetadataBuilder metadataBuilder; - - public VersionedEndpointMetadataConvention( IEndpointMetadataBuilder metadataBuilder ) => this.metadataBuilder = metadataBuilder; - - public static implicit operator Action( VersionedEndpointMetadataConvention convention ) => convention.Apply; - - private static RequestDelegate EnsureRequestDelegate( RequestDelegate? current, RequestDelegate? original ) => - ( current ?? original ) ?? - throw new InvalidOperationException( - string.Format( - CultureInfo.CurrentCulture, - SR.UnsetRequestDelegate, - nameof( RequestDelegate ), - nameof( RouteEndpoint ) ) ); - - private void Apply( EndpointBuilder endpointBuilder ) - { - var requestDelegate = default( RequestDelegate ); - var metadata = metadataBuilder.Build(); - var services = metadataBuilder.ServiceProvider; - var source = services.GetRequiredService(); - var options = services.GetRequiredService>().Value; - - endpointBuilder.Metadata.Add( metadata ); - - if ( metadataBuilder.ReportApiVersions || options.ReportApiVersions ) - { - requestDelegate = EnsureRequestDelegate( requestDelegate, endpointBuilder.RequestDelegate ); - requestDelegate = new ReportApiVersionsDecorator( requestDelegate, metadata ); - endpointBuilder.RequestDelegate = requestDelegate; - } - - if ( source.VersionsByMediaType() ) - { - var parameterName = source.GetParameterName( MediaTypeParameter ); - requestDelegate = EnsureRequestDelegate( requestDelegate, endpointBuilder.RequestDelegate ); - requestDelegate = new ContentTypeApiVersionDecorator( requestDelegate, parameterName ); - endpointBuilder.RequestDelegate = requestDelegate; - } - } -} \ No newline at end of file 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 5a0e87b9..be78b149 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs @@ -3,6 +3,9 @@ namespace Microsoft.Extensions.DependencyInjection; using Asp.Versioning; +#if !NETCOREAPP3_1 +using Asp.Versioning.Builder; +#endif using Asp.Versioning.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -47,6 +50,9 @@ private static void AddApiVersioningServices( IServiceCollection services ) throw new ArgumentNullException( nameof( services ) ); } +#if !NETCOREAPP3_1 + services.TryAddSingleton(); +#endif services.TryAddSingleton(); services.TryAddSingleton(); services.Add( Singleton( sp => sp.GetRequiredService>().Value.ApiVersionReader ) ); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/IApiVersioningBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/IApiVersioningBuilder.cs index 7cdfa19a..09b48c5b 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/IApiVersioningBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/IApiVersioningBuilder.cs @@ -7,7 +7,7 @@ namespace Asp.Versioning; /// /// Defines the behavior for configuring API versioning. /// -public interface IApiVersioningBuilder +public partial interface IApiVersioningBuilder { /// /// Gets the services used when configuring API versioning. 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 81dce11a..985c76b0 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs @@ -78,6 +78,15 @@ internal static string ConventionAddedAfterEndpointBuilt { } } + /// + /// Looks up a localized string similar to The required services have not been provided to the EndointBuilder.. + /// + internal static string NoEndpointBuilderServices { + get { + return ResourceManager.GetString("NoEndpointBuilderServices", resourceCulture); + } + } + /// /// Looks up a localized string similar to The request type was not configured.. /// diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx index 44f12e69..758f02bb 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx @@ -123,6 +123,9 @@ Conventions cannot be added after building the endpoint. + + The required services have not been provided to the EndointBuilder. + The request type was not configured. diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SunsetPolicyManager.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SunsetPolicyManager.cs index aa16d930..35f92421 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SunsetPolicyManager.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SunsetPolicyManager.cs @@ -15,5 +15,8 @@ public partial class SunsetPolicyManager /// Initializes a new instance of the class. /// /// The associated API versioning options. +#if NETCOREAPP3_1 + [CLSCompliant( false )] +#endif public SunsetPolicyManager( IOptions options ) => this.options = options; } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/ApiVersionSet.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/ApiVersionSet.cs new file mode 100644 index 00000000..0ad9f827 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/ApiVersionSet.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Builder; + +/// +/// Represents an API version set. +/// +public class ApiVersionSet +{ + /// + /// Initializes a new instance of the class. + /// + /// The associated builder. + /// The optional API name. + public ApiVersionSet( ApiVersionSetBuilder builder, string? name ) + { + Builder = builder ?? throw new ArgumentNullException( nameof( builder ) ); + Name = name; + ReportApiVersions = builder.WillReportApiVersions; + } + + /// + /// Gets the configured API name, if any. + /// + /// The configured API name or null. + public string? Name { get; } + + /// + /// Gets a value indicating whether all APIs in the version set will report their API versions. + /// + /// True if all APIs in the version set will report their API versions; otherwise, false. + public bool ReportApiVersions { get; } + + // intentionally internal for 6.0, until EndpointBuilder.ServiceProvider is exposed in 7.0 + // REF: https://github.com/dotnet/aspnetcore/pull/41238/files#diff-f8807c470bcc3a077fb176668a46df57b4bb99c992b6b7b375665f8bf3903c94R510 + internal IServiceProvider? ServiceProvider { get; set; } + + /// + /// Gets the associated builder. + /// + /// The associated builder. + protected ApiVersionSetBuilder Builder { get; } + + /// + /// Builds and returns the API version model for the version set. + /// + /// The configured API versioning options. + /// A new API version model. + public virtual ApiVersionModel Build( ApiVersioningOptions options ) => Builder.BuildApiVersionModel( options ); + + /// + /// Advertises that the specified API version is supported in the version set. + /// + /// The advertised API version. + public virtual void AdvertisesApiVersion( ApiVersion apiVersion ) => + Builder.AdvertisesApiVersion( apiVersion ); + + /// + /// Advertises that the specified API version is deprecated in the version set. + /// + /// The advertised, but deprecated API version. + public virtual void AdvertisesDeprecatedApiVersion( ApiVersion apiVersion ) => + Builder.AdvertisesDeprecatedApiVersion( apiVersion ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/ApiVersionSetBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/ApiVersionSetBuilder.cs new file mode 100644 index 00000000..2678f5b2 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/ApiVersionSetBuilder.cs @@ -0,0 +1,139 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Builder; + +using Asp.Versioning.Conventions; + +/// +/// Represents the builder for an API version set. +/// +public class ApiVersionSetBuilder : ApiVersionConventionBuilderBase, IDeclareApiVersionConventionBuilder +{ + private readonly string? name; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the API, if any. + public ApiVersionSetBuilder( string? name ) => this.name = name; + + // intentionally internal for 6.0, until EndpointBuilder.ServiceProvider is exposed in 7.0 + // REF: https://github.com/dotnet/aspnetcore/pull/41238/files#diff-f8807c470bcc3a077fb176668a46df57b4bb99c992b6b7b375665f8bf3903c94R510 + internal IServiceProvider? ServiceProvider { get; set; } + + /// + /// Gets or sets a value indicating whether requests report the API version compatibility information in responses. + /// + /// True if API versions are reported; otherwise, false. + protected internal bool WillReportApiVersions { get; set; } + + /// + /// Builds and returns a new API versioning configuration. + /// + /// A new API versioning configuration. + public virtual ApiVersionSet Build() => new( this, name ) { ServiceProvider = ServiceProvider }; + + /// + /// Indicates that all APIs in the version set will report their versions. + /// + /// The original instance. + public virtual ApiVersionSetBuilder ReportApiVersions() + { + WillReportApiVersions = true; + return this; + } + + /// + /// Indicates that all APIs in the version set are API version-neutral. + /// + /// The original . + public virtual ApiVersionSetBuilder IsApiVersionNeutral() + { + VersionNeutral = true; + return this; + } + + /// + /// Indicates that the specified API version is supported by all APIs in the version set. + /// + /// The supported API version. + /// The original . + public virtual ApiVersionSetBuilder HasApiVersion( ApiVersion apiVersion ) + { + SupportedVersions.Add( apiVersion ); + return this; + } + + /// + /// Indicates that the specified API version is deprecated by all APIs in the version set. + /// + /// The deprecated API version. + /// The original . + public virtual ApiVersionSetBuilder HasDeprecatedApiVersion( ApiVersion apiVersion ) + { + DeprecatedVersions.Add( apiVersion ); + return this; + } + + /// + /// Indicates that the specified API version is advertised by all APIs in the version set. + /// + /// The advertised API version. + /// The original . + public virtual ApiVersionSetBuilder AdvertisesApiVersion( ApiVersion apiVersion ) + { + AdvertisedVersions.Add( apiVersion ); + return this; + } + + /// + /// Indicates that the specified API version is advertised and deprecated by all APIs in the version set. + /// + /// The advertised, but deprecated API version. + /// The original . + public virtual ApiVersionSetBuilder AdvertisesDeprecatedApiVersion( ApiVersion apiVersion ) + { + DeprecatedAdvertisedVersions.Add( apiVersion ); + return this; + } + + void IDeclareApiVersionConventionBuilder.IsApiVersionNeutral() => IsApiVersionNeutral(); + + void IDeclareApiVersionConventionBuilder.HasApiVersion( ApiVersion apiVersion ) => HasApiVersion( apiVersion ); + + void IDeclareApiVersionConventionBuilder.HasDeprecatedApiVersion( ApiVersion apiVersion ) => HasDeprecatedApiVersion( apiVersion ); + + void IDeclareApiVersionConventionBuilder.AdvertisesApiVersion( ApiVersion apiVersion ) => AdvertisesApiVersion( apiVersion ); + + void IDeclareApiVersionConventionBuilder.AdvertisesDeprecatedApiVersion( ApiVersion apiVersion ) => AdvertisesDeprecatedApiVersion( apiVersion ); + + /// + /// Builds and returns an API version model. + /// + /// The configured API versioning options. + /// A new API version model. + protected internal virtual ApiVersionModel BuildApiVersionModel( ApiVersioningOptions options ) + { + if ( options == null ) + { + throw new ArgumentNullException( nameof( options ) ); + } + + if ( VersionNeutral ) + { + return ApiVersionModel.Neutral; + } + + if ( IsEmpty ) + { + return new( options.DefaultApiVersion ); + } + + return new( + declaredVersions: SupportedVersions.Union( DeprecatedVersions ), + SupportedVersions, + DeprecatedVersions, + AdvertisedVersions, + DeprecatedAdvertisedVersions ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/DefaultApiVersionSetBuilderFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/DefaultApiVersionSetBuilderFactory.cs new file mode 100644 index 00000000..ca426800 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/DefaultApiVersionSetBuilderFactory.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Builder; + +/// +/// Represents the default API version set builder factory. +/// +public class DefaultApiVersionSetBuilderFactory : IApiVersionSetBuilderFactory +{ + private readonly IServiceProvider serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying service provider. + public DefaultApiVersionSetBuilderFactory( IServiceProvider serviceProvider ) => + this.serviceProvider = serviceProvider; + + /// + public ApiVersionSetBuilder Create( string? name = default ) + { + var instance = CreateInstance( name ); + instance.ServiceProvider = serviceProvider; + return instance; + } + + /// + /// Creates and returns a new builder instance. + /// + /// The optional name associated with the builder. + /// A new API version set builder. + protected virtual ApiVersionSetBuilder CreateInstance( string? name ) => new( name ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IApiVersionSetBuilderFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IApiVersionSetBuilderFactory.cs new file mode 100644 index 00000000..83a21989 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IApiVersionSetBuilderFactory.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +#pragma warning disable + +namespace Asp.Versioning.Builder; + +using Asp.Versioning.Conventions; +using Asp.Versioning.Routing; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using System.Globalization; +using static Asp.Versioning.ApiVersionParameterLocation; + +/// +/// Defines the behavior of a factory to create API version set builders. +/// +public interface IApiVersionSetBuilderFactory +{ + /// + /// Creates and returns a new API version set builder. + /// + /// The name of the API associated with the builder, if any. + /// A new API version set builder. + ApiVersionSetBuilder Create( string? name = default ); +} diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IEndpointConventionBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IEndpointConventionBuilderExtensions.cs new file mode 100644 index 00000000..bcedd700 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IEndpointConventionBuilderExtensions.cs @@ -0,0 +1,117 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Microsoft.AspNetCore.Builder; + +using Asp.Versioning; +using Asp.Versioning.Builder; +using Asp.Versioning.Routing; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Globalization; +using static Asp.Versioning.ApiVersionParameterLocation; + +/// +/// Provides extension methods for and . +/// +[CLSCompliant( false )] +public static class IEndpointConventionBuilderExtensions +{ + /// + /// Applies the specified API version set to the endpoint. + /// + /// The extended builder. + /// The API version set the endpoint will use. + /// A new instance. + /// If the specified already implements , + /// then that instance will be returned instead. + public static IVersionedEndpointConventionBuilder WithApiVersionSet( + this IEndpointConventionBuilder builder, + ApiVersionSet apiVersionSet ) + { + if ( builder == null ) + { + throw new ArgumentNullException( nameof( builder ) ); + } + + if ( apiVersionSet == null ) + { + throw new ArgumentNullException( nameof( apiVersionSet ) ); + } + + if ( builder is IVersionedEndpointConventionBuilder versionedBuilder ) + { + return versionedBuilder; + } + + versionedBuilder = new VersionedEndpointConventionBuilder( builder, apiVersionSet ); + builder.Add( endpoints => Apply( endpoints, versionedBuilder, apiVersionSet ) ); + + return versionedBuilder; + } + + /// + /// Indicates that the endpoint will report its API versions. + /// + /// The type of . + /// The extended builder. + /// The original . + public static TBuilder ReportApiVersions( this TBuilder builder ) + where TBuilder : notnull, IVersionedEndpointConventionBuilder + { + if ( builder == null ) + { + throw new ArgumentNullException( nameof( builder ) ); + } + + builder.ReportApiVersions = true; + return builder; + } + + private static void Apply( + EndpointBuilder endpointBuilder, + IVersionedEndpointConventionBuilder conventions, + ApiVersionSet versionSet ) + { + // this will change to EndpointBuilder.ServiceProvider in 7.0 + // REF: https://github.com/dotnet/aspnetcore/pull/41238/files#diff-f8807c470bcc3a077fb176668a46df57b4bb99c992b6b7b375665f8bf3903c94R510 + if ( versionSet.ServiceProvider is not IServiceProvider services ) + { + throw new InvalidOperationException( SR.NoEndpointBuilderServices ); + } + + var parameterSource = services.GetRequiredService(); + var options = services.GetRequiredService>().Value; + var requestDelegate = default( RequestDelegate ); + var metadata = conventions.Build( options ); + + endpointBuilder.Metadata.Add( metadata ); + + if ( options.ReportApiVersions || + versionSet.ReportApiVersions || + conventions.ReportApiVersions ) + { + requestDelegate = EnsureRequestDelegate( requestDelegate, endpointBuilder.RequestDelegate ); + requestDelegate = new ReportApiVersionsDecorator( requestDelegate, metadata ); + endpointBuilder.RequestDelegate = requestDelegate; + } + + if ( parameterSource.VersionsByMediaType() ) + { + var parameterName = parameterSource.GetParameterName( MediaTypeParameter ); + requestDelegate = EnsureRequestDelegate( requestDelegate, endpointBuilder.RequestDelegate ); + requestDelegate = new ContentTypeApiVersionDecorator( requestDelegate, parameterName ); + endpointBuilder.RequestDelegate = requestDelegate; + } + } + + private static RequestDelegate EnsureRequestDelegate( RequestDelegate? current, RequestDelegate? original ) => + ( current ?? original ) ?? + throw new InvalidOperationException( + string.Format( + CultureInfo.CurrentCulture, + SR.UnsetRequestDelegate, + nameof( RequestDelegate ), + nameof( RouteEndpoint ) ) ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IEndpointRouteBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IEndpointRouteBuilderExtensions.cs new file mode 100644 index 00000000..65664447 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IEndpointRouteBuilderExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Microsoft.AspNetCore.Builder; + +using Asp.Versioning.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Provides extension methods for . +/// +[CLSCompliant( false )] +public static class IEndpointRouteBuilderExtensions +{ + /// + /// Creates and returns a new API version set builder for the specified endpoints. + /// + /// The extended . + /// The optional name of the API. + /// A new API version set builder. + public static ApiVersionSetBuilder NewApiVersionSet( this IEndpointRouteBuilder endpoints, string? name = default ) + { + if ( endpoints == null ) + { + throw new ArgumentNullException( nameof( endpoints ) ); + } + + var factory = endpoints.ServiceProvider.GetRequiredService(); + + return factory.Create( name ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointMetadataBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IVersionedEndpointConventionBuilder.cs similarity index 59% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointMetadataBuilder.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IVersionedEndpointConventionBuilder.cs index ee535d59..f0364a41 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointMetadataBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/IVersionedEndpointConventionBuilder.cs @@ -3,18 +3,14 @@ namespace Asp.Versioning.Builder; using Asp.Versioning.Conventions; +using Microsoft.AspNetCore.Builder; /// -/// Defines the behavior of an endpoint metadata builder. +/// Defines the behavior of a versioned . /// -public interface IEndpointMetadataBuilder : IMapToApiVersionConventionBuilder +[CLSCompliant( false )] +public interface IVersionedEndpointConventionBuilder : IEndpointConventionBuilder, IMapToApiVersionConventionBuilder { - /// - /// Gets the provider used to resolve services. - /// - /// The used to resolve services. - IServiceProvider ServiceProvider { get; } - /// /// Gets or sets a value indicating whether requests report the API version compatibility information in responses. /// @@ -24,6 +20,7 @@ public interface IEndpointMetadataBuilder : IMapToApiVersionConventionBuilder /// /// Builds and returns a new API version metadata. /// + /// The configured API versioning options. /// A new API version metadata. - ApiVersionMetadata Build(); + ApiVersionMetadata Build( ApiVersioningOptions options ); } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointMetadataBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/VersionedEndpointConventionBuilder.cs similarity index 62% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointMetadataBuilder.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/VersionedEndpointConventionBuilder.cs index a9986d93..9c7095af 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointMetadataBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Builder/VersionedEndpointConventionBuilder.cs @@ -6,32 +6,36 @@ namespace Asp.Versioning.Builder; using Microsoft.AspNetCore.Builder; /// -/// Represents a metadata builder for API versions applied to an endpoint. +/// Represents a versioned . /// [CLSCompliant( false )] -public class EndpointMetadataBuilder : ApiVersionConventionBuilderBase, IEndpointMetadataBuilder, IEndpointConventionBuilder +public class VersionedEndpointConventionBuilder : + ApiVersionConventionBuilderBase, + IVersionedEndpointConventionBuilder { + private readonly IEndpointConventionBuilder inner; private HashSet? mapped; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The parent . - /// The parent . - public EndpointMetadataBuilder( IVersionedApiBuilder apiBuilder, IEndpointConventionBuilder conventionBuilder ) + /// The inner the new instance decorates. + /// The associated API version set. + public VersionedEndpointConventionBuilder( IEndpointConventionBuilder inner, ApiVersionSet apiVersionSet ) { - ApiBuilder = apiBuilder ?? throw new ArgumentNullException( nameof( apiBuilder ) ); - ConventionBuilder = conventionBuilder ?? throw new ArgumentNullException( nameof( conventionBuilder ) ); - ServiceProvider = apiBuilder.ServiceProvider; - ReportApiVersions = apiBuilder.ReportApiVersions; + this.inner = inner ?? throw new ArgumentNullException( nameof( inner ) ); + VersionSet = apiVersionSet ?? throw new ArgumentNullException( nameof( apiVersionSet ) ); } - /// - public IServiceProvider ServiceProvider { get; } - /// public bool ReportApiVersions { get; set; } + /// + /// Gets the associated API version set. + /// + /// The associated API version set. + protected ApiVersionSet VersionSet { get; } + /// protected override bool IsEmpty => ( mapped is null || mapped.Count == 0 ) && base.IsEmpty; @@ -42,31 +46,96 @@ public EndpointMetadataBuilder( IVersionedApiBuilder apiBuilder, IEndpointConven protected ICollection MappedVersions => mapped ??= new(); /// - /// Gets the underlying versioned API builder. + /// Maps the specified API version to the configured controller action. + /// + /// The API version to map to the action. + /// The original . + public virtual IVersionedEndpointConventionBuilder MapToApiVersion( ApiVersion apiVersion ) + { + MappedVersions.Add( apiVersion ); + return this; + } + + /// + /// Indicates that the action is API version-neutral. /// - /// The underlying versioned API builder. - protected IVersionedApiBuilder ApiBuilder { get; } + /// The original . + public virtual IVersionedEndpointConventionBuilder IsApiVersionNeutral() + { + VersionNeutral = true; + return this; + } /// - /// Gets the underlying endpoint convention builder. + /// Indicates that the specified API version is supported by the configured action. /// - /// The underlying endpoint convention builder. - protected IEndpointConventionBuilder ConventionBuilder { get; } + /// The supported API version implemented by the action. + /// The original . + public virtual IVersionedEndpointConventionBuilder HasApiVersion( ApiVersion apiVersion ) + { + SupportedVersions.Add( apiVersion ); + VersionSet.AdvertisesApiVersion( apiVersion ); + return this; + } + + /// + /// Indicates that the specified API version is deprecated by the configured action. + /// + /// The deprecated API version implemented by the action. + /// The original . + public virtual IVersionedEndpointConventionBuilder HasDeprecatedApiVersion( ApiVersion apiVersion ) + { + DeprecatedVersions.Add( apiVersion ); + VersionSet.AdvertisesDeprecatedApiVersion( apiVersion ); + return this; + } + + /// + /// Indicates that the specified API version is advertised by the configured action. + /// + /// The advertised API version not directly implemented by the action. + /// The original . + public virtual IVersionedEndpointConventionBuilder AdvertisesApiVersion( ApiVersion apiVersion ) + { + AdvertisedVersions.Add( apiVersion ); + VersionSet.AdvertisesApiVersion( apiVersion ); + return this; + } + + /// + /// Indicates that the specified API version is advertised and deprecated by the configured action. + /// + /// The advertised, but deprecated API version not directly implemented by the action. + /// The original . + public virtual IVersionedEndpointConventionBuilder AdvertisesDeprecatedApiVersion( ApiVersion apiVersion ) + { + DeprecatedAdvertisedVersions.Add( apiVersion ); + VersionSet.AdvertisesDeprecatedApiVersion( apiVersion ); + return this; + } /// - public virtual ApiVersionMetadata Build() + public virtual void Add( Action convention ) => inner.Add( convention ); + + /// + /// Builds and returns a new API version metadata. + /// + /// The configured API versioning options. + /// A new API version metadata. + protected virtual ApiVersionMetadata Build( ApiVersioningOptions options ) { + var name = VersionSet.Name; ApiVersionModel? apiModel; - if ( VersionNeutral || ( apiModel = ApiBuilder.Build() ).IsApiVersionNeutral ) + if ( VersionNeutral || ( apiModel = VersionSet.Build( options ) ).IsApiVersionNeutral ) { - if ( string.IsNullOrEmpty( ApiBuilder.Name ) ) + if ( string.IsNullOrEmpty( name ) ) { return ApiVersionMetadata.Neutral; } else { - return new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, ApiBuilder.Name ); + return new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, name ); } } @@ -80,7 +149,7 @@ public virtual ApiVersionMetadata Build() { if ( noInheritedApiVersions ) { - return new( apiModel, ApiVersionModel.Empty, ApiBuilder.Name ); + return new( apiModel, ApiVersionModel.Empty, name ); } emptyVersions = Array.Empty(); @@ -93,7 +162,7 @@ public virtual ApiVersionMetadata Build() inheritedDeprecated, emptyVersions, emptyVersions ), - ApiBuilder.Name ); + name ); } if ( mapped is null || mapped.Count == 0 ) @@ -106,7 +175,7 @@ public virtual ApiVersionMetadata Build() DeprecatedVersions.Union( inheritedDeprecated ), AdvertisedVersions, DeprecatedAdvertisedVersions ), - ApiBuilder.Name ); + name ); } emptyVersions = Array.Empty(); @@ -119,76 +188,10 @@ public virtual ApiVersionMetadata Build() deprecatedVersions: inheritedDeprecated, advertisedVersions: emptyVersions, deprecatedAdvertisedVersions: emptyVersions ), - ApiBuilder.Name ); + name ); } - /// - /// Maps the specified API version to the configured controller action. - /// - /// The API version to map to the action. - /// The original . - public virtual EndpointMetadataBuilder MapToApiVersion( ApiVersion apiVersion ) - { - MappedVersions.Add( apiVersion ); - return this; - } - - /// - /// Indicates that the action is API version-neutral. - /// - /// The original . - public virtual EndpointMetadataBuilder IsApiVersionNeutral() - { - VersionNeutral = true; - return this; - } - - /// - /// Indicates that the specified API version is supported by the configured action. - /// - /// The supported API version implemented by the action. - /// The original . - public virtual EndpointMetadataBuilder HasApiVersion( ApiVersion apiVersion ) - { - SupportedVersions.Add( apiVersion ); - return this; - } - - /// - /// Indicates that the specified API version is deprecated by the configured action. - /// - /// The deprecated API version implemented by the action. - /// The original . - public virtual EndpointMetadataBuilder HasDeprecatedApiVersion( ApiVersion apiVersion ) - { - DeprecatedVersions.Add( apiVersion ); - return this; - } - - /// - /// Indicates that the specified API version is advertised by the configured action. - /// - /// The advertised API version not directly implemented by the action. - /// The original . - public virtual EndpointMetadataBuilder AdvertisesApiVersion( ApiVersion apiVersion ) - { - AdvertisedVersions.Add( apiVersion ); - return this; - } - - /// - /// Indicates that the specified API version is advertised and deprecated by the configured action. - /// - /// The advertised, but deprecated API version not directly implemented by the action. - /// The original . - public virtual EndpointMetadataBuilder AdvertisesDeprecatedApiVersion( ApiVersion apiVersion ) - { - DeprecatedAdvertisedVersions.Add( apiVersion ); - return this; - } - - /// - public virtual void Add( Action convention ) => ConventionBuilder.Add( convention ); + ApiVersionMetadata IVersionedEndpointConventionBuilder.Build( ApiVersioningOptions options ) => Build( options ); void IDeclareApiVersionConventionBuilder.IsApiVersionNeutral() => IsApiVersionNeutral(); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DefaultApiVersionReporter.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/DefaultApiVersionReporter.cs similarity index 100% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Http/DefaultApiVersionReporter.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/DefaultApiVersionReporter.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ILoggerExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/ILoggerExtensions.cs similarity index 98% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Http/ILoggerExtensions.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/ILoggerExtensions.cs index 49f0a987..27f8d07b 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ILoggerExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/ILoggerExtensions.cs @@ -10,7 +10,7 @@ internal static partial class ILoggerExtensions [LoggerMessage( EventId = 1, Level = Information, Message = "Request contained the API version '{apiVersion}', which is not valid" )] internal static partial void ApiVersionInvalid( this ILogger logger, string? apiVersion ); - [LoggerMessage( EventId = 2, Level = Information, Message = "The requested API version is ambiguous. Requested API Versions: {ApiVersions}" )] + [LoggerMessage( EventId = 2, Level = Information, Message = "The requested API version is ambiguous. Requested API Versions: {apiVersions}" )] internal static partial void ApiVersionAmbiguous( this ILogger logger, string[]? apiVersions ); [LoggerMessage( EventId = 3, Level = Information, Message = "Request did not specify an API version" )] diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ContentTypeApiVersionDecorator.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Routing/ContentTypeApiVersionDecorator.cs similarity index 100% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ContentTypeApiVersionDecorator.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Routing/ContentTypeApiVersionDecorator.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ReportApiVersionsDecorator.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Routing/ReportApiVersionsDecorator.cs similarity index 100% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ReportApiVersionsDecorator.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Http/net6.0/Routing/ReportApiVersionsDecorator.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/DefaultApiVersionReporter.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/DefaultApiVersionReporter.cs new file mode 100644 index 00000000..5d21bff0 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/DefaultApiVersionReporter.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Microsoft.AspNetCore.Http; +using System.Text; + +/// +/// Provides additional implementation specific to ASP.NET Core. +/// +public partial class DefaultApiVersionReporter +{ + private static void AddApiVersionHeader( IHeaderDictionary headers, string headerName, IReadOnlyList versions ) + { + if ( versions.Count == 0 || headers.ContainsKey( headerName ) ) + { + return; + } + + if ( versions.Count == 1 ) + { + headers.Add( headerName, versions[0].ToString() ); + return; + } + + var headerValue = new StringBuilder( versions[0].ToString() ); + + for ( var i = 1; i < versions.Count; i++ ) + { + headerValue.Append( ", " ).Append( versions[i].ToString() ); + } + + headers.Add( headerName, headerValue.ToString() ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/HttpResponseJsonExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/HttpResponseJsonExtensions.cs new file mode 100644 index 00000000..8e77456a --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/HttpResponseJsonExtensions.cs @@ -0,0 +1,70 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +// REF: https://github.com/dotnet/aspnetcore/blob/main/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs +namespace Microsoft.AspNetCore.Http; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Text.Json; + +internal static class HttpResponseJsonExtensions +{ + private static readonly JsonSerializerOptions DefaultSerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public static Task WriteAsJsonAsync( + this HttpResponse response, + TValue value, + JsonSerializerOptions? options, + string? contentType, + CancellationToken cancellationToken = default ) + { + if ( response == null ) + { + throw new ArgumentNullException( nameof( response ) ); + } + + options ??= ResolveSerializerOptions( response.HttpContext ); + + response.ContentType = contentType ?? JsonConstants.JsonContentTypeWithCharset; + + // if no user provided token, pass the RequestAborted token and ignore OperationCanceledException + if ( !cancellationToken.CanBeCanceled ) + { + return WriteAsJsonAsyncSlow( response.Body, value, options, response.HttpContext.RequestAborted ); + } + + return JsonSerializer.SerializeAsync( response.Body, value, options, cancellationToken ); + } + + private static async Task WriteAsJsonAsyncSlow( + Stream body, + TValue value, + JsonSerializerOptions? options, + CancellationToken cancellationToken ) + { + try + { + await JsonSerializer.SerializeAsync( body, value, options, cancellationToken ).ConfigureAwait( false ); + } + catch ( OperationCanceledException ) + { + } + } + + private static JsonSerializerOptions ResolveSerializerOptions( HttpContext httpContext ) + { + // Attempt to resolve options from DI then fallback to default options + return httpContext.RequestServices?.GetService>()?.Value?.JsonSerializerOptions ?? DefaultSerializerOptions; + } + + private static class JsonConstants + { + public const string JsonContentType = "application/json"; + public const string JsonContentTypeWithCharset = JsonContentType + "; charset=utf-8"; + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/IApiVersioningBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/IApiVersioningBuilder.cs new file mode 100644 index 00000000..8f081c6d --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/IApiVersioningBuilder.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Additional implementation for ASP.NET Core 3.1. +/// +[CLSCompliant( false )] +public partial interface IApiVersioningBuilder +{ +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/ILoggerExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/ILoggerExtensions.cs new file mode 100644 index 00000000..bb559adb --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/netcoreapp3.1/ILoggerExtensions.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Microsoft.Extensions.Logging; +using static Microsoft.Extensions.Logging.LoggerMessage; +using static Microsoft.Extensions.Logging.LogLevel; + +internal static class ILoggerExtensions +{ + private static readonly Action apiVersionInvalid = + Define( Information, 1, "Request contained the service API version '{ApiVersion}', which is not valid" ); + + private static readonly Action apiVersionAmbiguous = + Define( Information, 2, "The requested API version is ambiguous. Requested API Versions: {ApiVersions}" ); + + private static readonly Action apiVersionUnspecified = + Define( Information, 3, "Request did not specify an API version" ); + + private static readonly Action apiVersionUnspecifiedWithCandidates = + Define( Information, 4, "Request did not specify an API version, but multiple candidate endpoints were found. Candidate endpoints: {CandidateEndpoints}" ); + + internal static void ApiVersionInvalid( this ILogger logger, string? apiVersion ) => apiVersionInvalid( logger, apiVersion, null ); + + internal static void ApiVersionAmbiguous( this ILogger logger, string[]? apiVersions ) => apiVersionAmbiguous( logger, apiVersions, null ); + + internal static void ApiVersionUnspecified( this ILogger logger ) => apiVersionUnspecified( logger, null ); + + internal static void ApiVersionUnspecifiedWithCandidates( this ILogger logger, string[] candidateEndpoints ) => + apiVersionUnspecifiedWithCandidates( logger, candidateEndpoints, null ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiDescriptionExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiDescriptionExtensions.cs index 3e134087..b6eda4e6 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiDescriptionExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiDescriptionExtensions.cs @@ -189,7 +189,9 @@ private static ApiParameterDescription Clone( ApiParameterDescription parameterD { return new() { +#if !NETCOREAPP3_1 BindingInfo = parameterDescription.BindingInfo, +#endif IsRequired = parameterDescription.IsRequired, DefaultValue = parameterDescription.DefaultValue, ModelMetadata = parameterDescription.ModelMetadata, 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 64abe89f..a313453d 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 @@ -3,7 +3,7 @@ 6.0.0 6.0.0.0 - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning.ApiExplorer ASP.NET Core API Versioning API Explorer The API Explorer extensions for ASP.NET Core API Versioning. @@ -14,6 +14,12 @@ + + + True + + + 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 14817979..b858d49e 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 @@ -3,13 +3,23 @@ 6.0.0 6.0.0.0 - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning ASP.NET Core API Versioning A service API versioning library for Microsoft ASP.NET Core MVC. Asp;AspNet;AspNetCore;MVC;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 d36acbbd..151a4a5f 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -17,6 +17,9 @@ namespace Microsoft.Extensions.DependencyInjection; /// /// Provides ASP.NET Core MVC specific extension methods for . /// +#if NETCOREAPP3_1 +[CLSCompliant( false )] +#endif public static class IApiVersioningBuilderExtensions { /// diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/MvcApiVersioningOptionsFactory{T}.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/net6.0/MvcApiVersioningOptionsFactory{T}.cs similarity index 100% rename from src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/MvcApiVersioningOptionsFactory{T}.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/net6.0/MvcApiVersioningOptionsFactory{T}.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/netcoreapp3.1/MvcApiVersioningOptionsFactory{T}.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/netcoreapp3.1/MvcApiVersioningOptionsFactory{T}.cs new file mode 100644 index 00000000..5e7703cd --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/netcoreapp3.1/MvcApiVersioningOptionsFactory{T}.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Asp.Versioning.Conventions; +using Microsoft.Extensions.Options; + +/// +/// Represents a factory to create API versioning options specific to ASP.NET Core MVC. +/// +/// The type of options to create. +[CLSCompliant( false )] +public class MvcApiVersioningOptionsFactory : IOptionsFactory where T : MvcApiVersioningOptions, new() +{ + private readonly IConfigureOptions[] setups; + private readonly IPostConfigureOptions[] postConfigures; + private readonly IApiVersionConventionBuilder conventionBuilder; + + /// + /// Initializes a new instance of the class. + /// + /// The configured convention builder. + /// The sequence of + /// configuration actions to run. + /// The sequence of + /// initialization actions to run. + public MvcApiVersioningOptionsFactory( + IApiVersionConventionBuilder conventionBuilder, + IEnumerable> setups, + IEnumerable> postConfigures ) + { + this.setups = setups as IConfigureOptions[] ?? new List>( setups ).ToArray(); + this.postConfigures = postConfigures as IPostConfigureOptions[] ?? new List>( postConfigures ).ToArray(); + this.conventionBuilder = conventionBuilder ?? throw new ArgumentNullException( nameof( conventionBuilder ) ); + } + + /// + public virtual T Create( string name ) + { + var options = new T() { Conventions = conventionBuilder }; + + foreach ( var setup in setups ) + { + if ( setup is IConfigureNamedOptions namedSetup ) + { + namedSetup.Configure( name, options ); + } + else if ( name == Options.DefaultName ) + { + setup.Configure( options ); + } + } + + foreach ( var post in postConfigures ) + { + post.PostConfigure( name, options ); + } + + return options; + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Asp.Versioning.Http.Tests.csproj b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Asp.Versioning.Http.Tests.csproj index e94a90db..03882b83 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Asp.Versioning.Http.Tests.csproj +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Asp.Versioning.Http.Tests.csproj @@ -1,10 +1,15 @@  - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning + + + + + diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/ApiVersionSetTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/ApiVersionSetTest.cs new file mode 100644 index 00000000..9f439760 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/ApiVersionSetTest.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Builder; + +public class ApiVersionSetTest +{ + [Fact] + public void report_api_versions_should_derive_from_builder() + { + // arrange + var builder = new ApiVersionSetBuilder( default ).ReportApiVersions(); + + // act + var versionSet = builder.Build(); + + // assert + versionSet.ReportApiVersions.Should().BeTrue(); + } + + [Fact] + public void build_should_construct_model_from_builder() + { + // arrange + var versionSet = new ApiVersionSetBuilder( null ).IsApiVersionNeutral().Build(); + + // act + var model = versionSet.Build( new() ); + + // assert + model.Should().BeSameAs( ApiVersionModel.Neutral ); + } + + [Fact] + public void advertises_api_version_should_propagate_to_builder() + { + // arrange + var builder = new Mock( null ) { CallBase = true }; + + builder.Setup( b => b.AdvertisesApiVersion( It.IsAny() ) ); + + var versionSet = builder.Object.Build(); + var expected = new ApiVersion( 2.0 ); + + // act + versionSet.AdvertisesApiVersion( expected ); + + // assert + builder.Verify( b => b.AdvertisesApiVersion( expected ) ); + } + + [Fact] + public void advertises_deprecated_api_version_should_propagate_to_builder() + { + // arrange + var builder = new Mock( null ) { CallBase = true }; + + builder.Setup( b => b.AdvertisesDeprecatedApiVersion( It.IsAny() ) ); + + var versionSet = builder.Object.Build(); + var expected = new ApiVersion( 0.9 ); + + // act + versionSet.AdvertisesDeprecatedApiVersion( expected ); + + // assert + builder.Verify( b => b.AdvertisesDeprecatedApiVersion( expected ) ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs new file mode 100644 index 00000000..2c055c90 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Builder; + +using Microsoft.AspNetCore.Builder; + +public class IEndpointConventionBuilderExtensionsTest +{ + [Fact] + public void use_api_versioning_should_return_same_instance() + { + // arrange + var endpoints = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + var builder1 = endpoints.WithApiVersionSet( versionSet ); + + // act + var builder2 = builder1.WithApiVersionSet( versionSet ); + + // assert + builder1.Should().BeSameAs( builder2 ); + } + + [Fact] + public void report_api_versions_should_set_builder_property_to_true() + { + // arrange + var builder = Mock.Of(); + + // act + builder.ReportApiVersions(); + + // assert + builder.ReportApiVersions.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointMetadataBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointMetadataBuilderExtensionsTest.cs deleted file mode 100644 index 4cee7427..00000000 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointMetadataBuilderExtensionsTest.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -public class IEndpointMetadataBuilderExtensionsTest -{ - [Fact] - public void report_api_versions_should_set_property_to_true() - { - // arrange - var builder = Mock.Of(); - - // act - builder.ReportApiVersions(); - - // assert - builder.ReportApiVersions.Should().BeTrue(); - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointRouteBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointRouteBuilderExtensionsTest.cs new file mode 100644 index 00000000..0c38fc7c --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointRouteBuilderExtensionsTest.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Builder; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +public class IEndpointRouteBuilderExtensionsTest +{ + [Fact] + public void new_api_version_set_should_use_name() + { + // arrange + var services = new ServiceCollection(); + + services.AddControllers(); + services.AddApiVersioning(); + + var endpoints = Mock.Of(); + + Mock.Get( endpoints ) + .Setup( e => e.ServiceProvider ) + .Returns( services.BuildServiceProvider() ); + + // act + var versionSet = endpoints.NewApiVersionSet( "Test" ).Build(); + + // assert + versionSet.Name.Should().Be( "Test" ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/VersionedApiBuilderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/VersionedApiBuilderTest.cs deleted file mode 100644 index 85afa0b4..00000000 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/VersionedApiBuilderTest.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Asp.Versioning.Conventions; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Options; - -public class VersionedApiBuilderTest -{ - [Fact] - public void has_mapping_should_invoke_callback() - { - // arrange - var routeBuilder = Mock.Of(); - var apiBuilder = new VersionedApiBuilder( routeBuilder ); - var map = Mock.Of>(); - - // act - apiBuilder.HasMapping( map ); - - // assert - Mock.Get( map ).Verify( f => f( It.IsAny() ) ); - } - - [Fact] - public void build_should_return_version_neutral_api_version_model() - { - // arrange - var routeBuilder = Mock.Of(); - var apiBuilder = new VersionedApiBuilder( routeBuilder ); - - apiBuilder.IsApiVersionNeutral(); - - // act - var model = apiBuilder.Build(); - - // assert - model.Should().BeSameAs( ApiVersionModel.Neutral ); - } - - [Fact] - public void build_should_ignore_explicit_mapping_with_version_neutral() - { - // arrange - var routeBuilder = Mock.Of(); - var apiBuilder = new VersionedApiBuilder( routeBuilder ); - - apiBuilder.HasApiVersion( 2.0 ); - apiBuilder.IsApiVersionNeutral(); - - // act - var model = apiBuilder.Build(); - - // assert - model.DeclaredApiVersions.Should().BeEmpty(); - } - - [Fact] - public void build_should_construct_api_version_model() - { - // arrange - var routeBuilder = Mock.Of(); - var apiBuilder = new VersionedApiBuilder( routeBuilder ); - - apiBuilder.HasApiVersion( 1.0 ) - .HasDeprecatedApiVersion( 0.9 ) - .AdvertisesApiVersion( 2.0 ) - .AdvertisesDeprecatedApiVersion( 2.0, "Beta" ); - - // act - var model = apiBuilder.Build(); - - // assert - model.Should().BeEquivalentTo( - new ApiVersionModel( - declaredVersions: new ApiVersion[] { new( 0.9 ), new( 1.0 ) }, - supportedVersions: new ApiVersion[] { new( 1.0 ), new( 2.0 ) }, - deprecatedVersions: new ApiVersion[] { new( 0.9 ), new( 2.0, "Beta" ) }, - advertisedVersions: new ApiVersion[] { new( 2.0 ) }, - deprecatedAdvertisedVersions: new ApiVersion[] { new( 2.0, "Beta" ) } ) ); - } - - [Fact] - public void build_should_construct_api_version_model_from_default_version() - { - // arrange - var routeBuilder = new Mock(); - var serviceProvider = new Mock(); - var options = new ApiVersioningOptions() { DefaultApiVersion = new( 2.0 ) }; - - serviceProvider.Setup( sp => sp.GetService( typeof( IOptions ) ) ) - .Returns( Options.Create( options ) ); - routeBuilder.SetupGet( rb => rb.ServiceProvider ).Returns( serviceProvider.Object ); - - var apiBuilder = new VersionedApiBuilder( routeBuilder.Object ); - - // act - var model = apiBuilder.Build(); - - // assert - model.Should().BeEquivalentTo( new ApiVersionModel( new ApiVersion( 2.0 ) ) ); - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/VersionedEndpointBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/VersionedEndpointBuilderExtensionsTest.cs deleted file mode 100644 index df82a0dc..00000000 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/VersionedEndpointBuilderExtensionsTest.cs +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; - -public class VersionedEndpointBuilderExtensionsTest -{ - [Fact] - public void report_api_versions_should_set_property_to_true() - { - // arrange - var builder = Mock.Of(); - - // act - builder.ReportApiVersions(); - - // assert - builder.ReportApiVersions.Should().BeTrue(); - } - - [Fact] - public void map_get_should_map_request_delegate() - { - // arrange - var builder = NewVersionedEndpointBuilder(); - RequestDelegate handler = c => Task.CompletedTask; - - // act - builder.MapGet( "test", handler ); - - // assert - Mock.Get( builder ).Verify( b => b.MapMethods( "test", new[] { "GET" }, handler ) ); - } - - [Fact] - public void map_get_should_map_delegate() - { - // arrange - var builder = NewVersionedEndpointBuilder(); - Delegate handler = () => { }; - - // act - builder.MapGet( "test", handler ); - - // assert - Mock.Get( builder ).Verify( b => b.MapMethods( "test", new[] { "GET" }, handler ) ); - } - - [Fact] - public void map_post_should_map_request_delegate() - { - // arrange - var builder = NewVersionedEndpointBuilder(); - RequestDelegate handler = c => Task.CompletedTask; - - // act - builder.MapPost( "test", handler ); - - // assert - Mock.Get( builder ).Verify( b => b.MapMethods( "test", new[] { "POST" }, handler ) ); - } - - [Fact] - public void map_post_should_map_delegate() - { - // arrange - var builder = NewVersionedEndpointBuilder(); - Delegate handler = () => { }; - - // act - builder.MapPost( "test", handler ); - - // assert - Mock.Get( builder ).Verify( b => b.MapMethods( "test", new[] { "POST" }, handler ) ); - } - - [Fact] - public void map_put_should_map_request_delegate() - { - // arrange - var builder = NewVersionedEndpointBuilder(); - RequestDelegate handler = c => Task.CompletedTask; - - // act - builder.MapPut( "test", handler ); - - // assert - Mock.Get( builder ).Verify( b => b.MapMethods( "test", new[] { "PUT" }, handler ) ); - } - - [Fact] - public void map_put_should_map_delegate() - { - // arrange - var builder = NewVersionedEndpointBuilder(); - Delegate handler = () => { }; - - // act - builder.MapPut( "test", handler ); - - // assert - Mock.Get( builder ).Verify( b => b.MapMethods( "test", new[] { "PUT" }, handler ) ); - } - - [Fact] - public void map_delete_should_map_request_delegate() - { - // arrange - var builder = NewVersionedEndpointBuilder(); - RequestDelegate handler = c => Task.CompletedTask; - - // act - builder.MapDelete( "test", handler ); - - // assert - Mock.Get( builder ).Verify( b => b.MapMethods( "test", new[] { "DELETE" }, handler ) ); - } - - [Fact] - public void map_delete_should_map_delegate() - { - // arrange - var builder = NewVersionedEndpointBuilder(); - Delegate handler = () => { }; - - // act - builder.MapDelete( "test", handler ); - - // assert - Mock.Get( builder ).Verify( b => b.MapMethods( "test", new[] { "DELETE" }, handler ) ); - } - - [Fact] - public void map_patch_should_map_request_delegate() - { - // arrange - var builder = NewVersionedEndpointBuilder(); - RequestDelegate handler = c => Task.CompletedTask; - - // act - builder.MapPatch( "test", handler ); - - // assert - Mock.Get( builder ).Verify( b => b.MapMethods( "test", new[] { "PATCH" }, handler ) ); - } - - [Fact] - public void map_patch_should_map_delegate() - { - // arrange - var builder = NewVersionedEndpointBuilder(); - Delegate handler = () => { }; - - // act - builder.MapPatch( "test", handler ); - - // assert - Mock.Get( builder ).Verify( b => b.MapMethods( "test", new[] { "PATCH" }, handler ) ); - } - - private static VersionedEndpointBuilder NewVersionedEndpointBuilder() => - new Mock( Mock.Of() ).Object; -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/EndpointMetadataBuilderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/VersionedEndpointConventionBuilderTest.cs similarity index 52% rename from src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/EndpointMetadataBuilderTest.cs rename to src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/VersionedEndpointConventionBuilderTest.cs index 5d6e683c..bcfe5a56 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/EndpointMetadataBuilderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/VersionedEndpointConventionBuilderTest.cs @@ -5,20 +5,20 @@ namespace Asp.Versioning.Builder; using Asp.Versioning.Conventions; using Microsoft.AspNetCore.Builder; -public class EndpointMetadataBuilderTest +public class VersionedEndpointConventionBuilderTest { [Fact] public void build_should_return_version_neutral_api_version_model() { // arrange - var apiBuilder = Mock.Of(); - var conventionBuilder = Mock.Of(); - var metadataBuilder = new EndpointMetadataBuilder( apiBuilder, conventionBuilder ); + var endpoints = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + var builder = endpoints.WithApiVersionSet( versionSet ); - metadataBuilder.IsApiVersionNeutral(); + builder.IsApiVersionNeutral(); // act - var metadata = metadataBuilder.Build(); + var metadata = builder.Build( new() ); // assert metadata.Should().BeSameAs( ApiVersionMetadata.Neutral ); @@ -28,15 +28,12 @@ public void build_should_return_version_neutral_api_version_model() public void build_should_return_version_neutral_api_version_model_for_entire_api() { // arrange - var apiBuilder = new Mock(); - var conventionBuilder = Mock.Of(); - - apiBuilder.Setup( b => b.Build() ).Returns( ApiVersionModel.Neutral ); - - var metadataBuilder = new EndpointMetadataBuilder( apiBuilder.Object, conventionBuilder ); + var endpoints = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).IsApiVersionNeutral().Build(); + var builder = endpoints.WithApiVersionSet( versionSet ); // act - var metadata = metadataBuilder.Build(); + var metadata = builder.Build( new() ); // assert metadata.Should().BeSameAs( ApiVersionMetadata.Neutral ); @@ -46,17 +43,13 @@ public void build_should_return_version_neutral_api_version_model_for_entire_api public void build_should_return_version_neutral_api_version_model_for_entire_api_with_name() { // arrange - var apiBuilder = new Mock(); - var conventionBuilder = Mock.Of(); - - apiBuilder.Setup( b => b.Build() ).Returns( ApiVersionModel.Neutral ); - apiBuilder.SetupGet( b => b.Name ).Returns( "Test" ); - - var metadataBuilder = new EndpointMetadataBuilder( apiBuilder.Object, conventionBuilder ); + var endpoints = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( "Test" ).IsApiVersionNeutral().Build(); + var builder = endpoints.WithApiVersionSet( versionSet ); var expected = new ApiVersionMetadata( ApiVersionModel.Neutral, ApiVersionModel.Neutral, "Test" ); // act - var metadata = metadataBuilder.Build(); + var metadata = builder.Build( new() ); // assert metadata.Should().BeEquivalentTo( expected ); @@ -66,17 +59,13 @@ public void build_should_return_version_neutral_api_version_model_for_entire_api public void build_should_return_empty_api_version_model() { // arrange - var apiBuilder = new Mock(); - var conventionBuilder = Mock.Of(); - - apiBuilder.Setup( b => b.Build() ).Returns( ApiVersionModel.Empty ); - apiBuilder.SetupGet( b => b.Name ).Returns( "Test" ); - - var metadataBuilder = new EndpointMetadataBuilder( apiBuilder.Object, conventionBuilder ); + var endpoints = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( "Test" ).Build(); + var builder = endpoints.WithApiVersionSet( versionSet ); var expected = new ApiVersionMetadata( ApiVersionModel.Empty, ApiVersionModel.Empty, "Test" ); // act - var metadata = metadataBuilder.Build(); + var metadata = builder.Build( new() ); // assert metadata.Should().BeEquivalentTo( expected ); @@ -86,18 +75,14 @@ public void build_should_return_empty_api_version_model() public void build_should_return_inherited_api_version_model() { // arrange - var apiBuilder = new Mock(); - var conventionBuilder = Mock.Of(); + var endpoints = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( "Test" ).HasApiVersion( 2.0 ).Build(); + var builder = endpoints.WithApiVersionSet( versionSet ); var apiModel = new ApiVersionModel( new ApiVersion( 2.0 ) ); - - apiBuilder.Setup( b => b.Build() ).Returns( apiModel ); - apiBuilder.SetupGet( b => b.Name ).Returns( "Test" ); - - var metadataBuilder = new EndpointMetadataBuilder( apiBuilder.Object, conventionBuilder ); var expected = new ApiVersionMetadata( apiModel, apiModel, "Test" ); // act - var metadata = metadataBuilder.Build(); + var metadata = builder.Build( new() ); // assert metadata.Should().BeEquivalentTo( expected ); @@ -107,13 +92,9 @@ public void build_should_return_inherited_api_version_model() public void build_should_return_explicit_api_version_model() { // arrange - var apiBuilder = new Mock(); - var conventionBuilder = Mock.Of(); - - apiBuilder.Setup( b => b.Build() ).Returns( ApiVersionModel.Empty ); - apiBuilder.SetupGet( b => b.Name ).Returns( "Test" ); - - var metadataBuilder = new EndpointMetadataBuilder( apiBuilder.Object, conventionBuilder ); + var endpoints = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( "Test" ).Build(); + var builder = endpoints.WithApiVersionSet( versionSet ); var expected = new ApiVersionMetadata( ApiVersionModel.Empty, new ApiVersionModel( @@ -124,13 +105,13 @@ public void build_should_return_explicit_api_version_model() deprecatedAdvertisedVersions: new ApiVersion[] { new( 2.0, "Beta" ) } ), "Test" ); - metadataBuilder.HasApiVersion( 1.0 ) - .HasDeprecatedApiVersion( 0.9 ) - .AdvertisesApiVersion( 2.0 ) - .AdvertisesDeprecatedApiVersion( 2.0, "Beta" ); + builder.HasApiVersion( 1.0 ) + .HasDeprecatedApiVersion( 0.9 ) + .AdvertisesApiVersion( 2.0 ) + .AdvertisesDeprecatedApiVersion( 2.0, "Beta" ); // act - var metadata = metadataBuilder.Build(); + var metadata = builder.Build( new() ); // assert metadata.Should().BeEquivalentTo( expected ); @@ -140,19 +121,18 @@ public void build_should_return_explicit_api_version_model() public void build_should_return_mapped_api_version_model() { // arrange - var apiBuilder = new Mock(); - var conventionBuilder = Mock.Of(); + var endpoints = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( "Test" ) + .HasApiVersion( 1.0 ) + .HasApiVersion( 2.0 ) + .Build(); + var builder = endpoints.WithApiVersionSet( versionSet ); var apiModel = new ApiVersionModel( declaredVersions: new ApiVersion[] { new( 1.0 ), new( 2.0 ) }, supportedVersions: new ApiVersion[] { new( 1.0 ), new( 2.0 ) }, deprecatedVersions: Array.Empty(), advertisedVersions: Array.Empty(), deprecatedAdvertisedVersions: Array.Empty() ); - - apiBuilder.Setup( b => b.Build() ).Returns( ApiVersionModel.Empty ); - apiBuilder.SetupGet( b => b.Name ).Returns( "Test" ); - - var metadataBuilder = new EndpointMetadataBuilder( apiBuilder.Object, conventionBuilder ); var expected = new ApiVersionMetadata( apiModel, new ApiVersionModel( @@ -163,10 +143,10 @@ public void build_should_return_mapped_api_version_model() deprecatedAdvertisedVersions: Array.Empty() ), "Test" ); - metadataBuilder.MapToApiVersion( 2.0 ); + builder.MapToApiVersion( 2.0 ); // act - var metadata = metadataBuilder.Build(); + var metadata = builder.Build( new() ); // assert metadata.Should().BeEquivalentTo( expected ); diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/ConstantApiVersionSelectorTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/ConstantApiVersionSelectorTest.cs index 26bf6f6a..f73b0110 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/ConstantApiVersionSelectorTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/ConstantApiVersionSelectorTest.cs @@ -3,6 +3,9 @@ namespace Asp.Versioning; using Microsoft.AspNetCore.Http; +#if NETCOREAPP3_1 +using DateOnly = System.DateTime; +#endif public class ConstantApiVersionSelectorTest { diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/Asp.Versioning.Mvc.ApiExplorer.Tests.csproj b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/Asp.Versioning.Mvc.ApiExplorer.Tests.csproj index b78f8056..09960d1c 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/Asp.Versioning.Mvc.ApiExplorer.Tests.csproj +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/Asp.Versioning.Mvc.ApiExplorer.Tests.csproj @@ -1,7 +1,7 @@  - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning.ApiExplorer diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Asp.Versioning.Mvc.Tests.csproj b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Asp.Versioning.Mvc.Tests.csproj index 701a16af..0e262e4d 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Asp.Versioning.Mvc.Tests.csproj +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/Asp.Versioning.Mvc.Tests.csproj @@ -1,7 +1,7 @@  - net6.0 + net6.0;netcoreapp3.1 Asp.Versioning diff --git a/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs b/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs index c6d64b53..c766317d 100644 --- a/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs +++ b/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs @@ -40,7 +40,8 @@ public ODataQueryOptionDescriptionContext( ApiDescription apiDescription ) => /// Initializes a new instance of the class. /// /// The associated API description. - /// The validation settings to derive the description context from. + /// The validation settings to + /// derive the description context from. protected internal ODataQueryOptionDescriptionContext( ApiDescription apiDescription, ODataValidationSettings validationSettings ) diff --git a/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionSettings.cs b/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionSettings.cs index a95fdbb1..72556399 100644 --- a/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionSettings.cs +++ b/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionSettings.cs @@ -2,6 +2,12 @@ namespace Asp.Versioning.Conventions; +#if NETFRAMEWORK +using Microsoft.AspNet.OData.Query; +#else +using Microsoft.OData.ModelBuilder.Config; +#endif + /// /// Represents the settings for OData query options. /// @@ -16,6 +22,12 @@ public partial class ODataQueryOptionSettings /// value is false. public bool NoDollarPrefix { get; set; } + /// + /// Gets or sets the default OData query settings. + /// + /// The default OData query settings. + public DefaultQuerySettings? DefaultQuerySettings { get; set; } + /// /// Gets or sets the provider used to describe query options. /// diff --git a/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs b/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs index d4abd7b9..472efb74 100644 --- a/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs +++ b/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs @@ -129,19 +129,19 @@ public virtual void ApplyTo( IEnumerable apiDescriptions, ODataQ throw new ArgumentNullException( nameof( apiDescriptions ) ); } - var conventions = new Dictionary(); + var conventions = default( Dictionary ); foreach ( var description in apiDescriptions ) { var controller = GetController( description ); - if ( !conventions.TryGetValue( controller, out var convention ) ) + if ( !controller.IsODataController() && !IsODataLike( description ) ) { - if ( !controller.IsODataController() && !IsODataLike( description ) ) - { - continue; - } + continue; + } + if ( conventions == null || !conventions.TryGetValue( controller, out var convention ) ) + { if ( conventionBuilders == null || conventionBuilders.Count == 0 || !conventionBuilders.TryGetValue( controller, out var builder ) ) @@ -150,6 +150,7 @@ public virtual void ApplyTo( IEnumerable apiDescriptions, ODataQ } convention = builder.Build( queryOptionSettings ); + conventions ??= new(); conventions.Add( controller, convention ); } diff --git a/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataValidationSettingsConvention.cs b/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataValidationSettingsConvention.cs index c807a3ff..983feb6f 100644 --- a/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataValidationSettingsConvention.cs +++ b/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataValidationSettingsConvention.cs @@ -132,10 +132,7 @@ private static bool IsSupported( string? httpMethod ) return httpMethod!.ToUpperInvariant() switch { - "GET" => true, - "PUT" => true, - "PATCH" => true, - "POST" => true, + "GET" or "PUT" or "PATCH" or "POST" => true, _ => false, }; } diff --git a/src/Common/src/Common/SunsetPolicyManager.cs b/src/Common/src/Common/SunsetPolicyManager.cs index 2c076f0d..cfd13699 100644 --- a/src/Common/src/Common/SunsetPolicyManager.cs +++ b/src/Common/src/Common/SunsetPolicyManager.cs @@ -13,7 +13,7 @@ public partial class SunsetPolicyManager : ISunsetPolicyManager public virtual bool TryGetPolicy( string? name, ApiVersion? apiVersion, -#if !NETFRAMEWORK +#if !NETFRAMEWORK && !NETCOREAPP3_1 [MaybeNullWhen( false )] #endif out SunsetPolicy sunsetPolicy ) @@ -30,7 +30,13 @@ public virtual bool TryGetPolicy( policies ??= BuildPolicies( options.Value ); #endif var key = new PolicyKey( name, apiVersion ); + + // NETCOREAPP3_1 only; remove when target is dropped +#pragma warning disable IDE0079 +#pragma warning disable CS8601 // Possible null reference assignment return policies.TryGetValue( key, out sunsetPolicy ); +#pragma warning restore CS8601 +#pragma warning restore IDE0079 } private static Dictionary BuildPolicies( ApiVersioningOptions options ) diff --git a/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderExtensionsTTest.cs b/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderExtensionsTTest.cs index a6ce9d50..69ad2b67 100644 --- a/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderExtensionsTTest.cs +++ b/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderExtensionsTTest.cs @@ -5,6 +5,9 @@ namespace Asp.Versioning.Conventions; #if NETFRAMEWORK using ControllerBase = System.Web.Http.Controllers.IHttpController; using DateOnly = System.DateTime; +#elif NETCOREAPP3_1 +using Microsoft.AspNetCore.Mvc; +using DateOnly = System.DateTime; #else using Microsoft.AspNetCore.Mvc; #endif diff --git a/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderExtensionsTest.cs b/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderExtensionsTest.cs index 8e5bb80d..90185b2a 100644 --- a/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderExtensionsTest.cs +++ b/src/Common/test/Common.Mvc.Tests/Conventions/ActionApiVersionConventionBuilderExtensionsTest.cs @@ -5,6 +5,9 @@ namespace Asp.Versioning.Conventions; #if NETFRAMEWORK using ControllerBase = System.Web.Http.Controllers.IHttpController; using DateOnly = System.DateTime; +#elif NETCOREAPP3_1 +using Microsoft.AspNetCore.Mvc; +using DateOnly = System.DateTime; #else using Microsoft.AspNetCore.Mvc; #endif diff --git a/src/Common/test/Common.Mvc.Tests/Conventions/ControllerApiVersionConventionBuilderExtensionsTTest.cs b/src/Common/test/Common.Mvc.Tests/Conventions/ControllerApiVersionConventionBuilderExtensionsTTest.cs index 235b575e..14f898e5 100644 --- a/src/Common/test/Common.Mvc.Tests/Conventions/ControllerApiVersionConventionBuilderExtensionsTTest.cs +++ b/src/Common/test/Common.Mvc.Tests/Conventions/ControllerApiVersionConventionBuilderExtensionsTTest.cs @@ -5,6 +5,9 @@ namespace Asp.Versioning.Conventions; #if NETFRAMEWORK using ControllerBase = System.Web.Http.Controllers.IHttpController; using DateOnly = System.DateTime; +#elif NETCOREAPP3_1 +using Microsoft.AspNetCore.Mvc; +using DateOnly = System.DateTime; #else using Microsoft.AspNetCore.Mvc; #endif diff --git a/src/Common/test/Common.Mvc.Tests/Conventions/ControllerApiVersionConventionBuilderExtensionsTest.cs b/src/Common/test/Common.Mvc.Tests/Conventions/ControllerApiVersionConventionBuilderExtensionsTest.cs index f18d23df..ac47b27d 100644 --- a/src/Common/test/Common.Mvc.Tests/Conventions/ControllerApiVersionConventionBuilderExtensionsTest.cs +++ b/src/Common/test/Common.Mvc.Tests/Conventions/ControllerApiVersionConventionBuilderExtensionsTest.cs @@ -5,6 +5,9 @@ namespace Asp.Versioning.Conventions; #if NETFRAMEWORK using ControllerBase = System.Web.Http.ApiController; using DateOnly = System.DateTime; +#elif NETCOREAPP3_1 +using Microsoft.AspNetCore.Mvc; +using DateOnly = System.DateTime; #else using Microsoft.AspNetCore.Mvc; #endif