OpenApi: Lowercase document name on registration to match AddOpenApi internal behaviour (closes #23210) (#23212)

* Lowercase OpenAPI document name on registration to match AddOpenApi internal behaviour

AddOpenApi lowercases the document name when registering its keyed services, so
ReplaceOpenApiSchemaService must receive the same lowercased key or the lookup
throws. BackOfficeOpenApiDocumentBuilder now computes a normalised registration
name and uses it for all DI calls, while keeping DocumentName in its original
casing. ShouldInclude matches [MapToApi] case-insensitively to align with how
documents are registered, and the UI dropdown label falls back to DocumentName
(original casing) rather than the lowercased registration key.
AddUmbracoOpenApiDocument applies the same normalisation for its apiName parameter.

* Add regression tests for mixed-case OpenAPI document name registration

Covers the bug scenario where AddBackOfficeOpenApiDocument with a mixed-case
name and WithJsonOptions threw InvalidOperationException at startup, and verifies
that ShouldInclude matches [MapToApi] case-insensitively.
This commit is contained in:
Laura Neto
2026-06-26 13:59:27 +02:00
committed by GitHub
parent fb1e16ff36
commit df16c114b6
3 changed files with 39 additions and 6 deletions
@@ -59,6 +59,7 @@ public static class UmbracoBuilderApiExtensions
string? jsonOptionsName = null)
where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase
{
apiName = apiName.ToLowerInvariant();
builder.Services.AddOpenApi(apiName);
builder.Services.ConfigureOptions<TConfigureOptions>();
builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle);
@@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Common.Attributes;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
@@ -116,12 +116,20 @@ public sealed class BackOfficeOpenApiDocumentBuilder
/// <param name="builder">The Umbraco builder to register services against.</param>
internal void Build(IUmbracoBuilder builder)
{
// AddOpenApi lowercases the document name when registering its keyed services (https://github.com/dotnet/aspnetcore/blob/v10.0.9/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs#L64),
// so we must normalise here to keep AddOpenApiDocumentToUi and ReplaceOpenApiSchemaService in sync.
string lowercasedDocumentName = DocumentName.ToLowerInvariant();
builder.Services.AddOpenApi(
DocumentName,
lowercasedDocumentName,
options =>
{
// ShouldInclude matches [MapToApi] case-insensitively to align with how documents are registered.
options.ShouldInclude = apiDescription =>
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
apiDescription.ActionDescriptor.EndpointMetadata
?.OfType<MapToApiAttribute>()
.Any(a => a.ApiName.Equals(DocumentName, StringComparison.OrdinalIgnoreCase))
?? false;
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
@@ -158,12 +166,12 @@ public sealed class BackOfficeOpenApiDocumentBuilder
if (_includedInUi)
{
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
builder.Services.AddOpenApiDocumentToUi(lowercasedDocumentName, _uiTitle ?? _title ?? DocumentName);
}
if (_httpJsonOptionsFactory is not null)
{
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
builder.Services.ReplaceOpenApiSchemaService(lowercasedDocumentName, _httpJsonOptionsFactory);
}
}
}
@@ -9,6 +9,7 @@ using Swashbuckle.AspNetCore.SwaggerUI;
using Umbraco.Cms.Api.Common.Attributes;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Cms.Core.DependencyInjection;
using JsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Common.OpenApi;
@@ -117,6 +118,26 @@ public class BackOfficeOpenApiDocumentBuilderTests
Assert.IsTrue(urls is null || urls.All(url => url.Name != "My API" && url.Name != DocumentName));
}
[Test]
public void Build_With_Mixed_Case_Name_And_WithJsonOptions_Does_Not_Throw()
{
// AddOpenApi lowercases the document name internally; our registration logic must account for that.
Assert.DoesNotThrow(() => Build("MixedCaseDocument", b => b.WithJsonOptions(new JsonOptions())));
}
[Test]
public void ShouldInclude_Matches_MapToApi_Case_Insensitively_When_Document_Name_Is_Mixed_Case()
{
// ShouldInclude matches [MapToApi] case-insensitively so callers are not forced to use exact casing.
ServiceCollection services = Build("MixedCaseDocument");
ServiceProvider provider = services.BuildServiceProvider();
OpenApiOptions options = provider.GetRequiredService<IOptionsMonitor<OpenApiOptions>>().Get("mixedcasedocument");
Assert.IsTrue(options.ShouldInclude!(CreateApiDescription(new MapToApiAttribute("MixedCaseDocument"))));
Assert.IsTrue(options.ShouldInclude!(CreateApiDescription(new MapToApiAttribute("MIXEDCASEDOCUMENT"))));
Assert.IsTrue(options.ShouldInclude!(CreateApiDescription(new MapToApiAttribute("mixedcasedocument"))));
}
private static OpenApiOptions BuildAndResolveOptions(Action<BackOfficeOpenApiDocumentBuilder>? configure = null)
{
ServiceCollection services = Build(configure);
@@ -132,10 +153,13 @@ public class BackOfficeOpenApiDocumentBuilderTests
}
private static ServiceCollection Build(Action<BackOfficeOpenApiDocumentBuilder>? configure = null)
=> Build(DocumentName, configure);
private static ServiceCollection Build(string documentName, Action<BackOfficeOpenApiDocumentBuilder>? configure = null)
{
var services = new ServiceCollection();
IUmbracoBuilder umbracoBuilder = Mock.Of<IUmbracoBuilder>(b => b.Services == services);
var documentBuilder = new BackOfficeOpenApiDocumentBuilder(DocumentName);
var documentBuilder = new BackOfficeOpenApiDocumentBuilder(documentName);
configure?.Invoke(documentBuilder);
documentBuilder.Build(umbracoBuilder);