Skip to content

Commit a8b7f9e

Browse files
committed
Add OAS 3.1 jsonSchemaDialect-aware schema meta-validation
1 parent fbfb9e4 commit a8b7f9e

File tree

5 files changed

+200
-23
lines changed

5 files changed

+200
-23
lines changed

openapi_spec_validator/validation/keywords.py

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import Iterator
44
from collections.abc import Mapping
55
from collections.abc import Sequence
6+
from functools import partial
67
from typing import TYPE_CHECKING
78
from typing import Any
89
from typing import cast
@@ -11,11 +12,13 @@
1112
from jsonschema.exceptions import SchemaError
1213
from jsonschema.exceptions import ValidationError
1314
from jsonschema.protocols import Validator
15+
from jsonschema.validators import validator_for
1416
from jsonschema_path.paths import SchemaPath
1517
from openapi_schema_validator import oas30_format_checker
1618
from openapi_schema_validator import oas31_format_checker
1719
from openapi_schema_validator.validators import OAS30Validator
1820
from openapi_schema_validator.validators import OAS31Validator
21+
from openapi_schema_validator.validators import check_openapi_schema
1922

2023
from openapi_spec_validator.validation.exceptions import (
2124
DuplicateOperationIDError,
@@ -34,6 +37,8 @@
3437
KeywordValidatorRegistry,
3538
)
3639

40+
OAS31_BASE_DIALECT_URI = "https://spec.openapis.org/oas/3.1/dialect/base"
41+
3742

3843
class KeywordValidator:
3944
def __init__(self, registry: "KeywordValidatorRegistry"):
@@ -101,6 +106,26 @@ def _collect_properties(self, schema: SchemaPath) -> set[str]:
101106

102107
return props
103108

109+
def _get_schema_checker(
110+
self, schema: SchemaPath, schema_value: Any
111+
) -> Callable[[Any], None]:
112+
raise NotImplementedError
113+
114+
def _validate_schema_meta(
115+
self, schema: SchemaPath, schema_value: Any
116+
) -> OpenAPIValidationError | None:
117+
try:
118+
schema_checker = self._get_schema_checker(schema, schema_value)
119+
except ValueError as exc:
120+
return OpenAPIValidationError(str(exc))
121+
try:
122+
schema_checker(schema_value)
123+
except (SchemaError, ValidationError) as err:
124+
return cast(
125+
OpenAPIValidationError, OpenAPIValidationError.create_from(err)
126+
)
127+
return None
128+
104129
def __call__(
105130
self,
106131
schema: SchemaPath,
@@ -114,23 +139,17 @@ def __call__(
114139
)
115140
return
116141

142+
schema_id = id(schema_value)
117143
if not meta_checked:
118144
assert self.meta_checked_schema_ids is not None
119-
schema_id = id(schema_value)
120145
if schema_id not in self.meta_checked_schema_ids:
121-
try:
122-
schema_check = getattr(
123-
self.default_validator.value_validator_cls,
124-
"check_schema",
125-
)
126-
schema_check(schema_value)
127-
except (SchemaError, ValidationError) as err:
128-
yield OpenAPIValidationError.create_from(err)
129-
return
130146
self.meta_checked_schema_ids.append(schema_id)
147+
err = self._validate_schema_meta(schema, schema_value)
148+
if err is not None:
149+
yield err
150+
return
131151

132152
assert self.visited_schema_ids is not None
133-
schema_id = id(schema_value)
134153
if schema_id in self.visited_schema_ids:
135154
return
136155
self.visited_schema_ids.append(schema_id)
@@ -218,6 +237,74 @@ def __call__(
218237
yield from self.default_validator(schema, default_value)
219238

220239

240+
class OpenAPIV2SchemaValidator(SchemaValidator):
241+
def _get_schema_checker(
242+
self, schema: SchemaPath, schema_value: Any
243+
) -> Callable[[Any], None]:
244+
return cast(
245+
Callable[[Any], None],
246+
getattr(
247+
self.default_validator.value_validator_cls,
248+
"check_schema",
249+
),
250+
)
251+
252+
253+
class OpenAPIV30SchemaValidator(SchemaValidator):
254+
def _get_schema_checker(
255+
self, schema: SchemaPath, schema_value: Any
256+
) -> Callable[[Any], None]:
257+
return cast(Callable[[Any], None], OAS30Validator.check_schema)
258+
259+
260+
class OpenAPIV31SchemaValidator(SchemaValidator):
261+
default_jsonschema_dialect_id = OAS31_BASE_DIALECT_URI
262+
263+
def _get_schema_checker(
264+
self, schema: SchemaPath, schema_value: Any
265+
) -> Callable[[Any], None]:
266+
if isinstance(schema_value, Mapping):
267+
schema_to_check = dict(schema_value)
268+
if "$schema" in schema_to_check:
269+
dialect_source = schema_to_check
270+
else:
271+
jsonschema_dialect_id = self._get_jsonschema_dialect_id(schema)
272+
dialect_source = {"$schema": jsonschema_dialect_id}
273+
schema_to_check = {
274+
**schema_to_check,
275+
"$schema": jsonschema_dialect_id,
276+
}
277+
else:
278+
jsonschema_dialect_id = self._get_jsonschema_dialect_id(schema)
279+
dialect_source = {"$schema": jsonschema_dialect_id}
280+
schema_to_check = schema_value
281+
282+
validator_cls = validator_for(
283+
dialect_source,
284+
default=cast(Any, None),
285+
)
286+
if validator_cls is None:
287+
raise ValueError(
288+
f"Unknown JSON Schema dialect: {dialect_source['$schema']!r}"
289+
)
290+
return partial(
291+
check_openapi_schema,
292+
validator_cls,
293+
format_checker=oas31_format_checker,
294+
)
295+
296+
def _get_jsonschema_dialect_id(self, schema: SchemaPath) -> str:
297+
schema_root = self._get_schema_root(schema)
298+
try:
299+
return (schema_root // "jsonSchemaDialect").read_str()
300+
except KeyError:
301+
return self.default_jsonschema_dialect_id
302+
303+
def _get_schema_root(self, schema: SchemaPath) -> SchemaPath:
304+
# jsonschema-path currently has no public API for root traversal.
305+
return schema._clone_with_parts(())
306+
307+
221308
class SchemasValidator(KeywordValidator):
222309
@property
223310
def schema_validator(self) -> SchemaValidator:

openapi_spec_validator/validation/validators.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class OpenAPIV2SpecValidator(SpecValidator):
102102
"path": keywords.PathValidator,
103103
"response": keywords.OpenAPIV2ResponseValidator,
104104
"responses": keywords.ResponsesValidator,
105-
"schema": keywords.SchemaValidator,
105+
"schema": keywords.OpenAPIV2SchemaValidator,
106106
"schemas": keywords.SchemasValidator,
107107
}
108108
root_keywords = ["paths", "components"]
@@ -123,7 +123,7 @@ class OpenAPIV30SpecValidator(SpecValidator):
123123
"path": keywords.PathValidator,
124124
"response": keywords.OpenAPIV3ResponseValidator,
125125
"responses": keywords.ResponsesValidator,
126-
"schema": keywords.SchemaValidator,
126+
"schema": keywords.OpenAPIV30SchemaValidator,
127127
"schemas": keywords.SchemasValidator,
128128
}
129129
root_keywords = ["paths", "components"]
@@ -144,7 +144,7 @@ class OpenAPIV31SpecValidator(SpecValidator):
144144
"path": keywords.PathValidator,
145145
"response": keywords.OpenAPIV3ResponseValidator,
146146
"responses": keywords.ResponsesValidator,
147-
"schema": keywords.SchemaValidator,
147+
"schema": keywords.OpenAPIV31SchemaValidator,
148148
"schemas": keywords.SchemasValidator,
149149
}
150150
root_keywords = ["paths", "components"]

poetry.lock

Lines changed: 9 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ classifiers = [
2525
]
2626
dependencies = [
2727
"jsonschema >=4.24.0,<4.25.0",
28-
"openapi-schema-validator >=0.7.0,<0.8.0",
28+
"openapi-schema-validator >=0.7.2,<0.8.0",
2929
"jsonschema-path >=0.4.2,<0.5.0",
3030
"lazy-object-proxy >=1.7.1,<2.0",
3131
]
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from openapi_spec_validator import OpenAPIV31SpecValidator
2+
from openapi_spec_validator.validation.exceptions import OpenAPIValidationError
3+
4+
5+
def make_spec(
6+
component_schema: dict[str, object] | bool,
7+
json_schema_dialect: str | None = None,
8+
) -> dict[str, object]:
9+
spec: dict[str, object] = {
10+
"openapi": "3.1.0",
11+
"info": {
12+
"title": "Test API",
13+
"version": "0.0.1",
14+
},
15+
"paths": {},
16+
"components": {
17+
"schemas": {
18+
"Component": component_schema,
19+
},
20+
},
21+
}
22+
if json_schema_dialect is not None:
23+
spec["jsonSchemaDialect"] = json_schema_dialect
24+
return spec
25+
26+
27+
def test_root_json_schema_dialect_is_honored():
28+
spec = make_spec(
29+
{"type": "object"},
30+
json_schema_dialect="https://json-schema.org/draft/2019-09/schema",
31+
)
32+
33+
errors = list(OpenAPIV31SpecValidator(spec).iter_errors())
34+
assert errors == []
35+
36+
37+
def test_schema_dialect_overrides_root_json_schema_dialect():
38+
root_dialect = "https://json-schema.org/draft/2019-09/schema"
39+
schema_dialect = "https://json-schema.org/draft/2020-12/schema"
40+
spec = make_spec(
41+
{
42+
"$schema": schema_dialect,
43+
"type": "object",
44+
},
45+
json_schema_dialect=root_dialect,
46+
)
47+
48+
errors = list(OpenAPIV31SpecValidator(spec).iter_errors())
49+
50+
assert errors == []
51+
52+
53+
def test_unknown_dialect_raises_error():
54+
spec = make_spec(
55+
{"type": "object"},
56+
json_schema_dialect="https://example.com/custom",
57+
)
58+
59+
errors = list(OpenAPIV31SpecValidator(spec).iter_errors())
60+
61+
assert len(errors) == 1
62+
assert isinstance(errors[0], OpenAPIValidationError)
63+
assert "Unknown JSON Schema dialect" in errors[0].message
64+
65+
66+
def test_meta_check_error_stops_further_schema_traversal():
67+
spec = make_spec(
68+
{
69+
"type": 1,
70+
"required": ["missing_property"],
71+
},
72+
json_schema_dialect="https://json-schema.org/draft/2020-12/schema",
73+
)
74+
75+
errors = list(OpenAPIV31SpecValidator(spec).iter_errors())
76+
77+
assert len(errors) == 1
78+
assert "is not valid under any of the given schemas" in errors[0].message
79+
80+
81+
def test_boolean_schema_uses_root_json_schema_dialect():
82+
spec = make_spec(
83+
True,
84+
json_schema_dialect="https://json-schema.org/draft/2019-09/schema",
85+
)
86+
87+
errors = list(OpenAPIV31SpecValidator(spec).iter_errors())
88+
89+
assert errors == []

0 commit comments

Comments
 (0)