diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs index cb6cc5e6b774..9777549cc108 100644 --- a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs @@ -30,10 +30,12 @@ public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder e var options = endpoints.ServiceProvider.GetRequiredService>(); return endpoints.MapGet(pattern, async (HttpContext context, string documentName = OpenApiConstants.DefaultDocumentName) => { - // We need to retrieve the document name in a case-insensitive manner - // to support case-insensitive document name resolution. - // Keyed Services are case-sensitive by default, which doesn't work well for document names in ASP.NET Core - // as routing in ASP.NET Core is case-insensitive by default. + // We need to retrieve the document name in a case-insensitive manner to support case-insensitive document name resolution. + // The document service is registered with a key equal to the document name, but in lowercase. + // The GetRequiredKeyedService() method is case-sensitive, which doesn't work well for OpenAPI document names here, + // as the document name is also used as the route to retrieve the document, so we need to ensure this is lowercased to achieve consistency with ASP.NET Core routing. + // The same goes for the document options below, which is also case-sensitive, and thus we need to pass in a case-insensitive document name. + // See OpenApiServiceCollectionExtensions.cs for more info. var lowercasedDocumentName = documentName.ToLowerInvariant(); // It would be ideal to use the `HttpResponseStreamWriter` to @@ -50,7 +52,7 @@ public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder e else { var document = await documentService.GetOpenApiDocumentAsync(context.RequestServices, context.RequestAborted); - var documentOptions = options.Get(documentName); + var documentOptions = options.Get(lowercasedDocumentName); using var output = MemoryBufferWriter.Get(); using var writer = Utf8BufferTextWriter.Get(output); try diff --git a/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs index 5e848bdc5851..407375bd327e 100644 --- a/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs @@ -57,10 +57,9 @@ public static IServiceCollection AddOpenApi(this IServiceCollection services, st ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configureOptions); - // We need to store the document name in a case-insensitive manner - // to support case-insensitive document name resolution. - // Keyed Services are case-sensitive by default, which doesn't work well for document names in ASP.NET Core - // as routing in ASP.NET Core is case-insensitive by default. + // We need to register the document name in a case-insensitive manner to support case-insensitive document name resolution. + // The document name is used to store and retrieve keyed services and configuration options, which are all case-sensitive. + // To achieve parity with ASP.NET Core routing, which is case-insensitive, we need to ensure the document name is lowercased. var lowercasedDocumentName = documentName.ToLowerInvariant(); services.AddOpenApiCore(lowercasedDocumentName); @@ -106,6 +105,9 @@ public static IServiceCollection AddOpenApi(this IServiceCollection services, Ac public static IServiceCollection AddOpenApi(this IServiceCollection services) => services.AddOpenApi(OpenApiConstants.DefaultDocumentName); + /// The to register services onto. + /// Please ensure this is lowercased to prevent case-sensitive routing issues + /// See https://github.com/dotnet/aspnetcore/issues/59175 for more information around the routing issue mentioned above private static IServiceCollection AddOpenApiCore(this IServiceCollection services, string documentName) { services.AddEndpointsApiExplorer(); diff --git a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs index d5bec60be52c..dc9144be8f5d 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs @@ -25,10 +25,17 @@ internal sealed class OpenApiDocumentProvider(IServiceProvider serviceProvider) /// A text writer associated with the document to write to. public async Task GenerateAsync(string documentName, TextWriter writer) { + // We need to retrieve the document name in a case-insensitive manner to support case-insensitive document name resolution. + // The options are registered with a key equal to the document name, but in lowercase. + // The options monitor's Get() method is case-sensitive, which doesn't work well for OpenAPI document names here, + // as the document name is also used as the route to retrieve the document, so we need to ensure this is lowercased to achieve consistency with ASP.NET Core routing. + // See OpenApiServiceCollectionExtensions.cs for more info. + var lowercasedDocumentName = documentName.ToLowerInvariant(); + var options = serviceProvider.GetRequiredService>(); - var namedOption = options.Get(documentName); + var namedOption = options.Get(lowercasedDocumentName); var resolvedOpenApiVersion = namedOption.OpenApiVersion; - await GenerateAsync(documentName, writer, resolvedOpenApiVersion); + await GenerateAsync(lowercasedDocumentName, writer, resolvedOpenApiVersion); } /// @@ -40,10 +47,17 @@ public async Task GenerateAsync(string documentName, TextWriter writer) /// The OpenAPI specification version to use when serializing the document. public async Task GenerateAsync(string documentName, TextWriter writer, OpenApiSpecVersion openApiSpecVersion) { + // We need to retrieve the document name in a case-insensitive manner to support case-insensitive document name resolution. + // The document service is registered with a key equal to the document name, but in lowercase. + // The GetRequiredKeyedService() method is case-sensitive, which doesn't work well for OpenAPI document names here, + // as the document name is also used as the route to retrieve the document, so we need to ensure this is lowercased to achieve consistency with ASP.NET Core routing. + // See OpenApiServiceCollectionExtensions.cs for more info. + var lowercasedDocumentName = documentName.ToLowerInvariant(); + // Microsoft.OpenAPI does not provide async APIs for writing the JSON // document to a file. See https://github.com/microsoft/OpenAPI.NET/issues/421 for // more info. - var targetDocumentService = serviceProvider.GetRequiredKeyedService(documentName); + var targetDocumentService = serviceProvider.GetRequiredKeyedService(lowercasedDocumentName); using var scopedService = serviceProvider.CreateScope(); var document = await targetDocumentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider); var jsonWriter = new OpenApiJsonWriter(writer); diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs index ab4549f2b5ed..b3f606b72234 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs @@ -12,6 +12,7 @@ using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Readers; using System.Text; +using Microsoft.OpenApi; public class OpenApiEndpointRouteBuilderExtensionsTests : OpenApiDocumentServiceTestBase { @@ -156,6 +157,42 @@ public async Task MapOpenApi_ReturnsDocumentWhenPathIsCaseSensitive(string regis Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } + [Fact] + public async Task MapOpenApi_ShouldRetrieveOptionsInACaseInsensitiveManner() + { + // Arrange + var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) }; + var serviceProviderIsService = new ServiceProviderIsService(); + var serviceProvider = CreateServiceProvider("casesensitive", OpenApiSpecVersion.OpenApi2_0); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapOpenApi("/openapi/{documentName}.json"); + var context = new DefaultHttpContext(); + var responseBodyStream = new MemoryStream(); + context.Response.Body = responseBodyStream; + context.RequestServices = serviceProvider; + context.Request.RouteValues.Add("documentName", "CaseSensitive"); + var endpoint = builder.DataSources.First().Endpoints[0]; + + // Act + var requestDelegate = endpoint.RequestDelegate; + await requestDelegate(context); + + // Assert + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + var responseString = Encoding.UTF8.GetString(responseBodyStream.ToArray()); + + // When we receive an OpenAPI document, we use an OptionsMonitor to retrieve OpenAPI options which are stored with a key equal the requested document name. + // This key is case-sensitive. If the document doesn't exist, the options monitor return a default instance, in which the OpenAPI version is set to v3. + // This could cause bugs! You'd get your document, but depending on the casing you used in the document name you passed to the function, you'll receive different OpenAPI document versions. + // We want to prevent this from happening. Therefore: + // By setting up a v2 document on the "casesensitive" route and requesting it on "CaseSensitive", + // we can test that the we've configured the options monitor to retrieve the options in a case-insensitive manner. + // If it is case-sensitive, it would return a default instance with OpenAPI version v3, which would cause this test to fail! + // However, if it would return the v2 instance, which was configured on the lowercase - case-insensitive - documentname, the test would pass! + // For more info, see OpenApiEndpointRouteBuilderExtensions.cs + Assert.StartsWith("{\n \"swagger\": \"2.0\"", responseString); + } + [Theory] [InlineData("/openapi.json", "application/json;charset=utf-8", false)] [InlineData("/openapi.yaml", "text/plain+yaml;charset=utf-8", true)] @@ -201,7 +238,7 @@ private static void ValidateOpenApiDocument(MemoryStream documentStream, Action< action(document); } - private static IServiceProvider CreateServiceProvider(string documentName = Microsoft.AspNetCore.OpenApi.OpenApiConstants.DefaultDocumentName) + private static IServiceProvider CreateServiceProvider(string documentName = Microsoft.AspNetCore.OpenApi.OpenApiConstants.DefaultDocumentName, OpenApiSpecVersion openApiSpecVersion = OpenApiSpecVersion.OpenApi3_0) { var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) }; var serviceProviderIsService = new ServiceProviderIsService(); @@ -210,7 +247,7 @@ private static IServiceProvider CreateServiceProvider(string documentName = Micr .AddSingleton(hostEnvironment) .AddSingleton(CreateApiDescriptionGroupCollectionProvider()) .AddSingleton(NullLoggerFactory.Instance) - .AddOpenApi(documentName) + .AddOpenApi(documentName, x => x.OpenApiVersion = openApiSpecVersion) .BuildServiceProvider(); return serviceProvider; } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentProviderTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentProviderTests.cs index 92203c29cc54..1f7b969ad9dc 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentProviderTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentProviderTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.ApiDescriptions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Readers; using static Microsoft.AspNetCore.OpenApi.Tests.OpenApiOperationGeneratorTests; @@ -30,6 +31,56 @@ public async Task GenerateAsync_ReturnsDocument() }); } + [Fact] + public async Task GenerateAsync_ShouldRetrieveOptionsInACaseInsensitiveManner() + { + // Arrange + var documentName = "CaseSensitive"; + var serviceProvider = CreateServiceProvider(["casesensitive"], OpenApiSpecVersion.OpenApi2_0); + var documentProvider = new OpenApiDocumentProvider(serviceProvider); + var stringWriter = new StringWriter(); + + // Act + await documentProvider.GenerateAsync(documentName, stringWriter); + + // Assert + var document = stringWriter.ToString(); + + // When we generate an OpenAPI document, we use an OptionsMonitor to retrieve OpenAPI options which are stored with a key equal the requested document name. + // This key is case-sensitive. If the document doesn't exist, the options monitor return a default instance, in which the OpenAPI version is set to v3. + // This could cause bugs! You'd get your document, but depending on the casing you used in the document name you passed to the function, you'll receive different OpenAPI document versions. + // We want to prevent this from happening. Therefore: + // By setting up a v2 document on the "casesensitive" route and requesting it on "CaseSensitive", + // we can test that the we've configured the options monitor to retrieve the options in a case-insensitive manner. + // If it is case-sensitive, it would return a default instance with OpenAPI version v3, which would cause this test to fail! + // However, if it would return the v2 instance, which was configured on the lowercase - case-insensitive - documentname, the test would pass! + Assert.StartsWith("{\n \"swagger\": \"2.0\"", document); + } + + [Fact] + public async Task GenerateAsync_ShouldRetrieveOpenApiDocumentServiceWithACaseInsensitiveKey() + { + // Arrange + var documentName = "CaseSensitive"; + var serviceProvider = CreateServiceProvider(["casesensitive"]); + var documentProvider = new OpenApiDocumentProvider(serviceProvider); + var stringWriter = new StringWriter(); + + // Act + await documentProvider.GenerateAsync(documentName, stringWriter, OpenApiSpecVersion.OpenApi3_0); + + // Assert + + // If the Document Service is retrieved with a non-existent (case-sensitive) key, it would throw: + // https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.serviceproviderkeyedserviceextensions.getrequiredkeyedservice?view=net-9.0-pp + + // In this test's case, we're testing that the document service is retrieved with a case-insensitive key. + // It's registered as "casesensitive" but we're passing in "CaseSensitive", which doesn't exist. + // Therefore, if the test doesn't throw, we know it has passed correctly. + // We still do a small check to validate the document, just in case. But the main test is that it doesn't throw. + ValidateOpenApiDocument(stringWriter, _ => { }); + } + [Fact] public void GetDocumentNames_ReturnsAllRegisteredDocumentName() { @@ -56,7 +107,7 @@ private static void ValidateOpenApiDocument(StringWriter stringWriter, Action x.OpenApiVersion = openApiSpecVersion); } var serviceProvider = serviceCollection.BuildServiceProvider(validateScopes: true); return serviceProvider;