diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs index f577ebf4..c31945a5 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -58,7 +58,12 @@ private static void AddApiExplorerServices( IApiVersioningBuilder builder ) var services = builder.Services; + // registers DefaultApiDescriptionProvider, which discovers controller-based endpoints services.AddMvcCore().AddApiExplorer(); + + // registers EndpointMetadataApiDescriptionProvider, which discovers minimal API endpoints. + // both providers are required so that OpenApiDocumentService sees all endpoints. + services.AddEndpointsApiExplorer(); services.TryAddSingleton, ApiExplorerOptionsFactory>(); services.TryAddTransient(); services.TryAddSingleton( static sp => sp.GetRequiredService().Create() ); diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/AssumeCultureAttribute.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/AssumeCultureAttribute.cs new file mode 100644 index 00000000..395a9550 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/AssumeCultureAttribute.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using System.Globalization; +using System.Reflection; +using static System.AttributeTargets; +using static System.Threading.Thread; + +/// +/// Allows a test method to assume that it is running in a specific locale. +/// +[AttributeUsage( Class | Method, AllowMultiple = false, Inherited = true )] +internal sealed class AssumeCultureAttribute : BeforeAfterTestAttribute +{ + private CultureInfo originalCulture; + private CultureInfo originalUICulture; + + public AssumeCultureAttribute( string name ) => Name = name; + + public string Name { get; } + + public override void Before( MethodInfo methodUnderTest, IXunitTest test ) + { + originalCulture = CurrentThread.CurrentCulture; + originalUICulture = CurrentThread.CurrentUICulture; + + var culture = CultureInfo.CreateSpecificCulture( Name ); + + CurrentThread.CurrentCulture = culture; + CurrentThread.CurrentUICulture = culture; + } + + public override void After( MethodInfo methodUnderTest, IXunitTest test ) + { + CurrentThread.CurrentCulture = originalCulture; + CurrentThread.CurrentUICulture = originalUICulture; + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json new file mode 100644 index 00000000..3a29e0a2 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json @@ -0,0 +1,86 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Test API | v1", + "description": "The API was deprecated on 1/1/2026. The API was sunset on 2/10/2026.\r\n\r\n### Links\r\n\r\n- [Version Deprecation Policy](http://my.api.com/policies/versions/deprecated.html)\r\n- [Version Sunset Policy](http://my.api.com/policies/versions/sunset.html)", + "version": "1.0" + }, + "servers": [ + { + "url": "http://localhost/" + } + ], + "paths": { + "/test/{id}": { + "get": { + "tags": [ + "Test" + ], + "summary": "", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32" + } + }, + { + "name": "api-version", + "in": "query", + "description": "The requested API version", + "required": true, + "schema": { + "type": "string", + "default": "1.0" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + } + }, + "400": { + "description": "Bad Request" + } + } + } + } + }, + "tags": [ + { + "name": "Test" + } + ], + "x-api-versioning": [ + { + "title": "Version Deprecation Policy", + "type": "text/html", + "rel": "deprecation", + "url": "http://my.api.com/policies/versions/deprecated.html" + }, + { + "title": "Version Sunset Policy", + "type": "text/html", + "rel": "sunset", + "url": "http://my.api.com/policies/versions/sunset.html" + } + ] +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json new file mode 100644 index 00000000..30b4afc6 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json @@ -0,0 +1,157 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Test API | v1", + "description": "The API was deprecated on 1/1/2026. The API was sunset on 2/10/2026.\r\n\r\n### Links\r\n\r\n- [Version Deprecation Policy](http://my.api.com/policies/versions/deprecated.html)\r\n- [Version Sunset Policy](http://my.api.com/policies/versions/sunset.html)", + "version": "1.0" + }, + "servers": [ + { + "url": "http://localhost/" + } + ], + "paths": { + "/minimal/{id}": { + "get": { + "tags": [ + "Test" + ], + "summary": "", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32" + } + }, + { + "name": "api-version", + "in": "query", + "description": "The requested API version", + "required": true, + "schema": { + "type": "string", + "default": "1.0" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + } + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/Test": { + "get": { + "tags": [ + "Test" + ], + "summary": "", + "description": "", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + }, + { + "name": "api-version", + "in": "query", + "description": "The requested API version", + "required": true, + "schema": { + "type": "string", + "default": "1.0" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + }, + "application/json": { + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + }, + "text/json": { + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + } + } + } + } + } + }, + "tags": [ + { + "name": "Test" + } + ], + "x-api-versioning": [ + { + "title": "Version Deprecation Policy", + "type": "text/html", + "rel": "deprecation", + "url": "http://my.api.com/policies/versions/deprecated.html" + }, + { + "title": "Version Sunset Policy", + "type": "text/html", + "rel": "sunset", + "url": "http://my.api.com/policies/versions/sunset.html" + } + ] +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/AcceptanceTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/AcceptanceTest.cs index 738e5d89..2e8652cc 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/AcceptanceTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/AcceptanceTest.cs @@ -7,25 +7,33 @@ namespace Asp.Versioning.OpenApi.Transformers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using System.Collections.Generic; +using System.Globalization; using System.Net.Http.Json; using System.Text.Json.Nodes; public class AcceptanceTest { + /// + /// Verifies that minimal API endpoints produce a non-empty OpenAPI document. + /// AddApiExplorer internally calls AddMvcCore().AddApiExplorer(), + /// which auto-discovers controllers from the test assembly. Application parts + /// are cleared to isolate the test to minimal API endpoints only. + /// + /// A representing the asynchronous unit test. [Fact] + [AssumeCulture( "en-US" )] public async Task minimal_api_should_generate_expected_open_api_document() { // arrange var builder = WebApplication.CreateBuilder(); - + var culture = CultureInfo.CurrentCulture; builder.WebHost.UseTestServer(); - builder.Services.AddControllers() - .AddApplicationPart( GetType().Assembly ); builder.Services.AddApiVersioning( options => AddPolicies( options ) ) - .AddMvc() .AddApiExplorer( options => options.GroupNameFormat = "'v'VVV" ) .AddOpenApi(); + builder.Services.AddMvcCore() + .ConfigureApplicationPartManager( + m => m.ApplicationParts.Clear() ); var app = builder.Build(); var api = app.NewVersionedApi( "Test" ) @@ -36,7 +44,7 @@ public async Task minimal_api_should_generate_expected_open_api_document() app.MapOpenApi().WithDocumentPerVersion(); var cancellationToken = TestContext.Current.CancellationToken; - using var stream = File.OpenRead( Path.Combine( AppContext.BaseDirectory, "Content", "v1.json" ) ); + using var stream = File.OpenRead( Path.Combine( AppContext.BaseDirectory, "Content", "v1-minimal.json" ) ); var expected = await JsonNode.ParseAsync( stream, default, default, cancellationToken ); await app.StartAsync( cancellationToken ); @@ -51,6 +59,7 @@ public async Task minimal_api_should_generate_expected_open_api_document() } [Fact] + [AssumeCulture( "en-US" )] public async Task controller_should_generate_expected_open_api_document() { // arrange @@ -84,6 +93,46 @@ public async Task controller_should_generate_expected_open_api_document() JsonNode.DeepEquals( actual, expected ).Should().BeTrue(); } + [Fact] + [AssumeCulture( "en-US" )] + public async Task mixed_api_should_generate_expected_open_api_document() + { + // arrange + var builder = WebApplication.CreateBuilder(); + + builder.WebHost.UseTestServer(); + builder.Services.AddControllers() + .AddApplicationPart( GetType().Assembly ); + builder.Services.AddApiVersioning( options => AddPolicies( options ) ) + .AddMvc() + .AddApiExplorer( options => options.GroupNameFormat = "'v'VVV" ) + .AddOpenApi(); + + var app = builder.Build(); + + app.MapControllers(); + var api = app.NewVersionedApi( "Test" ) + .MapGroup( "/minimal" ) + .HasApiVersion( 1.0 ); + + api.MapGet( "{id:int}", MinimalApi.Get ).Produces().Produces( 400 ); + app.MapOpenApi().WithDocumentPerVersion(); + + var cancellationToken = TestContext.Current.CancellationToken; + using var stream = File.OpenRead( Path.Combine( AppContext.BaseDirectory, "Content", "v1-mixed.json" ) ); + var expected = await JsonNode.ParseAsync( stream, default, default, cancellationToken ); + + await app.StartAsync( cancellationToken ); + + using var client = app.GetTestClient(); + + // act + var actual = await client.GetFromJsonAsync( "/openapi/v1.json", cancellationToken ); + + // assert + JsonNode.DeepEquals( actual, expected ).Should().BeTrue(); + } + private static ApiVersioningOptions AddPolicies( ApiVersioningOptions options ) { options.Policies.Deprecate( 1.0 )