Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 29 additions & 19 deletions src/Core/Services/OpenAPI/OpenApiDocumentor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Mime;
using System.Text;
Expand Down Expand Up @@ -139,16 +140,22 @@ public void CreateDocument(bool doOverrideExistingDocument = false)
};

// Collect all entity tags and their descriptions for the top-level tags array
List<OpenApiTag> globalTags = new();
// Store tags in a dictionary to ensure we can reuse the same tag instances in BuildPaths
Dictionary<string, OpenApiTag> globalTagsDict = new();
foreach (KeyValuePair<string, Entity> 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()
Expand All @@ -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;
}
Expand Down Expand Up @@ -193,7 +200,7 @@ public void CreateDocument(bool doOverrideExistingDocument = false)
/// "/EntityName"
/// </example>
/// <returns>All possible paths in the DAB engine's REST API endpoint.</returns>
private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName)
private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName, Dictionary<string, OpenApiTag> globalTags)
{
OpenApiPaths pathsCollection = new();

Expand Down Expand Up @@ -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<OpenApiTag> 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<OpenApiTag> 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<OperationType, bool> configuredRestOperations = GetConfiguredRestOperations(entity, dbObject);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,43 @@ public void OpenApiDocumentor_TagsIncludeEntityDescription()
$"Expected tag for '{entityName}' with description '{expectedDescription}' not found.");
}

/// <summary>
/// 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.
/// </summary>
[TestMethod]
public void OpenApiDocumentor_NoDuplicateTags()
{
// Act: Get the tags from the OpenAPI document
IList<OpenApiTag> 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");
}
}
}
}

/// <summary>
/// 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.
Expand Down