Skip to content

Commit 93c468e

Browse files
committed
feat: deduplicates tags at the document level
Signed-off-by: Vincent Biret <[email protected]>
1 parent 49014cf commit 93c468e

16 files changed

+171
-21
lines changed

src/Microsoft.OpenApi/Models/OpenApiDocument.cs

+24-2
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,32 @@ public void RegisterComponents()
7676
public IList<OpenApiSecurityRequirement>? SecurityRequirements { get; set; } =
7777
new List<OpenApiSecurityRequirement>();
7878

79+
private HashSet<OpenApiTag>? _tags;
7980
/// <summary>
8081
/// A list of tags used by the specification with additional metadata.
8182
/// </summary>
82-
public IList<OpenApiTag>? Tags { get; set; } = new List<OpenApiTag>();
83+
public ISet<OpenApiTag>? Tags
84+
{
85+
get
86+
{
87+
return _tags;
88+
}
89+
set
90+
{
91+
if (value is null)
92+
{
93+
return;
94+
}
95+
if (value is HashSet<OpenApiTag> tags && tags.Comparer is OpenApiTagComparer)
96+
{
97+
_tags = tags;
98+
}
99+
else
100+
{
101+
_tags = new HashSet<OpenApiTag>(value, OpenApiTagComparer.Instance);
102+
}
103+
}
104+
}
83105

84106
/// <summary>
85107
/// Additional external documentation.
@@ -123,7 +145,7 @@ public OpenApiDocument(OpenApiDocument? document)
123145
Webhooks = document?.Webhooks != null ? new Dictionary<string, IOpenApiPathItem>(document.Webhooks) : null;
124146
Components = document?.Components != null ? new(document?.Components) : null;
125147
SecurityRequirements = document?.SecurityRequirements != null ? new List<OpenApiSecurityRequirement>(document.SecurityRequirements) : null;
126-
Tags = document?.Tags != null ? new List<OpenApiTag>(document.Tags) : null;
148+
Tags = document?.Tags != null ? new HashSet<OpenApiTag>(document.Tags, OpenApiTagComparer.Instance) : null;
127149
ExternalDocs = document?.ExternalDocs != null ? new(document?.ExternalDocs) : null;
128150
Extensions = document?.Extensions != null ? new Dictionary<string, IOpenApiExtension>(document.Extensions) : null;
129151
Annotations = document?.Annotations != null ? new Dictionary<string, object>(document.Annotations) : null;

src/Microsoft.OpenApi/Models/References/OpenApiTagReference.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public override OpenApiTag Target
2121
{
2222
get
2323
{
24-
return Reference.HostDocument?.Tags.FirstOrDefault(t => StringComparer.Ordinal.Equals(t.Name, Reference.Id));
24+
return Reference.HostDocument?.Tags.FirstOrDefault(t => OpenApiTagComparer.StringComparer.Equals(t.Name, Reference.Id));
2525
}
2626
}
2727

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Microsoft.OpenApi.Models;
4+
5+
namespace Microsoft.OpenApi;
6+
7+
#nullable enable
8+
/// <summary>
9+
/// This comparer is used to maintain a globally unique list of tags encountered
10+
/// in a particular OpenAPI document.
11+
/// </summary>
12+
internal sealed class OpenApiTagComparer : IEqualityComparer<OpenApiTag>
13+
{
14+
private static readonly Lazy<OpenApiTagComparer> _lazyInstance = new(() => new OpenApiTagComparer());
15+
/// <summary>
16+
/// Default instance for the comparer.
17+
/// </summary>
18+
internal static OpenApiTagComparer Instance { get => _lazyInstance.Value; }
19+
20+
/// <inheritdoc/>
21+
public bool Equals(OpenApiTag? x, OpenApiTag? y)
22+
{
23+
if (x is null && y is null)
24+
{
25+
return true;
26+
}
27+
if (x is null || y is null)
28+
{
29+
return false;
30+
}
31+
if (ReferenceEquals(x, y))
32+
{
33+
return true;
34+
}
35+
return StringComparer.Equals(x.Name, y.Name);
36+
}
37+
38+
// Tag comparisons are case-sensitive by default. Although the OpenAPI specification
39+
// only outlines case sensitivity for property names, we extend this principle to
40+
// property values for tag names as well.
41+
// See https://spec.openapis.org/oas/v3.1.0#format.
42+
internal static readonly StringComparer StringComparer = StringComparer.Ordinal;
43+
44+
/// <inheritdoc/>
45+
public int GetHashCode(OpenApiTag obj) => obj?.Name is null ? 0 : StringComparer.GetHashCode(obj.Name);
46+
}
47+
#nullable restore

src/Microsoft.OpenApi/Reader/V2/OpenApiDocumentDeserializer.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ internal static partial class OpenApiV2Deserializer
104104
}
105105
},
106106
{"security", (o, n, _) => o.SecurityRequirements = n.CreateList(LoadSecurityRequirement, o)},
107-
{"tags", (o, n, _) => o.Tags = n.CreateList(LoadTag, o)},
107+
{"tags", (o, n, _) => o.Tags = new HashSet<OpenApiTag>(n.CreateList(LoadTag, o), OpenApiTagComparer.Instance)},
108108
{"externalDocs", (o, n, _) => o.ExternalDocs = LoadExternalDocs(n, o)}
109109
};
110110

src/Microsoft.OpenApi/Reader/V3/OpenApiDocumentDeserializer.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT license.
33

44
using System;
5+
using System.Collections.Generic;
56
using Microsoft.OpenApi.Extensions;
67
using Microsoft.OpenApi.Models;
78
using Microsoft.OpenApi.Reader.ParseNodes;
@@ -26,7 +27,7 @@ internal static partial class OpenApiV3Deserializer
2627
{"servers", (o, n, _) => o.Servers = n.CreateList(LoadServer, o)},
2728
{"paths", (o, n, _) => o.Paths = LoadPaths(n, o)},
2829
{"components", (o, n, _) => o.Components = LoadComponents(n, o)},
29-
{"tags", (o, n, _) => o.Tags = n.CreateList(LoadTag, o) },
30+
{"tags", (o, n, _) => o.Tags = new HashSet<OpenApiTag>(n.CreateList(LoadTag, o), OpenApiTagComparer.Instance) },
3031
{"externalDocs", (o, n, _) => o.ExternalDocs = LoadExternalDocs(n, o)},
3132
{"security", (o, n, _) => o.SecurityRequirements = n.CreateList(LoadSecurityRequirement, o)}
3233
};

src/Microsoft.OpenApi/Reader/V31/OpenApiDocumentDeserializer.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using Microsoft.OpenApi.Extensions;
34
using Microsoft.OpenApi.Models;
45
using Microsoft.OpenApi.Reader.ParseNodes;
@@ -24,7 +25,7 @@ internal static partial class OpenApiV31Deserializer
2425
{"paths", (o, n, _) => o.Paths = LoadPaths(n, o)},
2526
{"webhooks", (o, n, _) => o.Webhooks = n.CreateMap(LoadPathItem, o)},
2627
{"components", (o, n, _) => o.Components = LoadComponents(n, o)},
27-
{"tags", (o, n, _) => o.Tags = n.CreateList(LoadTag, o) },
28+
{"tags", (o, n, _) => o.Tags = new HashSet<OpenApiTag>(n.CreateList(LoadTag, o), OpenApiTagComparer.Instance) },
2829
{"externalDocs", (o, n, _) => o.ExternalDocs = LoadExternalDocs(n, o)},
2930
{"security", (o, n, _) => o.SecurityRequirements = n.CreateList(LoadSecurityRequirement, o)}
3031
};

src/Microsoft.OpenApi/Services/OpenApiVisitorBase.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ public virtual void Visit(IOpenApiExample example)
309309
/// <summary>
310310
/// Visits list of <see cref="OpenApiTag"/>
311311
/// </summary>
312-
public virtual void Visit(IList<OpenApiTag> openApiTags)
312+
public virtual void Visit(ISet<OpenApiTag> openApiTags)
313313
{
314314
}
315315

src/Microsoft.OpenApi/Services/OpenApiWalker.cs

+6-4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Linq;
67
using System.Text.Json.Nodes;
78
using Microsoft.OpenApi.Any;
89
using Microsoft.OpenApi.Extensions;
@@ -60,7 +61,7 @@ public void Walk(OpenApiDocument doc)
6061
/// <summary>
6162
/// Visits list of <see cref="OpenApiTag"/> and child objects
6263
/// </summary>
63-
internal void Walk(IList<OpenApiTag> tags)
64+
internal void Walk(ISet<OpenApiTag> tags)
6465
{
6566
if (tags == null)
6667
{
@@ -72,9 +73,10 @@ internal void Walk(IList<OpenApiTag> tags)
7273
// Visit tags
7374
if (tags != null)
7475
{
75-
for (var i = 0; i < tags.Count; i++)
76+
var tagsAsArray = tags.ToArray();
77+
for (var i = 0; i < tagsAsArray.Length; i++)
7678
{
77-
Walk(i.ToString(), () => Walk(tags[i]));
79+
Walk(i.ToString(), () => Walk(tagsAsArray[i]));
7880
}
7981
}
8082
}
@@ -1213,7 +1215,7 @@ internal void Walk(IOpenApiElement element)
12131215
case OpenApiServer e: Walk(e); break;
12141216
case OpenApiServerVariable e: Walk(e); break;
12151217
case OpenApiTag e: Walk(e); break;
1216-
case IList<OpenApiTag> e: Walk(e); break;
1218+
case ISet<OpenApiTag> e: Walk(e); break;
12171219
case IOpenApiExtensible e: Walk(e); break;
12181220
case IOpenApiExtension e: Walk(e); break;
12191221
}

test/Microsoft.OpenApi.Hidi.Tests/UtilityFiles/OpenApiDocumentMock.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,7 @@ public static OpenApiDocument CreateOpenApiDocument()
629629
}
630630
}
631631
},
632-
Tags = new List<OpenApiTag>
632+
Tags = new HashSet<OpenApiTag>
633633
{
634634
new()
635635
{

test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1000,7 +1000,7 @@ public async Task ParseModifiedPetStoreDocumentWithTagAndSecurityShouldSucceed()
10001000
}
10011001
},
10021002
Components = components,
1003-
Tags = new List<OpenApiTag>
1003+
Tags = new HashSet<OpenApiTag>
10041004
{
10051005
new OpenApiTag
10061006
{

test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs

+38-1
Original file line numberDiff line numberDiff line change
@@ -2078,7 +2078,7 @@ public async Task SerializeDocumentTagsWithMultipleExtensionsWorks()
20782078
Version = "1.0.0"
20792079
},
20802080
Paths = new OpenApiPaths(),
2081-
Tags = new List<OpenApiTag>
2081+
Tags = new HashSet<OpenApiTag>
20822082
{
20832083
new OpenApiTag
20842084
{
@@ -2102,5 +2102,42 @@ public async Task SerializeDocumentTagsWithMultipleExtensionsWorks()
21022102
var actual = await doc.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0);
21032103
Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral());
21042104
}
2105+
[Fact]
2106+
public void DeduplicatesTags()
2107+
{
2108+
var document = new OpenApiDocument
2109+
{
2110+
Tags = new HashSet<OpenApiTag>
2111+
{
2112+
new OpenApiTag
2113+
{
2114+
Name = "tag1",
2115+
Extensions = new Dictionary<string, IOpenApiExtension>
2116+
{
2117+
["x-tag1"] = new OpenApiAny("tag1")
2118+
}
2119+
},
2120+
new OpenApiTag
2121+
{
2122+
Name = "tag2",
2123+
Extensions = new Dictionary<string, IOpenApiExtension>
2124+
{
2125+
["x-tag2"] = new OpenApiAny("tag2")
2126+
}
2127+
},
2128+
new OpenApiTag
2129+
{
2130+
Name = "tag1",
2131+
Extensions = new Dictionary<string, IOpenApiExtension>
2132+
{
2133+
["x-tag1"] = new OpenApiAny("tag1")
2134+
}
2135+
}
2136+
}
2137+
};
2138+
Assert.Equal(2, document.Tags.Count);
2139+
Assert.Contains(document.Tags, t => t.Name == "tag1");
2140+
Assert.Contains(document.Tags, t => t.Name == "tag2");
2141+
}
21052142
}
21062143
}

test/Microsoft.OpenApi.Tests/Models/OpenApiOperationTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public class OpenApiOperationTests
9191
{
9292
Tags = new List<OpenApiTagReference>
9393
{
94-
new OpenApiTagReference("tagId1", new OpenApiDocument{ Tags = new List<OpenApiTag>() { new OpenApiTag{Name = "tagId1"}} })
94+
new OpenApiTagReference("tagId1", new OpenApiDocument{ Tags = new HashSet<OpenApiTag>() { new OpenApiTag{Name = "tagId1"}} })
9595
},
9696
Summary = "summary1",
9797
Description = "operationDescription",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using Microsoft.OpenApi.Models;
2+
using Xunit;
3+
4+
namespace Microsoft.OpenApi.Tests;
5+
6+
public class OpenApiTagComparerTests
7+
{
8+
private readonly OpenApiTagComparer _comparer = OpenApiTagComparer.Instance;
9+
[Fact]
10+
public void Defensive()
11+
{
12+
Assert.NotNull(_comparer);
13+
14+
Assert.True(_comparer.Equals(null, null));
15+
Assert.False(_comparer.Equals(null, new OpenApiTag()));
16+
Assert.Equal(0, _comparer.GetHashCode(null));
17+
Assert.Equal(0, _comparer.GetHashCode(new OpenApiTag()));
18+
}
19+
[Fact]
20+
public void SameNamesAreEqual()
21+
{
22+
var openApiTag1 = new OpenApiTag { Name = "tag" };
23+
var openApiTag2 = new OpenApiTag { Name = "tag" };
24+
Assert.True(_comparer.Equals(openApiTag1, openApiTag2));
25+
}
26+
[Fact]
27+
public void SameInstanceAreEqual()
28+
{
29+
var openApiTag = new OpenApiTag { Name = "tag" };
30+
Assert.True(_comparer.Equals(openApiTag, openApiTag));
31+
}
32+
33+
[Fact]
34+
public void DifferentCasingAreNotEquals()
35+
{
36+
var openApiTag1 = new OpenApiTag { Name = "tag" };
37+
var openApiTag2 = new OpenApiTag { Name = "TAG" };
38+
Assert.False(_comparer.Equals(openApiTag1, openApiTag2));
39+
}
40+
}

test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt

+2-2
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,7 @@ namespace Microsoft.OpenApi.Models
722722
public Microsoft.OpenApi.Models.OpenApiPaths Paths { get; set; }
723723
public System.Collections.Generic.IList<Microsoft.OpenApi.Models.OpenApiSecurityRequirement>? SecurityRequirements { get; set; }
724724
public System.Collections.Generic.IList<Microsoft.OpenApi.Models.OpenApiServer>? Servers { get; set; }
725-
public System.Collections.Generic.IList<Microsoft.OpenApi.Models.OpenApiTag>? Tags { get; set; }
725+
public System.Collections.Generic.ISet<Microsoft.OpenApi.Models.OpenApiTag>? Tags { get; set; }
726726
public System.Collections.Generic.IDictionary<string, Microsoft.OpenApi.Models.Interfaces.IOpenApiPathItem>? Webhooks { get; set; }
727727
public Microsoft.OpenApi.Services.OpenApiWorkspace? Workspace { get; set; }
728728
public bool AddComponent<T>(string id, T componentToRegister) { }
@@ -1664,8 +1664,8 @@ namespace Microsoft.OpenApi.Services
16641664
public virtual void Visit(System.Collections.Generic.IList<Microsoft.OpenApi.Models.Interfaces.IOpenApiParameter> parameters) { }
16651665
public virtual void Visit(System.Collections.Generic.IList<Microsoft.OpenApi.Models.OpenApiSecurityRequirement> openApiSecurityRequirements) { }
16661666
public virtual void Visit(System.Collections.Generic.IList<Microsoft.OpenApi.Models.OpenApiServer> servers) { }
1667-
public virtual void Visit(System.Collections.Generic.IList<Microsoft.OpenApi.Models.OpenApiTag> openApiTags) { }
16681667
public virtual void Visit(System.Collections.Generic.IList<Microsoft.OpenApi.Models.References.OpenApiTagReference> openApiTags) { }
1668+
public virtual void Visit(System.Collections.Generic.ISet<Microsoft.OpenApi.Models.OpenApiTag> openApiTags) { }
16691669
public virtual void Visit(System.Text.Json.Nodes.JsonNode node) { }
16701670
}
16711671
public class OpenApiWalker

test/Microsoft.OpenApi.Tests/Visitors/InheritanceTests.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public void ExpectedVirtualsInvolved()
5353
visitor.Visit(default(OpenApiSecurityRequirement));
5454
visitor.Visit(default(IOpenApiSecurityScheme));
5555
visitor.Visit(default(IOpenApiExample));
56-
visitor.Visit(default(IList<OpenApiTag>));
56+
visitor.Visit(default(ISet<OpenApiTag>));
5757
visitor.Visit(default(IList<OpenApiSecurityRequirement>));
5858
visitor.Visit(default(IOpenApiExtensible));
5959
visitor.Visit(default(IOpenApiExtension));
@@ -292,7 +292,7 @@ public override void Visit(IOpenApiExample example)
292292
base.Visit(example);
293293
}
294294

295-
public override void Visit(IList<OpenApiTag> openApiTags)
295+
public override void Visit(ISet<OpenApiTag> openApiTags)
296296
{
297297
EncodeCall();
298298
base.Visit(openApiTags);

test/Microsoft.OpenApi.Tests/Walkers/WalkerLocationTests.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public void LocateTopLevelArrayItems()
4242
new(),
4343
new()
4444
},
45-
Tags = new List<OpenApiTag>
45+
Tags = new HashSet<OpenApiTag>
4646
{
4747
new()
4848
}
@@ -305,7 +305,7 @@ public override void Visit(IOpenApiSchema schema)
305305
Locations.Add(this.PathString);
306306
}
307307

308-
public override void Visit(IList<OpenApiTag> openApiTags)
308+
public override void Visit(ISet<OpenApiTag> openApiTags)
309309
{
310310
Locations.Add(this.PathString);
311311
}

0 commit comments

Comments
 (0)