Skip to content

Commit e14cca5

Browse files
CopilotJerryNixonsouvikghosh04
authored
Fix duplicate entity groups in Swagger UI when entities have descriptions (#3099)
## Why make this change? Swagger UI displays duplicate groups for entities with descriptions (e.g., "Actor" appears twice). OpenAPI tags were instantiated separately in `BuildOpenApiDocument` (global tags) and `BuildPaths` (operation tags), causing Swagger UI to treat identical tag names as distinct groups. Additionally, a critical bug existed where REST paths with leading slashes (e.g., `"path": "/Actor"`) would cause key mismatches between `BuildOpenApiDocument` and `BuildPaths`, leading to duplicate tag instances even with the shared dictionary approach. ## What is this change? Share tag instances between global and operation-level tags, with consistent REST path normalization. Applied on top of main's refactored `BuildOpenApiDocument`/`BuildPaths` architecture: **Before:** ```csharp // BuildOpenApiDocument creates tag instance (no slash normalization) List<OpenApiTag> globalTags = new(); string restPath = entity.Rest?.Path ?? kvp.Key; // Keeps "/Actor" with slash globalTags.Add(new OpenApiTag { Name = restPath, Description = "..." }); // BuildPaths creates ANOTHER tag instance (with slash trimmed) string entityRestPath = GetEntityRestPath(...); // Returns "Actor" without slash OpenApiTag tag = new() { Name = entityRestPath, Description = "..." }; ``` **After:** ```csharp // BuildOpenApiDocument stores tags in dictionary with consistent path normalization Dictionary<string, OpenApiTag> globalTagsDict = new(); string restPath = GetEntityRestPath(entity.Rest, kvp.Key); // Both use same normalization globalTagsDict.TryAdd(restPath, new OpenApiTag { Name = restPath, Description = "..." }); // BuildPaths reuses the same instance (no fallback that silently reintroduces duplicates) if (!globalTags.TryGetValue(entityRestPath, out OpenApiTag? existingTag)) { _logger.LogWarning("Tag for REST path '{EntityRestPath}' not found in global tags dictionary...", entityRestPath); continue; } tags.Add(existingTag); // Same object reference ``` **Changes:** - `OpenApiDocumentor.BuildOpenApiDocument`: - Store tags in `Dictionary<string, OpenApiTag>` keyed by REST path - Use `GetEntityRestPath` for consistent path normalization (trims leading slashes) - Use `TryAdd` for cleaner deduplication - First entity's description wins when multiple entities share the same REST path - Pass global tags dictionary to `BuildPaths` for instance reuse - `OpenApiDocumentor.BuildPaths`: - Accept `globalTags` dictionary parameter and reuse existing tag instances - Replaced silent fallback `else` block (which would reintroduce duplicate tags) with `_logger.LogWarning` + `continue` to surface any key mismatch immediately - `TagValidationTests.cs`: - Added comprehensive integration tests: `NoDuplicateTags_WithDescription`, `SharedTagInstances_BetweenGlobalAndOperationTags`, `RestDisabledEntity_ProducesNoTag`, `RoleFilteredEntity_ProducesNoTag` - Tests exercise leading-slash normalization, entity descriptions, REST-disabled entities, and role-based filtering - `StoredProcedureGeneration.cs`: - Removed old `OpenApiDocumentor_NoDuplicateTags` test (moved to `TagValidationTests`) ## How was this tested? - [x] Integration Tests - `NoDuplicateTags_WithDescription`: Verifies no duplicate tag names in global tags when entities have descriptions - `SharedTagInstances_BetweenGlobalAndOperationTags`: Verifies operation tags reference same instances as global tags (via `ReferenceEquals`) - `RestDisabledEntity_ProducesNoTag`: Verifies REST-disabled entities produce no global tag - `RoleFilteredEntity_ProducesNoTag`: Verifies entities filtered by role produce no global tag - [x] Unit Tests ## Sample Request(s) N/A - This is an OpenAPI document generation fix. The Swagger UI at `/api/openapi` will now display each entity group once instead of duplicating them, even when REST paths are configured with leading slashes. <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> ---- *This section details on the original issue you should resolve* <issue_title>[Bug]: Swagger UI shows duplicate entity groups for single entity definition</issue_title> <issue_description>When using Data API builder 1.7.75, the Swagger UI generated at `/api/openapi` displays the `Actor` entity twice, even though `Actor` is defined only once in the configuration. No other entity shares its REST path or alias. > I think this has to do with `descriptions` <img width="934" height="1112" alt="Image" src="https://github.com/user-attachments/assets/ed06124e-2550-431a-9155-ac2d4d875aca" /> **Steps to Reproduce:** 1. Use this configuration (trimmed for brevity): ```json { "entities": { "Actor": { "source": { "object": "dbo.Actor", "type": "table" }, "rest": { "enabled": true, "path": "/Actor" } }, "Character": { "source": { "object": "dbo.Character", "type": "table" }, "rest": { "enabled": true, "path": "/Character" } }, "SeriesActors": { "source": { "object": "dbo.SeriesActors", "type": "view" }, "rest": { "enabled": true, "path": "/SeriesActors" } }, "GetSeriesActors": { "source": { "object": "dbo.GetSeriesActors", "type": "stored-procedure" }, "rest": { "enabled": true, "path": "/GetSeriesActors", "methods": ["post"] } } } } ``` 2. Start DAB (`dab start --config dab-config.json`). 3. Open the Swagger UI at `/api/openapi`. **Expected Behavior:** Each entity should appear once under its respective REST path group. **Actual Behavior:** Swagger shows two separate groups labeled `Actor`, each listing the same endpoints (`GET`, `PUT`, `PATCH`, `DELETE`, `POST`). **Environment:** * Data API builder version: 1.7.75 * Database: SQL Server * Host: Azure Container Apps * Authentication: StaticWebApps provider **Notes:** * No other entity defines `path: "/Actor"`. * The duplication persists even when unrelated entities (like stored procedures or views) are removed. * Suggest investigating the Swagger document generator—likely a duplicate path or tag registration in `OpenApiDocumentBuilder`. </issue_description> <agent_instructions>Resolve this with as little new code as possible</agent_instructions> ## Comments on the Issue (you are @copilot in this section) <comments> <comment_new><author>@JerryNixon</author><body> ### The Root Cause The issue is that **tags are being added to the OpenAPI document in TWO different places**, and when an entity has a `description` property, it creates tags with **different object identities but the same name**. ### Where Tags Are Created **1. Global Tags (in `CreateDocument` method):** ```csharp name=src/Core/Services/OpenAPI/OpenApiDocumentor.cs url=https://github.com/Azure/data-api-builder/blob/b11ab1a812d404ae8f176bc0cc4e483eac640133/src/Core/Services/OpenAPI/OpenApiDocumentor.cs#L145-L155 // Collect all entity tags and their descriptions for the top-level tags array List<OpenApiTag> globalTags = new(); foreach (KeyValuePair<string, Entity> kvp in runtimeConfig.Entities) { Entity entity = kvp.Value; string restPath = entity.Rest?.Path ?? kvp.Key; globalTags.Add(new OpenApiTag { Name = restPath, Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description }); } OpenApiDocument doc = new() { // ... Tags = globalTags // ← Global tags added here }; ``` **2. Per-Path Tags (in `BuildPaths` method):** ```csharp name=src/Core/Services/OpenAPI/OpenApiDocumentor.cs url=https://github.com/Azure/data-api-builder/blob/b11ab1a812d404ae8f176bc0cc4e483eac640133/src/Core/Services/OpenAPI/OpenApiDocumentor.cs#L229-L241 // Set the tag's Description property to the entity's semantic description if present. OpenApiTag openApiTag = new() { 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. List<OpenApiTag> tags = new() { openApiTag // ← Per-path tag created here }; ``` These per-path tags are then assigned to each operation, and each operation is added to the document's paths. ### Why This Causes Duplication When Swagger UI renders the OpenAPI document, it looks at: 1. **Document-level tags** (`doc.Tags`) - Added in `CreateDocument` 2. **Operation-level tags** (each operation's `Tags` property) - Added in `BuildPaths` Even though both have the same `Name` ("Actor"), they are **different object instances** with the same `Description`. Swagger UI treats them as distinct tag definitions and displays them separately. ### Why You Noticed It with Descriptions The user comment "I think this has to do with `descriptions`" is correct! Here's why: - **Without descriptions**: Both tag objects have `Description = null`, so Swagger might merge them - **With descriptions**: The OpenAPI s... </details> <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #2968 <!-- START COPILOT CODING AGENT TIPS --> --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com> Co-authored-by: souvikghosh04 <210500244+souvikghosh04@users.noreply.github.com> Co-authored-by: souvikghosh04 <souvikofficial04@gmail.com>
1 parent fb4b2af commit e14cca5

2 files changed

Lines changed: 301 additions & 34 deletions

File tree

src/Core/Services/OpenAPI/OpenApiDocumentor.cs

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,9 @@ private OpenApiDocument BuildOpenApiDocument(RuntimeConfig runtimeConfig, string
201201
Schemas = CreateComponentSchemas(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, role, isRequestBodyStrict: runtimeConfig.IsRequestBodyStrict)
202202
};
203203

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

213-
string restPath = entity.Rest?.Path ?? kvp.Key;
214-
globalTags.Add(new OpenApiTag
215+
// Use GetEntityRestPath to ensure consistent path normalization (with leading slash trimmed)
216+
// matching the same computation used in BuildPaths.
217+
string restPath = GetEntityRestPath(entity.Rest, kvp.Key);
218+
219+
// First entity's description wins when multiple entities share the same REST path.
220+
globalTagsDict.TryAdd(restPath, new OpenApiTag
215221
{
216222
Name = restPath,
217223
Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description
@@ -229,9 +235,9 @@ private OpenApiDocument BuildOpenApiDocument(RuntimeConfig runtimeConfig, string
229235
{
230236
new() { Url = url }
231237
},
232-
Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, role),
238+
Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, globalTagsDict, role),
233239
Components = components,
234-
Tags = globalTags
240+
Tags = globalTagsDict.Values.ToList()
235241
};
236242
}
237243

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

300307
ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(defaultDataSourceName);
301308
foreach (KeyValuePair<string, DatabaseObject> entityDbMetadataMap in metadataProvider.EntityToDatabaseObject)
302309
{
303310
string entityName = entityDbMetadataMap.Key;
304-
if (!entities.ContainsKey(entityName))
311+
if (!entities.TryGetValue(entityName, out Entity? entity) || entity is null)
305312
{
306313
// This can happen for linking entities which are not present in runtime config.
307314
continue;
308315
}
309316

310-
string entityRestPath = GetEntityRestPath(entities[entityName].Rest, entityName);
311-
string entityBasePathComponent = $"/{entityRestPath}";
312-
313-
DatabaseObject dbObject = entityDbMetadataMap.Value;
314-
SourceDefinition sourceDefinition = metadataProvider.GetSourceDefinition(entityName);
315-
316317
// Entities which disable their REST endpoint must not be included in
317318
// the OpenAPI description document.
318-
if (entities.TryGetValue(entityName, out Entity? entity) && entity is not null)
319-
{
320-
if (!entity.Rest.Enabled)
321-
{
322-
continue;
323-
}
324-
}
325-
else
319+
if (!entity.Rest.Enabled)
326320
{
327321
continue;
328322
}
329323

330-
// Set the tag's Description property to the entity's semantic description if present.
331-
OpenApiTag openApiTag = new()
332-
{
333-
Name = entityRestPath,
334-
Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description
335-
};
324+
string entityRestPath = GetEntityRestPath(entity.Rest, entityName);
325+
string entityBasePathComponent = $"/{entityRestPath}";
336326

337-
// The OpenApiTag will categorize all paths created using the entity's name or overridden REST path value.
338-
// The tag categorization will instruct OpenAPI document visualization tooling to display all generated paths together.
339-
List<OpenApiTag> tags = new()
340-
{
341-
openApiTag
342-
};
327+
DatabaseObject dbObject = entityDbMetadataMap.Value;
328+
SourceDefinition sourceDefinition = metadataProvider.GetSourceDefinition(entityName);
343329

344330
Dictionary<OperationType, bool> configuredRestOperations = GetConfiguredRestOperations(entity, dbObject, role);
345331

346-
// Skip entities with no available operations
332+
// Skip entities with no available operations before looking up the tag.
333+
// This prevents noisy warnings for entities that are legitimately excluded from
334+
// the global tags dictionary due to role-based permission filtering.
347335
if (!configuredRestOperations.ContainsValue(true))
348336
{
349337
continue;
350338
}
351339

340+
// Reuse the existing tag from the global tags dictionary instead of creating a new instance.
341+
// This ensures Swagger UI displays only one group per entity by using the same object reference.
342+
if (!globalTags.TryGetValue(entityRestPath, out OpenApiTag? existingTag))
343+
{
344+
_logger.LogWarning("Tag for REST path '{EntityRestPath}' not found in global tags dictionary. This indicates a key mismatch between BuildOpenApiDocument and BuildPaths.", entityRestPath);
345+
continue;
346+
}
347+
348+
List<OpenApiTag> tags = new()
349+
{
350+
existingTag
351+
};
352+
352353
if (dbObject.SourceType is EntitySourceType.StoredProcedure)
353354
{
354355
Dictionary<OperationType, OpenApiOperation> operations = CreateStoredProcedureOperations(
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Threading.Tasks;
7+
using Azure.DataApiBuilder.Config.ObjectModel;
8+
using Microsoft.OpenApi.Models;
9+
using Microsoft.VisualStudio.TestTools.UnitTesting;
10+
11+
namespace Azure.DataApiBuilder.Service.Tests.OpenApiIntegration
12+
{
13+
/// <summary>
14+
/// Integration tests validating that OpenAPI tags are correctly deduplicated
15+
/// and shared between global document tags and operation-level tags.
16+
/// Covers bug fix for duplicate entity groups in Swagger UI (#2968).
17+
/// </summary>
18+
[TestCategory(TestCategory.MSSQL)]
19+
[TestClass]
20+
public class TagValidationTests
21+
{
22+
private const string CONFIG_FILE = "tag-validation-config.MsSql.json";
23+
private const string DB_ENV = TestCategory.MSSQL;
24+
25+
/// <summary>
26+
/// Validates no duplicate tags and shared tag instances across various entity configurations.
27+
/// Exercises:
28+
/// - Multiple entities (one with description, one without)
29+
/// - Leading slash in REST path
30+
/// - Default REST path (entity name as path)
31+
/// - Stored procedure entity
32+
/// </summary>
33+
/// <param name="entityName">Entity name.</param>
34+
/// <param name="configuredRestPath">REST path override (null means use entity name).</param>
35+
/// <param name="description">Entity description (null means no description).</param>
36+
/// <param name="sourceType">Source type: Table or StoredProcedure.</param>
37+
/// <param name="sourceObject">Database source object name.</param>
38+
[DataRow("book", null, "A book entity", EntitySourceType.Table, "books",
39+
DisplayName = "Table entity with description and default REST path")]
40+
[DataRow("author", null, null, EntitySourceType.Table, "authors",
41+
DisplayName = "Table entity without description and default REST path")]
42+
[DataRow("genre", "/Genre", "Genre entity", EntitySourceType.Table, "brokers",
43+
DisplayName = "Table entity with leading slash REST path and description")]
44+
[DataRow("sp_entity", null, "SP description", EntitySourceType.StoredProcedure, "insert_and_display_all_books_for_given_publisher",
45+
DisplayName = "Stored procedure entity with description")]
46+
[DataTestMethod]
47+
public async Task NoDuplicateTags_AndSharedInstances(
48+
string entityName,
49+
string configuredRestPath,
50+
string description,
51+
EntitySourceType sourceType,
52+
string sourceObject)
53+
{
54+
// Arrange: Create a multi-entity configuration.
55+
// Always include a secondary entity so we exercise multi-entity deduplication.
56+
Entity primaryEntity = CreateEntity(sourceObject, sourceType, configuredRestPath, description);
57+
Entity secondaryEntity = CreateEntity("publishers", EntitySourceType.Table, null, "Secondary entity for dedup test");
58+
59+
Dictionary<string, Entity> entities = new()
60+
{
61+
{ entityName, primaryEntity },
62+
{ "publisher", secondaryEntity }
63+
};
64+
65+
RuntimeEntities runtimeEntities = new(entities);
66+
OpenApiDocument doc = await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(
67+
runtimeEntities: runtimeEntities,
68+
configFileName: CONFIG_FILE,
69+
databaseEnvironment: DB_ENV);
70+
71+
// Assert: No duplicate tag names in global tags
72+
List<string> tagNames = doc.Tags.Select(t => t.Name).ToList();
73+
List<string> distinctTagNames = tagNames.Distinct().ToList();
74+
Assert.AreEqual(distinctTagNames.Count, tagNames.Count,
75+
$"Duplicate tags found in OpenAPI document. Tags: {string.Join(", ", tagNames)}");
76+
77+
// Assert: The expected REST path (normalized, no leading slash) is present as a tag
78+
string expectedTagName = configuredRestPath?.TrimStart('/') ?? entityName;
79+
Assert.IsTrue(doc.Tags.Any(t => t.Name == expectedTagName),
80+
$"Expected tag '{expectedTagName}' not found. Actual tags: {string.Join(", ", tagNames)}");
81+
82+
// Assert: All operation tags reference the same instance as global tags
83+
AssertOperationTagsAreSharedInstances(doc);
84+
}
85+
86+
// Note: A test for duplicate REST paths (e.g., two entities both mapped to "/SharedPath") is intentionally
87+
// omitted because RuntimeConfigValidator rejects duplicate REST paths at startup (see RuntimeConfigValidator
88+
// line ~685). The TryAdd in BuildOpenApiDocument is defensive code for this edge case, but it cannot be
89+
// exercised through integration tests since the server won't start with an invalid configuration.
90+
91+
/// <summary>
92+
/// Validates REST-disabled entities produce no tags and no paths.
93+
/// </summary>
94+
[TestMethod]
95+
public async Task RestDisabledEntity_ProducesNoTagOrPath()
96+
{
97+
Entity disabledEntity = new(
98+
Source: new("books", EntitySourceType.Table, null, null),
99+
Fields: null,
100+
GraphQL: new(Singular: null, Plural: null, Enabled: false),
101+
Rest: new(Methods: EntityRestOptions.DEFAULT_SUPPORTED_VERBS, Path: null, Enabled: false),
102+
Permissions: OpenApiTestBootstrap.CreateBasicPermissions(),
103+
Mappings: null,
104+
Relationships: null,
105+
Description: "Should not appear");
106+
107+
Entity enabledEntity = CreateEntity("publishers", EntitySourceType.Table, null, "Enabled entity");
108+
109+
Dictionary<string, Entity> entities = new()
110+
{
111+
{ "disabled_book", disabledEntity },
112+
{ "publisher", enabledEntity }
113+
};
114+
115+
RuntimeEntities runtimeEntities = new(entities);
116+
OpenApiDocument doc = await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(
117+
runtimeEntities: runtimeEntities,
118+
configFileName: CONFIG_FILE,
119+
databaseEnvironment: DB_ENV);
120+
121+
Assert.IsFalse(doc.Tags.Any(t => t.Name == "disabled_book"),
122+
"REST-disabled entity should not have a tag in the OpenAPI document.");
123+
Assert.IsFalse(doc.Paths.Any(p => p.Key.Contains("disabled_book")),
124+
"REST-disabled entity should not have paths in the OpenAPI document.");
125+
Assert.IsTrue(doc.Tags.Any(t => t.Name == "publisher"),
126+
"Enabled entity should still have a tag.");
127+
128+
AssertOperationTagsAreSharedInstances(doc);
129+
}
130+
131+
/// <summary>
132+
/// Validates that entities with no permissions produce no tag when viewed
133+
/// for a specific role that has no access.
134+
/// </summary>
135+
[TestMethod]
136+
public async Task EntityWithNoPermissionsForRole_ProducesNoTag()
137+
{
138+
EntityPermission[] permissions = new[]
139+
{
140+
new EntityPermission(Role: "admin", Actions: new[]
141+
{
142+
new EntityAction(EntityActionOperation.All, null, new())
143+
})
144+
};
145+
146+
Entity entity = new(
147+
Source: new("books", EntitySourceType.Table, null, null),
148+
Fields: null,
149+
GraphQL: new(Singular: null, Plural: null, Enabled: false),
150+
Rest: new(Methods: EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
151+
Permissions: permissions,
152+
Mappings: null,
153+
Relationships: null,
154+
Description: "Admin-only entity");
155+
156+
Entity publicEntity = CreateEntity("publishers", EntitySourceType.Table, null, "Public entity");
157+
158+
Dictionary<string, Entity> entities = new()
159+
{
160+
{ "book", entity },
161+
{ "publisher", publicEntity }
162+
};
163+
164+
RuntimeEntities runtimeEntities = new(entities);
165+
166+
// Request OpenAPI doc for "anonymous" role - book should not appear
167+
OpenApiDocument doc = await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(
168+
runtimeEntities: runtimeEntities,
169+
configFileName: CONFIG_FILE,
170+
databaseEnvironment: DB_ENV,
171+
role: "anonymous");
172+
173+
Assert.IsFalse(doc.Tags.Any(t => t.Name == "book"),
174+
"Entity with no permissions for 'anonymous' role should not have a tag.");
175+
Assert.IsFalse(doc.Paths.Any(p => p.Key.Contains("book")),
176+
"Entity with no permissions for 'anonymous' role should not have paths.");
177+
178+
AssertOperationTagsAreSharedInstances(doc);
179+
}
180+
181+
/// <summary>
182+
/// Validates that entity descriptions are correctly reflected in OpenAPI tags.
183+
/// </summary>
184+
/// <param name="description">Entity description to test.</param>
185+
/// <param name="shouldHaveDescription">Whether the tag should have a description.</param>
186+
[DataRow("A meaningful description", true, DisplayName = "Entity with description")]
187+
[DataRow(null, false, DisplayName = "Entity without description")]
188+
[DataRow("", false, DisplayName = "Entity with empty description")]
189+
[DataRow(" ", false, DisplayName = "Entity with whitespace description")]
190+
[DataTestMethod]
191+
public async Task TagDescription_MatchesEntityDescription(string description, bool shouldHaveDescription)
192+
{
193+
Entity entity = CreateEntity("books", EntitySourceType.Table, null, description);
194+
195+
Dictionary<string, Entity> entities = new()
196+
{
197+
{ "book", entity }
198+
};
199+
200+
RuntimeEntities runtimeEntities = new(entities);
201+
OpenApiDocument doc = await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(
202+
runtimeEntities: runtimeEntities,
203+
configFileName: CONFIG_FILE,
204+
databaseEnvironment: DB_ENV);
205+
206+
OpenApiTag tag = doc.Tags.FirstOrDefault(t => t.Name == "book");
207+
Assert.IsNotNull(tag, "Expected tag 'book' to exist.");
208+
209+
if (shouldHaveDescription)
210+
{
211+
Assert.AreEqual(description, tag.Description,
212+
$"Tag description should match entity description.");
213+
}
214+
else
215+
{
216+
Assert.IsNull(tag.Description,
217+
"Tag description should be null for empty/whitespace/null entity descriptions.");
218+
}
219+
}
220+
221+
/// <summary>
222+
/// Asserts that every operation tag in the document is the exact same object instance
223+
/// as the corresponding tag in the global Tags list. This prevents Swagger UI from
224+
/// treating them as separate groups.
225+
/// </summary>
226+
/// <param name="doc">OpenAPI document to validate.</param>
227+
private static void AssertOperationTagsAreSharedInstances(OpenApiDocument doc)
228+
{
229+
foreach (KeyValuePair<string, OpenApiPathItem> path in doc.Paths)
230+
{
231+
foreach (KeyValuePair<OperationType, OpenApiOperation> operation in path.Value.Operations)
232+
{
233+
foreach (OpenApiTag operationTag in operation.Value.Tags)
234+
{
235+
bool isSharedInstance = doc.Tags.Any(globalTag => ReferenceEquals(globalTag, operationTag));
236+
Assert.IsTrue(isSharedInstance,
237+
$"Operation tag '{operationTag.Name}' at path '{path.Key}' ({operation.Key}) " +
238+
$"is not the same instance as the global tag. This will cause duplicate groups in Swagger UI.");
239+
}
240+
}
241+
}
242+
}
243+
244+
/// <summary>
245+
/// Helper to create an Entity with common defaults for tag validation tests.
246+
/// </summary>
247+
private static Entity CreateEntity(
248+
string sourceObject,
249+
EntitySourceType sourceType,
250+
string configuredRestPath,
251+
string description)
252+
{
253+
return new Entity(
254+
Source: new(sourceObject, sourceType, null, null),
255+
Fields: null,
256+
GraphQL: new(Singular: null, Plural: null, Enabled: false),
257+
Rest: sourceType == EntitySourceType.StoredProcedure
258+
? new(Methods: EntityRestOptions.DEFAULT_SUPPORTED_VERBS, Path: configuredRestPath)
259+
: new(Methods: EntityRestOptions.DEFAULT_SUPPORTED_VERBS, Path: configuredRestPath),
260+
Permissions: OpenApiTestBootstrap.CreateBasicPermissions(),
261+
Mappings: null,
262+
Relationships: null,
263+
Description: description);
264+
}
265+
}
266+
}

0 commit comments

Comments
 (0)