Skip to content

Commit 5524544

Browse files
committed
OAS 3.1 dialect registration and validator discovery
1 parent 5015df1 commit 5524544

File tree

5 files changed

+265
-56
lines changed

5 files changed

+265
-56
lines changed

README.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,26 @@ To validate an OpenAPI v3.1 schema:
100100
101101
By default, the latest OpenAPI schema syntax is expected.
102102

103+
The OpenAPI 3.1 base dialect URI is registered for
104+
``jsonschema.validators.validator_for`` resolution.
105+
Schemas declaring
106+
``"$schema": "https://spec.openapis.org/oas/3.1/dialect/base"``
107+
resolve directly to ``OAS31Validator`` without unresolved-metaschema
108+
fallback warnings.
109+
110+
.. code-block:: python
111+
112+
from jsonschema.validators import validator_for
113+
114+
from openapi_schema_validator import OAS31Validator
115+
116+
schema = {
117+
"$schema": "https://spec.openapis.org/oas/3.1/dialect/base",
118+
"type": "object",
119+
}
120+
121+
assert validator_for(schema) is OAS31Validator
122+
103123
104124
Strict vs Pragmatic Validators
105125
==============================

docs/validation.rst

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,25 @@ if you want to disambiguate the expected schema version, import and use ``OAS31V
6767
6868
validate({"name": "John", "age": 23}, schema, cls=OAS31Validator)
6969
70+
The OpenAPI 3.1 base dialect URI is registered for
71+
``jsonschema.validators.validator_for`` resolution.
72+
If your schema declares
73+
``"$schema": "https://spec.openapis.org/oas/3.1/dialect/base"``,
74+
``validator_for`` resolves directly to ``OAS31Validator`` without
75+
unresolved-metaschema fallback warnings.
76+
77+
.. code-block:: python
78+
79+
from jsonschema.validators import validator_for
80+
81+
from openapi_schema_validator import OAS31Validator
82+
83+
schema = {
84+
"$schema": "https://spec.openapis.org/oas/3.1/dialect/base",
85+
"type": "object",
86+
}
87+
assert validator_for(schema) is OAS31Validator
88+
7089
For OpenAPI 3.2, use ``OAS32Validator`` (behaves identically to ``OAS31Validator``, since 3.2 uses the same JSON Schema dialect).
7190

7291
In order to validate OpenAPI 3.0 schema, import and use ``OAS30Validator`` instead of ``OAS31Validator``.
@@ -193,7 +212,8 @@ Example usage:
193212

194213
.. code-block:: python
195214
196-
from openapi_schema_validator import OAS30Validator, OAS30StrictValidator
215+
from openapi_schema_validator import OAS30StrictValidator
216+
from openapi_schema_validator import OAS30Validator
197217
198218
# Pragmatic (default) - accepts bytes for binary format
199219
validator = OAS30Validator({"type": "string", "format": "binary"})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from copy import deepcopy
2+
from typing import Any
3+
from typing import cast
4+
5+
from jsonschema.validators import Draft202012Validator
6+
from jsonschema.validators import validates
7+
from jsonschema_specifications import REGISTRY as JSONSCHEMA_SPECIFICATIONS
8+
9+
__all__ = [
10+
"JSONSCHEMA_SPECIFICATIONS",
11+
"OAS31_BASE_DIALECT_ID",
12+
"OAS31_BASE_DIALECT_METASCHEMA",
13+
"OPENAPI_SPECIFICATIONS",
14+
"register_openapi_dialect",
15+
]
16+
17+
OAS31_BASE_DIALECT_ID = "https://spec.openapis.org/oas/3.1/dialect/base"
18+
19+
OAS31_BASE_DIALECT_METASCHEMA = cast(
20+
dict[str, Any],
21+
deepcopy(Draft202012Validator.META_SCHEMA),
22+
)
23+
OAS31_BASE_DIALECT_METASCHEMA["$id"] = OAS31_BASE_DIALECT_ID
24+
25+
OPENAPI_SPECIFICATIONS = JSONSCHEMA_SPECIFICATIONS.with_contents(
26+
[(OAS31_BASE_DIALECT_ID, OAS31_BASE_DIALECT_METASCHEMA)],
27+
)
28+
29+
_REGISTERED_VALIDATORS: dict[tuple[str, str], Any] = {}
30+
31+
32+
def register_openapi_dialect(
33+
*,
34+
validator: Any,
35+
dialect_id: str,
36+
version_name: str,
37+
metaschema: dict[str, Any],
38+
) -> Any:
39+
key = (dialect_id, version_name)
40+
registered_validator = _REGISTERED_VALIDATORS.get(key)
41+
42+
if registered_validator is validator:
43+
return validator
44+
if registered_validator is not None:
45+
return registered_validator
46+
47+
validator.META_SCHEMA = metaschema
48+
validator = validates(version_name)(validator)
49+
_REGISTERED_VALIDATORS[key] = validator
50+
return validator

openapi_schema_validator/validators.py

Lines changed: 86 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@
77
from jsonschema.validators import Draft202012Validator
88
from jsonschema.validators import create
99
from jsonschema.validators import extend
10-
from jsonschema_specifications import REGISTRY as SPECIFICATIONS
1110

1211
from openapi_schema_validator import _format as oas_format
1312
from openapi_schema_validator import _keywords as oas_keywords
1413
from openapi_schema_validator import _types as oas_types
14+
from openapi_schema_validator._dialects import JSONSCHEMA_SPECIFICATIONS
15+
from openapi_schema_validator._dialects import (
16+
OAS31_BASE_DIALECT_ID as _OAS31_BASE_DIALECT_ID,
17+
)
18+
from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_METASCHEMA
19+
from openapi_schema_validator._dialects import register_openapi_dialect
1520
from openapi_schema_validator._types import oas31_type_checker
1621

22+
OAS31_BASE_DIALECT_ID = _OAS31_BASE_DIALECT_ID
23+
1724

1825
def _oas30_id_of(schema: Any) -> str:
1926
if isinstance(schema, dict):
@@ -63,64 +70,88 @@ def _oas30_id_of(schema: Any) -> str:
6370
},
6471
)
6572

66-
OAS30Validator = create(
67-
meta_schema=SPECIFICATIONS.contents(
68-
"http://json-schema.org/draft-04/schema#",
69-
),
70-
validators=OAS30_VALIDATORS,
71-
type_checker=oas_types.oas30_type_checker,
72-
format_checker=oas_format.oas30_format_checker,
73-
# NOTE: version causes conflict with global jsonschema validator
74-
# See https://github.com/python-openapi/openapi-schema-validator/pull/12
75-
# version="oas30",
76-
id_of=_oas30_id_of,
77-
)
7873

79-
OAS30StrictValidator = extend(
80-
OAS30Validator,
81-
validators={
82-
"type": oas_keywords.strict_type,
83-
},
84-
type_checker=oas_types.oas30_type_checker,
85-
format_checker=oas_format.oas30_strict_format_checker,
86-
# NOTE: version causes conflict with global jsonschema validator
87-
# See https://github.com/python-openapi/openapi-schema-validator/pull/12
88-
# version="oas30-strict",
89-
)
74+
def _build_oas30_validator() -> Any:
75+
return create(
76+
meta_schema=JSONSCHEMA_SPECIFICATIONS.contents(
77+
"http://json-schema.org/draft-04/schema#",
78+
),
79+
validators=OAS30_VALIDATORS,
80+
type_checker=oas_types.oas30_type_checker,
81+
format_checker=oas_format.oas30_format_checker,
82+
# NOTE: version causes conflict with global jsonschema validator
83+
# See https://github.com/python-openapi/openapi-schema-validator/pull/12
84+
# version="oas30",
85+
id_of=_oas30_id_of,
86+
)
9087

91-
OAS30ReadValidator = extend(
92-
OAS30Validator,
93-
validators={
94-
"required": oas_keywords.read_required,
95-
"writeOnly": oas_keywords.read_writeOnly,
96-
},
97-
)
9888

99-
OAS30WriteValidator = extend(
100-
OAS30Validator,
101-
validators={
102-
"required": oas_keywords.write_required,
103-
"readOnly": oas_keywords.write_readOnly,
104-
},
105-
)
89+
def _build_oas30_strict_validator(oas30_validator: Any) -> Any:
90+
return extend(
91+
oas30_validator,
92+
validators={
93+
"type": oas_keywords.strict_type,
94+
},
95+
type_checker=oas_types.oas30_type_checker,
96+
format_checker=oas_format.oas30_strict_format_checker,
97+
# NOTE: version causes conflict with global jsonschema validator
98+
# See https://github.com/python-openapi/openapi-schema-validator/pull/12
99+
# version="oas30-strict",
100+
)
106101

107-
OAS31Validator = extend(
108-
Draft202012Validator,
109-
{
110-
# adjusted to OAS
111-
"allOf": oas_keywords.allOf,
112-
"oneOf": oas_keywords.oneOf,
113-
"anyOf": oas_keywords.anyOf,
114-
"description": oas_keywords.not_implemented,
115-
# fixed OAS fields
116-
"discriminator": oas_keywords.not_implemented,
117-
"xml": oas_keywords.not_implemented,
118-
"externalDocs": oas_keywords.not_implemented,
119-
"example": oas_keywords.not_implemented,
120-
},
121-
type_checker=oas31_type_checker,
122-
format_checker=oas_format.oas31_format_checker,
123-
)
102+
103+
def _build_oas30_read_validator(oas30_validator: Any) -> Any:
104+
return extend(
105+
oas30_validator,
106+
validators={
107+
"required": oas_keywords.read_required,
108+
"writeOnly": oas_keywords.read_writeOnly,
109+
},
110+
)
111+
112+
113+
def _build_oas30_write_validator(oas30_validator: Any) -> Any:
114+
return extend(
115+
oas30_validator,
116+
validators={
117+
"required": oas_keywords.write_required,
118+
"readOnly": oas_keywords.write_readOnly,
119+
},
120+
)
121+
122+
123+
def _build_oas31_validator() -> Any:
124+
validator = extend(
125+
Draft202012Validator,
126+
{
127+
# adjusted to OAS
128+
"allOf": oas_keywords.allOf,
129+
"oneOf": oas_keywords.oneOf,
130+
"anyOf": oas_keywords.anyOf,
131+
"description": oas_keywords.not_implemented,
132+
# fixed OAS fields
133+
"discriminator": oas_keywords.not_implemented,
134+
"xml": oas_keywords.not_implemented,
135+
"externalDocs": oas_keywords.not_implemented,
136+
"example": oas_keywords.not_implemented,
137+
},
138+
type_checker=oas31_type_checker,
139+
format_checker=oas_format.oas31_format_checker,
140+
)
141+
return register_openapi_dialect(
142+
validator=validator,
143+
dialect_id=OAS31_BASE_DIALECT_ID,
144+
version_name="oas31",
145+
metaschema=OAS31_BASE_DIALECT_METASCHEMA,
146+
)
147+
148+
149+
OAS30Validator = _build_oas30_validator()
150+
OAS30StrictValidator = _build_oas30_strict_validator(OAS30Validator)
151+
OAS30ReadValidator = _build_oas30_read_validator(OAS30Validator)
152+
OAS30WriteValidator = _build_oas30_write_validator(OAS30Validator)
153+
154+
OAS31Validator = _build_oas31_validator()
124155

125156
# OAS 3.2 uses JSON Schema Draft 2020-12 as its base dialect, same as
126157
# OAS 3.1. The OAS-specific vocabulary differs slightly (e.g. xml keyword

tests/integration/test_validators.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import warnings
12
from base64 import b64encode
23
from typing import Any
34
from typing import cast
@@ -7,6 +8,9 @@
78
from jsonschema.exceptions import (
89
_WrappedReferencingError as WrappedReferencingError,
910
)
11+
from jsonschema.validators import Draft202012Validator
12+
from jsonschema.validators import extend
13+
from jsonschema.validators import validator_for
1014
from referencing import Registry
1115
from referencing import Resource
1216
from referencing.exceptions import InvalidAnchor
@@ -24,6 +28,9 @@
2428
from openapi_schema_validator import oas30_strict_format_checker
2529
from openapi_schema_validator import oas31_format_checker
2630
from openapi_schema_validator import oas32_format_checker
31+
from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_METASCHEMA
32+
from openapi_schema_validator._dialects import register_openapi_dialect
33+
from openapi_schema_validator.validators import OAS31_BASE_DIALECT_ID
2734

2835

2936
class TestOAS30ValidatorFormatChecker:
@@ -1113,3 +1120,84 @@ def test_strict_binary_format_rejects_str(self):
11131120
# Note: "test" is actually valid base64, so use "not base64" which is not
11141121
with pytest.raises(ValidationError, match="is not a 'binary'"):
11151122
validator.validate("not base64")
1123+
1124+
1125+
class TestValidatorForDiscovery:
1126+
def test_oas31_base_dialect_resolves_to_oas31_validator(self):
1127+
schema = {"$schema": OAS31_BASE_DIALECT_ID}
1128+
1129+
validator_class = validator_for(schema)
1130+
1131+
assert validator_class is OAS31Validator
1132+
1133+
def test_oas31_base_dialect_discovery_has_no_deprecation_warning(self):
1134+
schema = {"$schema": OAS31_BASE_DIALECT_ID}
1135+
1136+
with warnings.catch_warnings(record=True) as caught:
1137+
warnings.simplefilter("always")
1138+
validator_for(schema)
1139+
1140+
assert not any(
1141+
issubclass(warning.category, DeprecationWarning)
1142+
for warning in caught
1143+
)
1144+
1145+
def test_oas31_base_dialect_keeps_oas_keyword_behavior(self):
1146+
schema = {
1147+
"$schema": OAS31_BASE_DIALECT_ID,
1148+
"type": "object",
1149+
"required": ["kind"],
1150+
"properties": {"kind": {"type": "string"}},
1151+
"discriminator": {"propertyName": "kind"},
1152+
"xml": {"name": "Pet"},
1153+
"example": {"kind": "cat"},
1154+
}
1155+
1156+
validator_class = validator_for(schema)
1157+
validator = validator_class(
1158+
schema, format_checker=oas31_format_checker
1159+
)
1160+
1161+
result = validator.validate({"kind": "cat"})
1162+
1163+
assert result is None
1164+
1165+
def test_draft_2020_12_discovery_is_unchanged(self):
1166+
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"}
1167+
1168+
validator_class = validator_for(schema)
1169+
1170+
assert validator_class is Draft202012Validator
1171+
1172+
def test_openapi_dialect_registration_is_idempotent(self):
1173+
register_openapi_dialect(
1174+
validator=OAS31Validator,
1175+
dialect_id=OAS31_BASE_DIALECT_ID,
1176+
version_name="oas31",
1177+
metaschema=OAS31_BASE_DIALECT_METASCHEMA,
1178+
)
1179+
register_openapi_dialect(
1180+
validator=OAS31Validator,
1181+
dialect_id=OAS31_BASE_DIALECT_ID,
1182+
version_name="oas31",
1183+
metaschema=OAS31_BASE_DIALECT_METASCHEMA,
1184+
)
1185+
1186+
validator_class = validator_for({"$schema": OAS31_BASE_DIALECT_ID})
1187+
1188+
assert validator_class is OAS31Validator
1189+
1190+
def test_openapi_dialect_registration_does_not_replace_validator(self):
1191+
another_oas31_validator = extend(OAS31Validator, {})
1192+
1193+
registered_validator = register_openapi_dialect(
1194+
validator=another_oas31_validator,
1195+
dialect_id=OAS31_BASE_DIALECT_ID,
1196+
version_name="oas31",
1197+
metaschema=OAS31_BASE_DIALECT_METASCHEMA,
1198+
)
1199+
1200+
assert registered_validator is OAS31Validator
1201+
assert (
1202+
validator_for({"$schema": OAS31_BASE_DIALECT_ID}) is OAS31Validator
1203+
)

0 commit comments

Comments
 (0)