Skip to content
Open
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
34 changes: 20 additions & 14 deletions src/Core/Services/OpenAPI/OpenApiDocumentor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,9 @@ private OpenApiDocument BuildOpenApiDocument(RuntimeConfig runtimeConfig, string
Schemas = CreateComponentSchemas(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, role, isRequestBodyStrict: runtimeConfig.IsRequestBodyStrict)
};

List<OpenApiTag> globalTags = new();
// Store tags in a dictionary keyed by normalized REST path to ensure we can
// reuse the same tag instances in BuildPaths, preventing duplicate groups in Swagger UI.
Dictionary<string, OpenApiTag> globalTagsDict = new();
foreach (KeyValuePair<string, Entity> kvp in runtimeConfig.Entities)
{
Entity entity = kvp.Value;
Expand All @@ -210,8 +212,12 @@ private OpenApiDocument BuildOpenApiDocument(RuntimeConfig runtimeConfig, string
continue;
}

string restPath = entity.Rest?.Path ?? kvp.Key;
globalTags.Add(new OpenApiTag
// Use GetEntityRestPath to ensure consistent path normalization (with leading slash trimmed)
// matching the same computation used in BuildPaths.
string restPath = GetEntityRestPath(entity.Rest, kvp.Key);

// First entity's description wins when multiple entities share the same REST path.
globalTagsDict.TryAdd(restPath, new OpenApiTag
{
Name = restPath,
Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description
Expand All @@ -229,9 +235,9 @@ private OpenApiDocument BuildOpenApiDocument(RuntimeConfig runtimeConfig, string
{
new() { Url = url }
},
Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, role),
Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, globalTagsDict, role),
Components = components,
Tags = globalTags
Tags = globalTagsDict.Values.ToList()
};
}

Expand Down Expand Up @@ -291,9 +297,10 @@ public void CreateDocument(bool doOverrideExistingDocument = false)
/// A path with no primary key nor parameter representing the primary key value:
/// "/EntityName"
/// </example>
/// <param name="globalTags">Dictionary of global tags keyed by normalized REST path for reuse.</param>
/// <param name="role">Optional role to filter permissions. If null, returns superset of all roles.</param>
/// <returns>All possible paths in the DAB engine's REST API endpoint.</returns>
private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName, string? role = null)
private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName, Dictionary<string, OpenApiTag> globalTags, string? role = null)
{
OpenApiPaths pathsCollection = new();

Expand Down Expand Up @@ -327,18 +334,17 @@ 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 instance.
// This ensures Swagger UI displays only one group per entity by using the same object reference.
if (!globalTags.TryGetValue(entityRestPath, out OpenApiTag? existingTag))
{
Name = entityRestPath,
Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description
};
_logger.LogWarning("Tag for REST path '{EntityRestPath}' not found in global tags dictionary. This indicates a key mismatch between BuildOpenApiDocument and BuildPaths.", entityRestPath);
continue;
}
Comment on lines +337 to +343
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildPaths logs a warning and continues when the tag isn’t found in globalTags. This can happen in normal, non-mismatch scenarios for role-filtered documents: BuildOpenApiDocument only adds tags when HasAnyAvailableOperations(entity, role) is true, but BuildPaths iterates all REST-enabled entities from metadata and attempts the tag lookup before checking configuredRestOperations. This can produce noisy warnings and also couples path generation to tag presence. Consider moving the permission/operation filtering (e.g., HasAnyAvailableOperations or configuredRestOperations.ContainsValue(true)) before the tag lookup, and only warn/throw when an entity should be included but the tag key is still missing.

Copilot uses AI. Check for mistakes.

// 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()
{
openApiTag
existingTag
};

Dictionary<OperationType, bool> configuredRestOperations = GetConfiguredRestOperations(entity, dbObject, role);
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
List<string> tagNames = tags.Select(t => t.Name).ToList();

// Get distinct tag names
List<string> 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)}");
Comment on lines +158 to +171
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test verifies reference equality between global and operation tags, but it doesn’t exercise the reported key-mismatch scenario where an entity has a REST path with a leading slash (e.g., "/Actor"). As written, _runtimeEntities uses the default REST path, so the test would pass even if slash-normalization regressed. Consider setting the test entity’s Rest.Path to a value with a leading slash (e.g., "/sp1") and asserting that both the global tag name and the operation tag name are normalized (no leading slash) and reference the same instance.

Copilot uses AI. Check for mistakes.

// Additionally, verify that each operation references tags that are in the global tags list
foreach (KeyValuePair<string, OpenApiPathItem> path in _openApiDocument.Paths)
{
foreach (KeyValuePair<OperationType, OpenApiOperation> operation in path.Value.Operations)
{
foreach (OpenApiTag 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
Loading