Skip to content

Commit 383d17a

Browse files
committed
Add OpenAPI 3.2 tags
1 parent 268e427 commit 383d17a

File tree

3 files changed

+126
-0
lines changed

3 files changed

+126
-0
lines changed

openapi_spec_validator/validation/keywords.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,65 @@ def __call__(self, components: SchemaPath) -> Iterator[ValidationError]:
642642
yield from self.schemas_validator(schemas)
643643

644644

645+
class TagsValidator(KeywordValidator):
646+
def __call__(self, tags: SchemaPath) -> Iterator[ValidationError]:
647+
seen: set[str] = set()
648+
for tag in tags:
649+
tag_name = (tag / "name").read_str()
650+
if tag_name in seen:
651+
yield OpenAPIValidationError(
652+
f"Duplicate tag name '{tag_name}'"
653+
)
654+
seen.add(tag_name)
655+
656+
657+
class OpenAPIV32TagsValidator(TagsValidator):
658+
def __call__(self, tags: SchemaPath) -> Iterator[ValidationError]:
659+
yield from super().__call__(tags)
660+
661+
seen: set[str] = set()
662+
parent_by_tag_name: dict[str, str | None] = {}
663+
for tag in tags:
664+
tag_name = (tag / "name").read_str()
665+
seen.add(tag_name)
666+
667+
if "parent" in tag:
668+
parent_by_tag_name[tag_name] = (tag / "parent").read_str()
669+
else:
670+
parent_by_tag_name[tag_name] = None
671+
672+
for tag_name, parent in parent_by_tag_name.items():
673+
if parent is not None and parent not in seen:
674+
yield OpenAPIValidationError(
675+
f"Tag '{tag_name}' references unknown parent tag '{parent}'"
676+
)
677+
678+
reported_cycles: set[str] = set()
679+
for start_tag_name in parent_by_tag_name:
680+
tag_name = start_tag_name
681+
trail: list[str] = []
682+
trail_pos: dict[str, int] = {}
683+
684+
while True:
685+
if tag_name in trail_pos:
686+
cycle = trail[trail_pos[tag_name] :] + [tag_name]
687+
cycle_str = " -> ".join(cycle)
688+
if cycle_str not in reported_cycles:
689+
reported_cycles.add(cycle_str)
690+
yield OpenAPIValidationError(
691+
f"Circular tag hierarchy detected: {cycle_str}"
692+
)
693+
break
694+
695+
trail_pos[tag_name] = len(trail)
696+
trail.append(tag_name)
697+
698+
parent = parent_by_tag_name.get(tag_name)
699+
if parent is None or parent not in seen:
700+
break
701+
tag_name = parent
702+
703+
645704
class RootValidator(KeywordValidator):
646705
@property
647706
def paths_validator(self) -> PathsValidator:
@@ -652,6 +711,11 @@ def components_validator(self) -> ComponentsValidator:
652711
return cast(ComponentsValidator, self.registry["components"])
653712

654713
def __call__(self, spec: SchemaPath) -> Iterator[ValidationError]:
714+
if "tags" in spec and "tags" in self.registry.keyword_validators:
715+
tags = spec / "tags"
716+
tags_validator = cast(Any, self.registry["tags"])
717+
yield from tags_validator(tags)
718+
655719
if "paths" in spec:
656720
paths = spec / "paths"
657721
yield from self.paths_validator(paths)

openapi_spec_validator/validation/validators.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class OpenAPIV2SpecValidator(SpecValidator):
105105
"responses": keywords.ResponsesValidator,
106106
"schema": keywords.OpenAPIV30SchemaValidator,
107107
"schemas": keywords.SchemasValidator,
108+
"tags": keywords.TagsValidator,
108109
}
109110
root_keywords = ["paths", "components"]
110111

@@ -126,6 +127,7 @@ class OpenAPIV30SpecValidator(SpecValidator):
126127
"responses": keywords.ResponsesValidator,
127128
"schema": keywords.OpenAPIV30SchemaValidator,
128129
"schemas": keywords.SchemasValidator,
130+
"tags": keywords.TagsValidator,
129131
}
130132
root_keywords = ["paths", "components"]
131133

@@ -147,6 +149,7 @@ class OpenAPIV31SpecValidator(SpecValidator):
147149
"responses": keywords.ResponsesValidator,
148150
"schema": keywords.OpenAPIV31SchemaValidator,
149151
"schemas": keywords.SchemasValidator,
152+
"tags": keywords.TagsValidator,
150153
}
151154
root_keywords = ["paths", "components"]
152155

@@ -168,5 +171,6 @@ class OpenAPIV32SpecValidator(SpecValidator):
168171
"responses": keywords.ResponsesValidator,
169172
"schema": keywords.OpenAPIV32SchemaValidator,
170173
"schemas": keywords.SchemasValidator,
174+
"tags": keywords.OpenAPIV32TagsValidator,
171175
}
172176
root_keywords = ["paths", "components"]

tests/integration/validation/test_validators.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,64 @@ def test_additional_operations_are_semantically_validated(self):
199199
assert len(errors) == 1
200200
assert "Path parameter 'item_id'" in errors[0].message
201201

202+
def test_top_level_duplicate_tags_are_invalid(self):
203+
spec = {
204+
"openapi": "3.2.0",
205+
"info": {
206+
"title": "Tag API",
207+
"version": "1.0.0",
208+
},
209+
"tags": [
210+
{
211+
"name": "pets",
212+
},
213+
{
214+
"name": "pets",
215+
},
216+
],
217+
"paths": {
218+
"/pets": {
219+
"get": {
220+
"responses": {
221+
"200": {
222+
"description": "ok",
223+
},
224+
},
225+
},
226+
},
227+
},
228+
}
229+
230+
errors = list(OpenAPIV32SpecValidator(spec).iter_errors())
231+
232+
assert len(errors) == 1
233+
assert errors[0].message == "Duplicate tag name 'pets'"
234+
235+
def test_operation_tags_without_root_declaration_are_valid(self):
236+
spec = {
237+
"openapi": "3.2.0",
238+
"info": {
239+
"title": "Tag API",
240+
"version": "1.0.0",
241+
},
242+
"paths": {
243+
"/pets": {
244+
"get": {
245+
"tags": ["pets", "animals"],
246+
"responses": {
247+
"200": {
248+
"description": "ok",
249+
},
250+
},
251+
},
252+
},
253+
},
254+
}
255+
256+
errors = list(OpenAPIV32SpecValidator(spec).iter_errors())
257+
258+
assert not errors
259+
202260

203261
def test_oas31_query_operation_is_not_semantically_traversed():
204262
spec = {

0 commit comments

Comments
 (0)