Skip to content

Commit 4542ee7

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

File tree

5 files changed

+184
-21
lines changed

5 files changed

+184
-21
lines changed

openapi_spec_validator/validation/keywords.py

Lines changed: 84 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,32 @@ 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+
return cast(
113+
Callable[[Any], None],
114+
getattr(
115+
self.default_validator.value_validator_cls,
116+
"check_schema",
117+
),
118+
)
119+
120+
def _validate_schema_meta(
121+
self, schema: SchemaPath, schema_value: Any
122+
) -> OpenAPIValidationError | None:
123+
try:
124+
schema_checker = self._get_schema_checker(schema, schema_value)
125+
except ValueError as exc:
126+
return OpenAPIValidationError(str(exc))
127+
try:
128+
schema_checker(schema_value)
129+
except (SchemaError, ValidationError) as err:
130+
return cast(
131+
OpenAPIValidationError, OpenAPIValidationError.create_from(err)
132+
)
133+
return None
134+
104135
def __call__(
105136
self,
106137
schema: SchemaPath,
@@ -114,23 +145,17 @@ def __call__(
114145
)
115146
return
116147

148+
schema_id = id(schema_value)
117149
if not meta_checked:
118150
assert self.meta_checked_schema_ids is not None
119-
schema_id = id(schema_value)
120151
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
130152
self.meta_checked_schema_ids.append(schema_id)
153+
err = self._validate_schema_meta(schema, schema_value)
154+
if err is not None:
155+
yield err
156+
return
131157

132158
assert self.visited_schema_ids is not None
133-
schema_id = id(schema_value)
134159
if schema_id in self.visited_schema_ids:
135160
return
136161
self.visited_schema_ids.append(schema_id)
@@ -218,6 +243,54 @@ def __call__(
218243
yield from self.default_validator(schema, default_value)
219244

220245

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

openapi_spec_validator/validation/validators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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)