diff --git a/asp.sln b/asp.sln index 57868d94..e5a1c95e 100644 --- a/asp.sln +++ b/asp.sln @@ -197,6 +197,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SomeODataOpenApiExample", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SomeOpenApiODataWebApiExample", "examples\AspNet\OData\SomeOpenApiODataWebApiExample\SomeOpenApiODataWebApiExample.csproj", "{AC952FBF-D7DC-4DE4-AD1C-4CEA589034F5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SomeODataOpenApiExampleCompositeKey", "examples\AspNetCore\OData\SomeODataOpenApiExampleCompositeKey\SomeODataOpenApiExampleCompositeKey.csproj", "{452D7A02-C198-48AA-8F99-0445653F4792}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -387,6 +389,10 @@ Global {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 + {452D7A02-C198-48AA-8F99-0445653F4792}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {452D7A02-C198-48AA-8F99-0445653F4792}.Debug|Any CPU.Build.0 = Debug|Any CPU + {452D7A02-C198-48AA-8F99-0445653F4792}.Release|Any CPU.ActiveCfg = Release|Any CPU + {452D7A02-C198-48AA-8F99-0445653F4792}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -471,6 +477,7 @@ Global {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} + {452D7A02-C198-48AA-8F99-0445653F4792} = {49EA6476-901C-4D4F-8E45-98BC8A2780EB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {91FE116A-CEFB-4304-A8A6-CFF021C7453A} diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/Book.cs b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/Book.cs new file mode 100644 index 00000000..ff29035a --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/Book.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace ApiVersioning.Examples; + +/// +/// Represents a book. +/// +public class Book +{ + /// + /// Gets or sets the book identifier. + /// + /// The International Standard Book Number (ISBN). + [Key] + public string IdFirst { get; set; } + [Key] + public string IdSecond { 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/SomeODataOpenApiExampleCompositeKey/BooksController.cs b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/BooksController.cs new file mode 100644 index 00000000..d3cd14aa --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/BooksController.cs @@ -0,0 +1,77 @@ +namespace ApiVersioning.Examples; + +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; +using System.Collections.Generic; +using System.Linq; +using static Microsoft.AspNetCore.Http.StatusCodes; + +/// +/// Represents a RESTful service of books. +/// +[Asp.Versioning.ApiVersion( "1.0" )] +[Asp.Versioning.ApiVersion( "2.0" )] +public class BooksController : ODataController +{ + private static readonly Book[] books = new Book[] + { + new() { IdFirst = "9781847490599",IdSecond = "9781847490599", Title = "Anna Karenina", Author = "Leo Tolstoy", Published = 1878 }, + new() { IdFirst = "9780198800545",IdSecond = "9780198800545", Title = "War and Peace", Author = "Leo Tolstoy", Published = 1869 }, + new() { IdFirst = "9780684801520",IdSecond = "9780684801520", Title = "The Great Gatsby", Author = "F. Scott Fitzgerald", Published = 1925 }, + new() { IdFirst = "9780486280615",IdSecond = "9780486280615", Title = "The Adventures of Huckleberry Finn", Author = "Mark Twain", Published = 1884 }, + new() { IdFirst = "9780140430820",IdSecond = "9780140430820", Title = "Moby Dick", Author = "Herman Melville", Published = 1851 }, + new() { IdFirst = "9780060934347",IdSecond = "9780060934347", Title = "Don Quixote", Author = "Miguel de Cervantes", Published = 1605 }, + }; + + /// + /// Gets all books. + /// + /// All available books. + /// The successfully retrieved books. + [Produces( "application/json" )] + [ProducesResponseType( typeof( IEnumerable ), Status200OK )] + public IActionResult Get() => + Ok( books.AsQueryable() ); + + /// + /// Gets a single book. + /// + /// The requested book identifier. + /// The requested book. + /// The book was successfully retrieved. + /// The book does not exist. + //[Produces("application/json")] + //[ProducesResponseType(typeof(Book), Status200OK)] + //[ProducesResponseType(Status404NotFound)] + //public IActionResult Get(string key) + //{ + // var book = books.FirstOrDefault(book => book.Id == key); + + // if (book == null) + // { + // return NotFound(); + // } + + // return Ok(book); + //} + + /// + /// Gets a single book. + /// + [Produces( "application/json" )] + [ProducesResponseType( typeof( Book ), Status200OK )] + [ProducesResponseType( Status404NotFound )] + public IActionResult Get( string keyidFirst, string keyidSecond ) + { + var book = books.FirstOrDefault( book => book.IdFirst == keyidFirst && book.IdSecond == keyidSecond ); + + if ( book == null ) + { + return NotFound(); + } + + return Ok( book ); + } +} \ No newline at end of file diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/BooksModelConfiguration.cs b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/BooksModelConfiguration.cs new file mode 100644 index 00000000..5d7550f9 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/BooksModelConfiguration.cs @@ -0,0 +1,33 @@ +using Asp.Versioning; +using Asp.Versioning.OData; +using Microsoft.OData.ModelBuilder; + +namespace ApiVersioning.Examples +{ + public class BooksModelConfiguration : IModelConfiguration + { + private void ConfigureV1(ODataModelBuilder builder) => ConfigureCurrent(builder); + + private EntityTypeConfiguration ConfigureCurrent(ODataModelBuilder builder) + { + var model = builder.EntitySet("Books").EntityType; + model.HasKey(p => p.IdFirst); + model.HasKey(p => p.IdSecond); + + return model; + } + + public void Apply(ODataModelBuilder builder, ApiVersion apiVersion, string routePrefix) + { + switch (apiVersion.MajorVersion) + { + case 1: + ConfigureV1(builder); + break; + default: + ConfigureCurrent(builder); + break; + } + } + } +} diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/ConfigureSwaggerOptions.cs b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/ConfigureSwaggerOptions.cs new file mode 100644 index 00000000..b8e59193 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/ConfigureSwaggerOptions.cs @@ -0,0 +1,90 @@ +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; +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/SomeODataOpenApiExampleCompositeKey/Program.cs b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/Program.cs new file mode 100644 index 00000000..aca8ef13 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/Program.cs @@ -0,0 +1,103 @@ +using ApiVersioning.Examples; +using Asp.Versioning; +using Asp.Versioning.Conventions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Microsoft.OData.ModelBuilder; +using Swashbuckle.AspNetCore.SwaggerGen; +using System; +using System.IO; +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, serviceProvider) => + { + options.SetMaxTop(null).Select().Expand().Filter().OrderBy().Count(); + }); +builder.Services.AddApiVersioning((options) => +{ + options.ReportApiVersions = true; + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; +}) + .AddOData(options => + { + options.AddRouteComponents("v{version:apiVersion}"); + // options.ModelBuilder.ModelBuilderFactory = () => new ODataConventionModelBuilder(); + }) + .AddODataApiExplorer(options => + { + options.GroupNameFormat = "'v'VV"; + options.SubstituteApiVersionInUrl = true; + }); + +// 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. + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); + // navigate to ~/$odata to determine whether any endpoints did not match an odata route template + app.UseODataRouteDebug(); +} + +// 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/SomeODataOpenApiExampleCompositeKey/Properties/launchSettings.json b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/Properties/launchSettings.json new file mode 100644 index 00000000..3c74a485 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/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/SomeODataOpenApiExampleCompositeKey/SomeODataOpenApiExampleCompositeKey.csproj b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/SomeODataOpenApiExampleCompositeKey.csproj new file mode 100644 index 00000000..01a3b626 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/SomeODataOpenApiExampleCompositeKey.csproj @@ -0,0 +1,16 @@ + + + + net6.0 + true + + + + + + + + + + + diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/SwaggerDefaultValues.cs b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/SwaggerDefaultValues.cs new file mode 100644 index 00000000..00a33340 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/SwaggerDefaultValues.cs @@ -0,0 +1,69 @@ +namespace ApiVersioning.Examples; + +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Linq; +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/SomeODataOpenApiExampleCompositeKey/appsettings.json b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/appsettings.json new file mode 100644 index 00000000..ec04bc12 --- /dev/null +++ b/examples/AspNetCore/OData/SomeODataOpenApiExampleCompositeKey/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file 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 ce2d828b..8b4cdb47 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs @@ -194,7 +194,22 @@ private static void CopyApiVersionEndpointMetadata( IList contr continue; } - var metadata = selectors[0].EndpointMetadata.OfType().FirstOrDefault(); + ApiVersionMetadata? metadata = null; + for ( var m = 0; m < selectors.Count; m++ ) + { + metadata = selectors[m].EndpointMetadata.OfType().FirstOrDefault(); + if ( metadata != null ) + { + if ( m != 0 ) + { + var tmpSelector = selectors[m]; + selectors[m] = selectors[0]; + selectors[0] = tmpSelector; + } + + break; + } + } if ( metadata is null ) {