Skip to content

Commit bf9954a

Browse files
fix: Improve handling of OpenAPI tag references (#2325)
* Fix OpenApiTagComparer behaviour Update `OpenApiTagComparer` to behave intuitively with `OpenApiTagReference` instances pointing to tags not defined in an `OpenApiDocument`. Contributes to #2319. * Verify tag references on serialization Verify OpenAPI tag references refer to a valid OpenAPI tag in the document on serialization. Resolves #2319.
1 parent 25136af commit bf9954a

File tree

9 files changed

+189
-8
lines changed

9 files changed

+189
-8
lines changed

src/Microsoft.OpenApi/Models/OpenApiOperation.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Linq;
7+
using Microsoft.OpenApi.Exceptions;
78
using Microsoft.OpenApi.Interfaces;
89
using Microsoft.OpenApi.Models.Interfaces;
910
using Microsoft.OpenApi.Models.References;
@@ -86,7 +87,7 @@ public HashSet<OpenApiTagReference>? Tags
8687
/// <summary>
8788
/// REQUIRED. The list of possible responses as they are returned from executing this operation.
8889
/// </summary>
89-
public OpenApiResponses? Responses { get; set; } = new();
90+
public OpenApiResponses? Responses { get; set; } = [];
9091

9192
/// <summary>
9293
/// A map of possible out-of band callbacks related to the parent operation.
@@ -182,7 +183,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
182183
// tags
183184
writer.WriteOptionalCollection(
184185
OpenApiConstants.Tags,
185-
Tags,
186+
VerifyTagReferences(Tags),
186187
callback);
187188

188189
// summary
@@ -236,7 +237,7 @@ public void SerializeAsV2(IOpenApiWriter writer)
236237
// tags
237238
writer.WriteOptionalCollection(
238239
OpenApiConstants.Tags,
239-
Tags,
240+
VerifyTagReferences(Tags),
240241
(w, t) => t.SerializeAsV2(w));
241242

242243
// summary
@@ -355,5 +356,21 @@ public void SerializeAsV2(IOpenApiWriter writer)
355356

356357
writer.WriteEndObject();
357358
}
359+
360+
private static HashSet<OpenApiTagReference>? VerifyTagReferences(HashSet<OpenApiTagReference>? tags)
361+
{
362+
if (tags?.Count > 0)
363+
{
364+
foreach (var tag in tags)
365+
{
366+
if (tag.Target is null)
367+
{
368+
throw new OpenApiException($"The OpenAPI tag reference '{tag.Reference.Id}' does reference a valid tag.");
369+
}
370+
}
371+
}
372+
373+
return tags;
374+
}
358375
}
359376
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
33

4-
using System;
54
using System.Collections.Generic;
65
using System.Linq;
76
using Microsoft.OpenApi.Interfaces;

src/Microsoft.OpenApi/OpenApiTagComparer.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using Microsoft.OpenApi.Models.Interfaces;
4+
using Microsoft.OpenApi.Models.References;
45

56
namespace Microsoft.OpenApi;
67

@@ -31,6 +32,10 @@ public bool Equals(IOpenApiTag? x, IOpenApiTag? y)
3132
{
3233
return true;
3334
}
35+
if (x is OpenApiTagReference referenceX && y is OpenApiTagReference referenceY)
36+
{
37+
return StringComparer.Equals(referenceX.Name ?? referenceX.Reference.Id, referenceY.Name ?? referenceY.Reference.Id);
38+
}
3439
return StringComparer.Equals(x.Name, y.Name);
3540
}
3641

@@ -41,5 +46,13 @@ public bool Equals(IOpenApiTag? x, IOpenApiTag? y)
4146
internal static readonly StringComparer StringComparer = StringComparer.Ordinal;
4247

4348
/// <inheritdoc/>
44-
public int GetHashCode(IOpenApiTag obj) => string.IsNullOrEmpty(obj?.Name) ? 0 : StringComparer.GetHashCode(obj!.Name);
49+
public int GetHashCode(IOpenApiTag obj)
50+
{
51+
string? value = obj?.Name;
52+
if (value is null && obj is OpenApiTagReference reference)
53+
{
54+
value = reference.Reference.Id;
55+
}
56+
return string.IsNullOrEmpty(value) ? 0 : StringComparer.GetHashCode(value);
57+
}
4558
}

test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ public async Task CopiesOverAllReferencedComponentsToTheSubsetDocumentCorrectly(
244244
var openApiOperationTags = doc?.Paths["/items"].Operations?[HttpMethod.Get].Tags?.ToArray();
245245
Assert.NotNull(openApiOperationTags);
246246
Assert.Single(openApiOperationTags);
247-
Assert.True(openApiOperationTags[0].UnresolvedReference);
247+
Assert.NotNull(openApiOperationTags[0].Target);
248248

249249
var predicate = OpenApiFilterService.CreatePredicate(operationIds: operationIds);
250250
if (doc is not null)
@@ -271,7 +271,7 @@ public async Task CopiesOverAllReferencedComponentsToTheSubsetDocumentCorrectly(
271271
var trimmedOpenApiOperationTags = subsetOpenApiDocument.Paths?["/items"].Operations?[HttpMethod.Get].Tags?.ToArray();
272272
Assert.NotNull(trimmedOpenApiOperationTags);
273273
Assert.Single(trimmedOpenApiOperationTags);
274-
Assert.True(trimmedOpenApiOperationTags[0].UnresolvedReference);
274+
Assert.NotNull(trimmedOpenApiOperationTags[0].Target);
275275

276276
// Finally try to write the trimmed document as v3 document
277277
var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
@@ -302,7 +302,8 @@ public void ReturnsPathParametersOnSlicingBasedOnOperationIdsOrTags(string? oper
302302
// Assert
303303
foreach (var pathItem in subsetOpenApiDocument.Paths)
304304
{
305-
Assert.True(pathItem.Value.Parameters!.Count != 0);
305+
Assert.NotNull(pathItem.Value.Parameters);
306+
Assert.NotEmpty(pathItem.Value.Parameters);
306307
Assert.Single(pathItem.Value.Parameters!);
307308
}
308309
}

test/Microsoft.OpenApi.Hidi.Tests/UtilityFiles/docWithReusableHeadersAndExamples.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,5 @@ components:
8181
value:
8282
name: "New Item"
8383

84+
tags:
85+
- name: list.items

test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.ParseDocumentWith31PropertiesWorks.verified.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ components:
9999
in: header
100100
security:
101101
- api_key: [ ]
102+
tags:
103+
- name: pets
102104
webhooks:
103105
newPetAlert:
104106
post:

test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiDocument/documentWith31Properties.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,5 +122,8 @@ components:
122122
ExtraInfo:
123123
type: string
124124

125+
tags:
126+
- name: pets
127+
125128
security:
126129
- api_key: []

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Collections.Generic;
55
using System.Text.Json.Nodes;
66
using System.Threading.Tasks;
7+
using Microsoft.OpenApi.Exceptions;
78
using Microsoft.OpenApi.Extensions;
89
using Microsoft.OpenApi.Models;
910
using Microsoft.OpenApi.Models.Interfaces;
@@ -859,5 +860,34 @@ public void OpenApiOperationCopyConstructorWithAnnotationsSucceeds()
859860

860861
Assert.NotEqual(baseOperation.Metadata["key1"], actualOperation.Metadata["key1"]);
861862
}
863+
864+
[Theory]
865+
[InlineData(OpenApiSpecVersion.OpenApi2_0)]
866+
[InlineData(OpenApiSpecVersion.OpenApi3_0)]
867+
[InlineData(OpenApiSpecVersion.OpenApi3_1)]
868+
public async Task SerializeAsJsonAsyncThrowsIfTagReferenceIsUnresolved(OpenApiSpecVersion version)
869+
{
870+
var document = new OpenApiDocument()
871+
{
872+
Tags =
873+
[
874+
new() { Name = "one" },
875+
new() { Name = "three" }
876+
]
877+
};
878+
879+
var operation = new OpenApiOperation()
880+
{
881+
Tags =
882+
[
883+
new OpenApiTagReference("one", document),
884+
new OpenApiTagReference("two", document),
885+
new OpenApiTagReference("three", document)
886+
]
887+
};
888+
889+
var exception = await Assert.ThrowsAsync<OpenApiException>(() => operation.SerializeAsJsonAsync(version));
890+
Assert.Equal("The OpenAPI tag reference 'two' does reference a valid tag.", exception.Message);
891+
}
862892
}
863893
}

test/Microsoft.OpenApi.Tests/OpenApiTagComparerTests.cs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
13
using Microsoft.OpenApi.Models;
4+
using Microsoft.OpenApi.Models.References;
25
using Xunit;
36

47
namespace Microsoft.OpenApi.Tests;
58

69
public class OpenApiTagComparerTests
710
{
811
private readonly OpenApiTagComparer _comparer = OpenApiTagComparer.Instance;
12+
913
[Fact]
1014
public void Defensive()
1115
{
@@ -16,13 +20,15 @@ public void Defensive()
1620
Assert.Equal(0, _comparer.GetHashCode(null));
1721
Assert.Equal(0, _comparer.GetHashCode(new OpenApiTag()));
1822
}
23+
1924
[Fact]
2025
public void SameNamesAreEqual()
2126
{
2227
var openApiTag1 = new OpenApiTag { Name = "tag" };
2328
var openApiTag2 = new OpenApiTag { Name = "tag" };
2429
Assert.True(_comparer.Equals(openApiTag1, openApiTag2));
2530
}
31+
2632
[Fact]
2733
public void SameInstanceAreEqual()
2834
{
@@ -37,4 +43,112 @@ public void DifferentCasingAreNotEquals()
3743
var openApiTag2 = new OpenApiTag { Name = "TAG" };
3844
Assert.False(_comparer.Equals(openApiTag1, openApiTag2));
3945
}
46+
47+
[Fact]
48+
public void WorksCorrectlyWithHashSetOfTags()
49+
{
50+
var tags = new HashSet<OpenApiTag>(_comparer)
51+
{
52+
new() { Name = "one" },
53+
new() { Name = "two" },
54+
new() { Name = "two" },
55+
new() { Name = "three" }
56+
};
57+
58+
Assert.Equal(["one", "two", "three"], [.. tags.Select(t => t.Name)]);
59+
}
60+
61+
[Fact]
62+
public void SameReferenceInstanceAreEqual()
63+
{
64+
var openApiTag = new OpenApiTagReference("tag");
65+
Assert.True(_comparer.Equals(openApiTag, openApiTag));
66+
}
67+
68+
[Fact]
69+
public void SameReferenceIdsAreEqual()
70+
{
71+
var openApiTag1 = new OpenApiTagReference("tag");
72+
var openApiTag2 = new OpenApiTagReference("tag");
73+
Assert.True(_comparer.Equals(openApiTag1, openApiTag2));
74+
}
75+
76+
[Fact]
77+
public void SameReferenceIdAreEqualWithValidTagReferences()
78+
{
79+
var document = new OpenApiDocument
80+
{
81+
Tags = [new() { Name = "tag" }]
82+
};
83+
84+
var openApiTag1 = new OpenApiTagReference("tag", document);
85+
var openApiTag2 = new OpenApiTagReference("tag", document);
86+
Assert.True(_comparer.Equals(openApiTag1, openApiTag2));
87+
}
88+
89+
[Fact]
90+
public void DifferentReferenceIdAreNotEqualWithValidTagReferences()
91+
{
92+
var document = new OpenApiDocument
93+
{
94+
Tags =
95+
[
96+
new() { Name = "one" },
97+
new() { Name = "two" },
98+
]
99+
};
100+
101+
var openApiTag1 = new OpenApiTagReference("one", document);
102+
var openApiTag2 = new OpenApiTagReference("two", document);
103+
Assert.False(_comparer.Equals(openApiTag1, openApiTag2));
104+
}
105+
106+
[Fact]
107+
public void DifferentCasingReferenceIdsAreNotEqual()
108+
{
109+
var openApiTag1 = new OpenApiTagReference("tag");
110+
var openApiTag2 = new OpenApiTagReference("TAG");
111+
Assert.False(_comparer.Equals(openApiTag1, openApiTag2));
112+
}
113+
114+
[Fact] // See https://github.com/microsoft/OpenAPI.NET/issues/2319
115+
public void WorksCorrectlyWithHashSetOfReferences()
116+
{
117+
// The document intentionally does not contain the actual tags
118+
var document = new OpenApiDocument();
119+
120+
var tags = new HashSet<OpenApiTagReference>(_comparer)
121+
{
122+
new("one", document),
123+
new("two", document),
124+
new("two", document),
125+
new("three", document)
126+
};
127+
128+
Assert.Equal(["one", "two", "three"], [..tags.Select(t => t.Reference.Id)]);
129+
}
130+
131+
[Fact]
132+
public void WorksCorrectlyWithHashSetOfReferencesToValidTags()
133+
{
134+
var document = new OpenApiDocument
135+
{
136+
Tags =
137+
[
138+
new() { Name = "one" },
139+
new() { Name = "two" },
140+
new() { Name = "three" }
141+
]
142+
};
143+
144+
var tags = new HashSet<OpenApiTagReference>(_comparer)
145+
{
146+
new("one", document),
147+
new("two", document),
148+
new("two", document),
149+
new("three", document)
150+
};
151+
152+
Assert.Equal(["one", "two", "three"], [.. tags.Select(t => t.Reference.Id)]);
153+
}
40154
}

0 commit comments

Comments
 (0)