Skip to content

Commit 11db6ef

Browse files
Fix exception sorting operation tags (#3652)
Fix `SwaggerGeneratorException` caused by an `InvalidOperationException` when attempting to compare `OpenApiTagReference` instances in a `SortedSet<T>`. Resolves #3650.
1 parent 5bb0e76 commit 11db6ef

File tree

6 files changed

+132
-4
lines changed

6 files changed

+132
-4
lines changed

src/Shared/OpenApiTagComparer.cs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
4+
* MIT License
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*
24+
*/
25+
26+
// Adapted from https://github.com/microsoft/OpenAPI.NET/blob/3b61b45991dded1aaecb16330430628d26a406de/src/Microsoft.OpenApi/OpenApiTagComparer.cs#L1
27+
28+
namespace Microsoft.OpenApi;
29+
30+
/// <summary>
31+
/// This comparer is used to maintain a globally unique list of tags encountered
32+
/// in a particular OpenAPI document.
33+
/// </summary>
34+
internal sealed class OpenApiTagComparer :
35+
IComparer<IOpenApiTag>,
36+
IComparer<OpenApiTagReference>,
37+
IEqualityComparer<IOpenApiTag>,
38+
IEqualityComparer<OpenApiTagReference>
39+
{
40+
private static readonly Lazy<OpenApiTagComparer> _lazyInstance = new(() => new OpenApiTagComparer());
41+
42+
// Tag comparisons are case-sensitive by default. Although the OpenAPI specification
43+
// only outlines case sensitivity for property names, we extend this principle to
44+
// property values for tag names as well.
45+
// See https://spec.openapis.org/oas/v3.1.0#format.
46+
private static readonly StringComparer StringComparer = StringComparer.Ordinal;
47+
48+
/// <summary>
49+
/// Default instance for the comparer.
50+
/// </summary>
51+
internal static OpenApiTagComparer Instance => _lazyInstance.Value;
52+
53+
public int Compare(IOpenApiTag x, IOpenApiTag y)
54+
{
55+
if (x is null)
56+
{
57+
return -1;
58+
}
59+
60+
if (y is null)
61+
{
62+
return 1;
63+
}
64+
65+
if (ReferenceEquals(x, y))
66+
{
67+
return 0;
68+
}
69+
70+
if (x is OpenApiTagReference referenceX && y is OpenApiTagReference referenceY)
71+
{
72+
return StringComparer.Compare(referenceX.Name ?? referenceX.Reference.Id, referenceY.Name ?? referenceY.Reference.Id);
73+
}
74+
75+
return StringComparer.Compare(x.Name, y.Name);
76+
}
77+
78+
public int Compare(OpenApiTagReference x, OpenApiTagReference y)
79+
{
80+
if (x is null)
81+
{
82+
return -1;
83+
}
84+
85+
if (y is null)
86+
{
87+
return 1;
88+
}
89+
90+
if (ReferenceEquals(x, y))
91+
{
92+
return 0;
93+
}
94+
95+
return StringComparer.Compare(x.Name ?? x.Reference.Id, y.Name ?? y.Reference.Id);
96+
}
97+
98+
/// <inheritdoc/>
99+
public bool Equals(IOpenApiTag x, IOpenApiTag y) => Compare(x, y) == 0;
100+
101+
/// <inheritdoc/>
102+
public bool Equals(OpenApiTagReference x, OpenApiTagReference y) => Compare(x, y) == 0;
103+
104+
/// <inheritdoc/>
105+
public int GetHashCode(IOpenApiTag obj)
106+
{
107+
string value = obj?.Name;
108+
109+
if (value is null && obj is OpenApiTagReference reference)
110+
{
111+
value = reference.Reference.Id;
112+
}
113+
114+
return string.IsNullOrEmpty(value) ? 0 : StringComparer.GetHashCode(value);
115+
}
116+
117+
/// <inheritdoc/>
118+
public int GetHashCode(OpenApiTagReference obj)
119+
{
120+
string value = obj?.Name ?? obj?.Reference?.Id;
121+
122+
return string.IsNullOrEmpty(value) ? 0 : StringComparer.GetHashCode(value);
123+
}
124+
}

src/Swashbuckle.AspNetCore.Annotations/AnnotationsDocumentFilter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ public class AnnotationsDocumentFilter : IDocumentFilter
88
{
99
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
1010
{
11-
swaggerDoc.Tags ??= new SortedSet<OpenApiTag>();
11+
swaggerDoc.Tags ??= new SortedSet<OpenApiTag>(OpenApiTagComparer.Instance);
1212

1313
// Collect (unique) controller names and custom attributes in a dictionary
1414
var controllerNamesAndAttributes = context.ApiDescriptions

src/Swashbuckle.AspNetCore.Annotations/AnnotationsOperationFilter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ private static void ApplySwaggerOperationAttribute(
6969

7070
if (swaggerOperationAttribute.Tags is { } tags)
7171
{
72-
operation.Tags = new SortedSet<OpenApiTagReference>(tags.Select(tagName => new OpenApiTagReference(tagName)));
72+
operation.Tags = new SortedSet<OpenApiTagReference>(tags.Select(tagName => new OpenApiTagReference(tagName)), OpenApiTagComparer.Instance);
7373
}
7474
}
7575

src/Swashbuckle.AspNetCore.Annotations/Swashbuckle.AspNetCore.Annotations.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,9 @@
2424
<AdditionalFiles Include="PublicAPI\$(TargetFramework)\PublicAPI.Shipped.txt" />
2525
<AdditionalFiles Include="PublicAPI\$(TargetFramework)\PublicAPI.Unshipped.txt" />
2626
</ItemGroup>
27+
28+
<ItemGroup>
29+
<Compile Include="..\Shared\OpenApiTagComparer.cs" Link="OpenApiTagComparer.cs" />
30+
</ItemGroup>
2731

2832
</Project>

test/Swashbuckle.AspNetCore.Annotations.Test/AnnotationsOperationFilterTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public void Apply_EnrichesOperationMetadata_IfActionDecoratedWithSwaggerOperatio
2828
Assert.Equal("Summary for ActionWithSwaggerOperationAttribute", operation.Summary);
2929
Assert.Equal("Description for ActionWithSwaggerOperationAttribute", operation.Description);
3030
Assert.Equal("actionWithSwaggerOperationAttribute", operation.OperationId);
31-
Assert.Equal(["foobar"], [.. operation.Tags.Select(t => t.Reference.Id)]);
31+
Assert.Equal(["bar", "foo"], [.. operation.Tags.Select(t => t.Reference.Id)]);
3232
}
3333

3434
[Fact]

test/Swashbuckle.AspNetCore.Annotations.Test/Fixtures/FakeControllerWithSwaggerAnnotations.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ internal class FakeControllerWithSwaggerAnnotations
1010
[SwaggerOperation("Summary for ActionWithSwaggerOperationAttribute",
1111
Description = "Description for ActionWithSwaggerOperationAttribute",
1212
OperationId = "actionWithSwaggerOperationAttribute",
13-
Tags = new[] { "foobar" }
13+
Tags = ["foo", "bar"]
1414
)]
1515
public void ActionWithSwaggerOperationAttribute()
1616
{ }

0 commit comments

Comments
 (0)