Skip to content

Cannot use a custom sort for OpenApiDocument.Tags in v2 #2678

@spanglerco

Description

@spanglerco

Describe the bug
In the v1 API, OpenApiDocument.Tags was an IList<OpenApiTag>, enabling us to order the tags however we want (e.g., in a Swashbuckle document filter). In the v2 API, the property is an ISet<OpenApiTag>, which, while more difficult, would let us use a custom ordering via SortedSet<OpenApiTag> with a custom comparer. However, the OpenApiDocument.Tags setter doesn't actually preserve the collection you give it, unless it's specifically a HashSet<OpenApiTag> that uses OpenApiTagComparer.

Technically, fully populating a SortedSet<OpenApiTag> before assigning it to OpenApiDocument.Tags often "works", because the current HashSet implementation will enumerate in the same order as insertion in many cases (I think it's if there aren't any hash code collisions and you don't remove any elements and add more). But initializing OpenApiDocument.Tags to a SortedSet<OpenApiTag> and then populating it doesn't produce the desired result.

OpenApi File To Reproduce
Any document with multiple tags. For example:

{
  "openapi": "3.0.4",
  "tags": [
    {
      "name": "Data",
      "description": "..."
    },
    {
      "name": "Export",
      "description": "..."
    },
    {
      "name": "Tables",
      "description": "..."
    },
    {
      "name": "Versioning",
      "description": "..."
    }
  ]
}

Expected behavior
I can order the document's tags such that the serialized document maintains the order I want instead of alphabetic:

{
  "openapi": "3.0.4",
  "tags": [
    {
      "name": "Versioning",
      "description": "..."
    },
    {
      "name": "Tables",
      "description": "..."
    },
    {
      "name": "Data",
      "description": "..."
    },
    {
      "name": "Export",
      "description": "..."
    }
  ]
}

Screenshots/Code Snippets
Swashbuckle document filter that works with the v1 API:

public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
    var tagList = new SortedList<int, OpenApiTag>();
    // The actual code discovers the tags using the ApiExplorer
    tagList.Add(2, new OpenApiTag { Name = "Data" });
    tagList.Add(3, new OpenApiTag { Name = "Export" });
    tagList.Add(1, new OpenApiTag { Name = "Tables" });
    tagList.Add(0, new OpenApiTag { Name = "Versioning" });
    swaggerDoc.Tags = tagList.Values;
}

Code that "works" in v2, assuming the HashSet cooperates:

public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
    var tagSet = new SortedSet<OpenApiTag>(new OrderedOpenApiTagComparer());
    tagSet.Add(new OrderedOpenApiTag(2) { Name = "Data" });
    tagSet.Add(new OrderedOpenApiTag(3) { Name = "Export" });
    tagSet.Add(new OrderedOpenApiTag(1) { Name = "Tables" });
    tagSet.Add(new OrderedOpenApiTag(0) { Name = "Versioning" });
    swaggerDoc.Tags = tagSet; // The setter copies the tags into a new HashSet
}

private sealed class OrderedOpenApiTag(int order) : OpenApiTag
{
    public int Order { get; } = order;
}

private sealed class OrderedOpenApiTagComparer : IComparer<OpenApiTag>
{
    public int Compare(OpenApiTag? x, OpenApiTag? y)
    {
        var xOrder = (x as OrderedOpenApiTag)?.Order ?? -1;
        var yOrder = (y as OrderedOpenApiTag)?.Order ?? -1;
        return xOrder.CompareTo(yOrder);
    }
}

Additional context
Perhaps the property setter should only override the ISet implementation if given a HashSet that uses the default comparer and/or an empty set?

Metadata

Metadata

Assignees

No one assigned

    Labels

    help wantedpriority:p2Medium. Generally has a work-around and a smaller sub-set of customers is affected. SLA <=30 daystype:bugA broken experiencetype:regressionA bug from previous release

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions