Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Feb 2, 2026

Why make this change?

Swagger UI displays duplicate groups for entities with descriptions (e.g., "Actor" appears twice). OpenAPI tags were instantiated separately in CreateDocument (global tags) and BuildPaths (operation tags), causing Swagger UI to treat identical tag names as distinct groups.

What is this change?

Share tag instances between global and operation-level tags:

Before:

// CreateDocument creates tag instance
List<OpenApiTag> globalTags = new();
globalTags.Add(new OpenApiTag { Name = "Actor", Description = "..." });

// BuildPaths creates ANOTHER tag instance  
OpenApiTag tag = new() { Name = "Actor", Description = "..." };

After:

// CreateDocument stores tags in dictionary
Dictionary<string, OpenApiTag> globalTagsDict = new();
globalTagsDict["Actor"] = new OpenApiTag { Name = "Actor", Description = "..." };

// BuildPaths reuses the same instance
if (globalTagsDict.TryGetValue("Actor", out OpenApiTag? existingTag))
{
    tags.Add(existingTag);
}

Changes:

  • OpenApiDocumentor.CreateDocument: Store tags in Dictionary<string, OpenApiTag> keyed by REST path
  • OpenApiDocumentor.BuildPaths: Accept dictionary parameter and reuse existing tag instances
  • Handle deduplication when multiple entities share the same REST path

How was this tested?

  • Unit Tests
    • Added OpenApiDocumentor_NoDuplicateTags test verifying:
      • No duplicate tag names in global tags
      • Operation tags reference same instances as global tags (via ReferenceEquals)

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.

Original prompt

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

Image

Steps to Reproduce:

  1. Use this configuration (trimmed for brevity):

    {
      "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)

@JerryNixon ### 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):

// 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):

// 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...

💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@JerryNixon
Copy link
Contributor

@copilot There is a related problem in #2946. Please include in this PR.

Current behavior: The OpenAPI document shows all HTTP methods (GET, POST, PUT, PATCH, DELETE) for every entity, regardless of:

  • Which methods are actually permitted for the current user's role
  • Entity-level permission restrictions

Example scenario:

{
  "entities": {
    "Book": {
      "source": { "object": "books", "type": "table" },
      "permissions": [
        {
          "role": "anonymous",
          "actions": [
            { "action": "read" }  // ← Only READ allowed
          ]
        }
      ]
    }
  }
}

Current OpenAPI output (WRONG):

paths:
  /api/Book:
    get:      # ✅ Should show (read allowed)
    post:     # ❌ Shouldn't show (create not allowed)
    
  /api/Book/{id}:
    get:      # ✅ Should show (read allowed)
    put:      # ❌ Shouldn't show (update not allowed)
    patch:    # ❌ Shouldn't show (update not allowed)
    delete:   # ❌ Shouldn't show (delete not allowed)

Expected OpenAPI output (for anonymous role):

paths:
  /api/Book:
    get:      # ✅ Only show allowed methods
    
  /api/Book/{id}:
    get:      # ✅ Only show allowed methods

Why This Matters

1. API Documentation Accuracy

Current OpenAPI docs mislead developers by showing methods they can't use:

  • Developer sees POST /api/Book in Swagger UI
  • Tries to use it
  • Gets 403 Forbidden error
  • Confusion and wasted time

2. Security Through Obscurity

While not a primary security measure, hiding unavailable methods:

  • Reduces attack surface visibility
  • Discourages unauthorized access attempts
  • Follows principle of least privilege in documentation

3. Better Developer Experience

  • Code generators only create client code for allowed operations
  • IDE autocomplete shows only valid operations
  • Clearer understanding of API capabilities per role

4. Role-Specific Documentation

Different users see different API capabilities:

  • anonymous sees only read operations
  • authenticated sees read/create operations
  • admin sees all operations

Current Implementation

The OpenAPI generator currently creates all methods regardless of permissions:

private static Dictionary<OperationType, bool> GetConfiguredRestOperations(Entity entity, DatabaseObject dbObject)
{
    Dictionary<OperationType, bool> configuredOperations = new()
    {
        [OperationType.Get] = false,
        [OperationType.Post] = false,
        [OperationType.Put] = false,
        [OperationType.Patch] = false,
        [OperationType.Delete] = false
    };

    if (dbObject.SourceType == EntitySourceType.StoredProcedure && entity is not null)
    {
        // For stored procedures, check configured HTTP methods
        // ...
    }
    else
    {
        // For tables/views, ALL operations are enabled by default
        configuredOperations[OperationType.Get] = true;
        configuredOperations[OperationType.Post] = true;
        configuredOperations[OperationType.Put] = true;
        configuredOperations[OperationType.Patch] = true;
        configuredOperations[OperationType.Delete] = true;  // ← No permission check!
    }

    return configuredOperations;
}

The problem: This method doesn't check entity permissions—it assumes all CRUD operations are available.


Proposed Solution

The enhancement suggests:

1. Detect Current User's Role

When generating OpenAPI, determine the requesting user's role from:

  • X-MS-API-ROLE header (custom role header)
  • JWT claims
  • EasyAuth headers
  • Default to anonymous if not authenticated

2. Filter Operations Based on Permissions

For each entity, check the role's permissions and only include allowed operations:

// Pseudo-code for proposed solution
private Dictionary<OperationType, bool> GetConfiguredRestOperations(
    Entity entity, 
    DatabaseObject dbObject,
    string currentRole)  // ← NEW: role parameter
{
    var configuredOperations = new Dictionary<OperationType, bool>();
    
    // Get permissions for the current role
    var rolePermissions = entity.Permissions
        .FirstOrDefault(p => p.Role == currentRole);
    
    if (rolePermissions == null)
    {
        // Role has no permissions, show nothing
        return configuredOperations;
    }
    
    // Map actions to HTTP methods
    foreach (var action in rolePermissions.Actions)
    {
        switch (action.Action)
        {
            case "read":
                configuredOperations[OperationType.Get] = true;
                break;
            case "create":
                configuredOperations[OperationType.Post] = true;
                break;
            case "update":
                configuredOperations[OperationType.Put] = true;
                configuredOperations[OperationType.Patch] = true;
                break;
            case "delete":
                configuredOperations[OperationType.Delete] = true;
                break;
            case "*":
                // All operations
                configuredOperations[OperationType.Get] = true;
                configuredOperations[OperationType.Post] = true;
                configuredOperations[OperationType.Put] = true;
                configuredOperations[OperationType.Patch] = true;
                configuredOperations[OperationType.Delete] = true;
                break;
        }
    }
    
    return configuredOperations;
}

3. Generate Role-Specific OpenAPI Documents

Option A: Dynamic per-request

  • /api/openapi returns different document based on X-MS-API-ROLE header
  • Each role gets their own view

Option B: Multiple static documents

  • /api/openapi?role=anonymous
  • /api/openapi?role=authenticated
  • /api/openapi?role=admin

Option C: Security schemes with role filter

  • Use OpenAPI security schemes to document role requirements
  • Include all operations but mark which roles can access them

Example Implementation

Configuration:

{
  "entities": {
    "Book": {
      "source": { "object": "books", "type": "table" },
      "permissions": [
        {
          "role": "anonymous",
          "actions": ["read"]
        },
        {
          "role": "authenticated",
          "actions": ["read", "create"]
        },
        {
          "role": "admin",
          "actions": ["*"]
        }
      ]
    }
  }
}

OpenAPI for anonymous role:

paths:
  /api/Book:
    get:
      summary: "Get all books"
      tags: ["Book"]
      
  /api/Book/{id}:
    get:
      summary: "Get book by ID"
      tags: ["Book"]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer

OpenAPI for authenticated role:

paths:
  /api/Book:
    get:
      summary: "Get all books"
      tags: ["Book"]
    post:  # ← Now included
      summary: "Create book"
      tags: ["Book"]
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Book'
      
  /api/Book/{id}:
    get:
      summary: "Get book by ID"
      tags: ["Book"]

OpenAPI for admin role:

paths:
  /api/Book:
    get: { ... }
    post: { ... }
    
  /api/Book/{id}:
    get: { ... }
    put: { ... }     # ← Now included
    patch: { ... }   # ← Now included
    delete: { ... }  # ← Now included

Challenges & Considerations

1. Caching

  • Role-specific documents need different cache keys
  • Can't cache a single document for all users

2. Swagger UI Integration

  • How to switch between role views in Swagger UI?
  • Need UI controls to select/change role

3. Backwards Compatibility

  • Default behavior should remain unchanged for existing users
  • Make role-aware generation opt-in via config flag?

4. Multiple Roles

  • Some users might have multiple roles
  • Should show union of all permissions?

5. Database Policies

  • Some restrictions come from database policies, not just DAB config
  • Those can't be reflected in OpenAPI

Workarounds

Since this enhancement isn't implemented yet:

Workaround 1: Manual OpenAPI Editing

  1. Generate the full OpenAPI document
  2. Manually remove unauthorized operations
  3. Serve the edited version
// Remove operations based on role
function filterOpenApiByRole(openApiDoc, role) {
  const rolePermissions = getRolePermissions(role);
  
  for (const path in openApiDoc.paths) {
    for (const method in openApiDoc.paths[path]) {
      if (!isMethodAllowed(method, rolePermissions)) {
        delete openApiDoc.paths[path][method];
      }
    }
  }
  
  return openApiDoc;
}

Workaround 2: Multiple Entity Configurations

Create separate entities with different REST paths for different roles:

{
  "entities": {
    "BookReadOnly": {
      "source": { "object": "books", "type": "table" },
      "rest": { "path": "/Book" },
      "permissions": [
        { "role": "anonymous", "actions": ["read"] }
      ]
    },
    "BookFull": {
      "source": { "object": "books", "type": "table" },
      "rest": { "path": "/BookAdmin" },
      "permissions": [
        { "role": "admin", "actions": ["*"] }
      ]
    }
  }
}

Then filter OpenAPI paths based on user role.

Workaround 3: Use OpenAPI Extensions

Add custom extensions to document permission requirements:

paths:
  /api/Book:
    post:
      x-dab-required-roles: ["authenticated", "admin"]
      x-dab-required-permissions: ["create"]

Then build tooling to filter based on these extensions.

Workaround 4: Proxy Layer

Create a proxy that:

  1. Intercepts /api/openapi requests
  2. Reads the user's role from auth headers
  3. Dynamically filters the OpenAPI document
  4. Returns role-specific version
app.Use(async (context, next) =>
{
    if (context.Request.Path == "/api/openapi")
    {
        var role = context.Request.Headers["X-MS-API-ROLE"].ToString() ?? "anonymous";
        var fullDoc = await GetFullOpenApiDoc();
        var filteredDoc = FilterByRole(fullDoc, role);
        
        context.Response.ContentType = "application/json";
        await context.Response.WriteAsync(JsonSerializer.Serialize(filteredDoc));
        return;
    }
    
    await next();
});

Summary

Issue #2946 requests role-aware OpenAPI generation that only shows HTTP methods the current user is authorized to use.

Current behavior:

  • ❌ Shows all HTTP methods for all entities
  • ❌ Misleading documentation
  • ❌ Users see operations they can't access

Desired behavior:

  • ✅ OpenAPI document respects role permissions
  • ✅ Only shows allowed HTTP methods
  • ✅ Accurate, role-specific API documentation

Implementation approach:

  1. Detect current user's role from request headers/auth
  2. Filter operations based on entity permissions for that role
  3. Generate role-specific OpenAPI document

Best current workaround: Create a middleware/proxy that filters the OpenAPI document based on the requesting user's role before returning it to clients.

Note: This enhancement would significantly improve developer experience and documentation accuracy, especially in multi-tenant or role-based systems.

For code references: GitHub search for "GetConfiguredRestOperations"

Copilot AI changed the title [WIP] Fix duplicate entity groups in Swagger UI for Actor entity Fix duplicate entity groups in Swagger UI when entities have descriptions Feb 2, 2026
Copilot AI requested a review from JerryNixon February 2, 2026 23:24
Copy link
Contributor Author

Copilot AI commented Feb 2, 2026

@copilot There is a related problem in #2946. Please include in this PR.

Current behavior: The OpenAPI document shows all HTTP methods (GET, POST, PUT, PATCH, DELETE) for every entity, reg...

I need clarification on the scope for issue #2946:

Question 1: Role-specific vs. All-roles documentation?
Should the OpenAPI document show:

  • Option A: Operations available to ANY role (union of all permissions)?
  • Option B: Operations for a SPECIFIC role (requires role parameter/header)?
  • Option C: Separate OpenAPI docs per role?

Question 2: Anonymous role handling?
If "anonymous" role has only read permission, should the OpenAPI doc:

  • Show all operations (since authenticated users might have more permissions)?
  • Show only GET operations (matching anonymous permissions)?

Current behavior: OpenAPI shows all CRUD operations regardless of ANY role's permissions, which is misleading.

Suggested approach (Option A): Filter to show only operations that at least one role can perform. This is the minimal change that fixes the core issue without requiring role-specific documentation infrastructure.

Please confirm which approach you prefer so I can implement the right solution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working cri Customer Reported issue open-api rest

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

[Bug]: Swagger UI shows duplicate entity groups for single entity definition

2 participants