diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index 87fb96bc32..46c7e456f5 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Linq; using System.Net; using System.Net.Mime; using System.Text; @@ -139,16 +140,22 @@ public void CreateDocument(bool doOverrideExistingDocument = false) }; // Collect all entity tags and their descriptions for the top-level tags array - List globalTags = new(); + // Store tags in a dictionary to ensure we can reuse the same tag instances in BuildPaths + Dictionary globalTagsDict = new(); foreach (KeyValuePair kvp in runtimeConfig.Entities) { Entity entity = kvp.Value; string restPath = entity.Rest?.Path ?? kvp.Key; - globalTags.Add(new OpenApiTag + + // Only add the tag if it hasn't been added yet (handles entities with the same REST path) + if (!globalTagsDict.ContainsKey(restPath)) { - Name = restPath, - Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description - }); + globalTagsDict[restPath] = new OpenApiTag + { + Name = restPath, + Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description + }; + } } OpenApiDocument doc = new() @@ -162,9 +169,9 @@ public void CreateDocument(bool doOverrideExistingDocument = false) { new() { Url = url } }, - Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName), + Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, globalTagsDict), Components = components, - Tags = globalTags + Tags = globalTagsDict.Values.ToList() }; _openApiDocument = doc; } @@ -193,7 +200,7 @@ public void CreateDocument(bool doOverrideExistingDocument = false) /// "/EntityName" /// /// All possible paths in the DAB engine's REST API endpoint. - private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName) + private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName, Dictionary globalTags) { OpenApiPaths pathsCollection = new(); @@ -227,19 +234,22 @@ private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSour continue; } - // Set the tag's Description property to the entity's semantic description if present. - OpenApiTag openApiTag = new() + // Reuse the existing tag from the global tags dictionary instead of creating a new one + // This ensures Swagger UI displays only one group per entity + List tags = new(); + if (globalTags.TryGetValue(entityRestPath, out OpenApiTag? existingTag)) { - Name = entityRestPath, - Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description - }; - - // The OpenApiTag will categorize all paths created using the entity's name or overridden REST path value. - // The tag categorization will instruct OpenAPI document visualization tooling to display all generated paths together. - List tags = new() + tags.Add(existingTag); + } + else { - openApiTag - }; + // Fallback: create a new tag if not found in global tags (should not happen in normal flow) + tags.Add(new OpenApiTag + { + Name = entityRestPath, + Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description + }); + } Dictionary configuredRestOperations = GetConfiguredRestOperations(entity, dbObject); diff --git a/src/Service.Tests/OpenApiDocumentor/StoredProcedureGeneration.cs b/src/Service.Tests/OpenApiDocumentor/StoredProcedureGeneration.cs index ffd5aaadde..18f696430d 100644 --- a/src/Service.Tests/OpenApiDocumentor/StoredProcedureGeneration.cs +++ b/src/Service.Tests/OpenApiDocumentor/StoredProcedureGeneration.cs @@ -149,6 +149,43 @@ public void OpenApiDocumentor_TagsIncludeEntityDescription() $"Expected tag for '{entityName}' with description '{expectedDescription}' not found."); } + /// + /// Integration test validating that there are no duplicate tags in the OpenAPI document. + /// This test ensures that tags created in CreateDocument are reused in BuildPaths, + /// preventing Swagger UI from showing duplicate entity groups. + /// + [TestMethod] + public void OpenApiDocumentor_NoDuplicateTags() + { + // Act: Get the tags from the OpenAPI document + IList tags = _openApiDocument.Tags; + + // Get all tag names + var tagNames = tags.Select(t => t.Name).ToList(); + + // Get distinct tag names + var distinctTagNames = tagNames.Distinct().ToList(); + + // Assert: The number of tags should equal the number of distinct tag names (no duplicates) + Assert.AreEqual(distinctTagNames.Count, tagNames.Count, + $"Duplicate tags found in OpenAPI document. Tags: {string.Join(", ", tagNames)}"); + + // Additionally, verify that each operation references tags that are in the global tags list + foreach (var path in _openApiDocument.Paths) + { + foreach (var operation in path.Value.Operations) + { + foreach (var operationTag in operation.Value.Tags) + { + // Verify that the operation's tag is the same instance as one in the global tags + bool foundMatchingTag = tags.Any(globalTag => ReferenceEquals(globalTag, operationTag)); + Assert.IsTrue(foundMatchingTag, + $"Operation tag '{operationTag.Name}' at path '{path.Key}' is not the same instance as the global tag"); + } + } + } + } + /// /// Validates that the provided OpenApiReference object has the expected schema reference id /// and that that id is present in the list of component schema in the OpenApi document.