From 293bb7dfc7597e5826f920b8a277b262c049f33a Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:29:19 +0100 Subject: [PATCH 1/2] 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. --- .../UmbracoBuilderApiExtensions.cs | 1 + .../BackOfficeOpenApiDocumentBuilder.cs | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderApiExtensions.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderApiExtensions.cs index 11c9151fed45..5487d24882af 100644 --- a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderApiExtensions.cs +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderApiExtensions.cs @@ -59,6 +59,7 @@ internal static void AddUmbracoOpenApiDocument( string? jsonOptionsName = null) where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase { + apiName = apiName.ToLowerInvariant(); builder.Services.AddOpenApi(apiName); builder.Services.ConfigureOptions(); builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle); diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/BackOfficeOpenApiDocumentBuilder.cs b/src/Umbraco.Cms.Api.Common/OpenApi/BackOfficeOpenApiDocumentBuilder.cs index 80203a2b69eb..9a5c7c2f0453 100644 --- a/src/Umbraco.Cms.Api.Common/OpenApi/BackOfficeOpenApiDocumentBuilder.cs +++ b/src/Umbraco.Cms.Api.Common/OpenApi/BackOfficeOpenApiDocumentBuilder.cs @@ -2,9 +2,9 @@ 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 BackOfficeOpenApiDocumentBuilder WithJsonOptions(FuncThe Umbraco builder to register services against. 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() + .Any(a => a.ApiName.Equals(DocumentName, StringComparison.OrdinalIgnoreCase)) + ?? false; options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId; @@ -158,12 +166,12 @@ internal void Build(IUmbracoBuilder builder) 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); } } } From 6303dbde3f9026f7669fa9c9cd27c8f81bc43c05 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:15:39 +0100 Subject: [PATCH 2/2] 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. --- .../BackOfficeOpenApiDocumentBuilderTests.cs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Common/OpenApi/BackOfficeOpenApiDocumentBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Common/OpenApi/BackOfficeOpenApiDocumentBuilderTests.cs index 74e5669aafa8..1b5b2a539b18 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Common/OpenApi/BackOfficeOpenApiDocumentBuilderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Common/OpenApi/BackOfficeOpenApiDocumentBuilderTests.cs @@ -9,6 +9,7 @@ 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 void ExcludeFromUi_Skips_Swagger_Dropdown_Registration() 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>().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? configure = null) { ServiceCollection services = Build(configure); @@ -132,10 +153,13 @@ private static SwaggerUIOptions BuildAndResolveSwaggerOptions(Action? configure = null) + => Build(DocumentName, configure); + + private static ServiceCollection Build(string documentName, Action? configure = null) { var services = new ServiceCollection(); IUmbracoBuilder umbracoBuilder = Mock.Of(b => b.Services == services); - var documentBuilder = new BackOfficeOpenApiDocumentBuilder(DocumentName); + var documentBuilder = new BackOfficeOpenApiDocumentBuilder(documentName); configure?.Invoke(documentBuilder); documentBuilder.Build(umbracoBuilder);