Skip to content

Commit c89f975

Browse files
committed
OAS30 strict validator
1 parent 0b4a1f2 commit c89f975

File tree

7 files changed

+197
-13
lines changed

7 files changed

+197
-13
lines changed

README.rst

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,57 @@ By default, the latest OpenAPI schema syntax is expected.
101101

102102
For more details read about `Validation <https://openapi-schema-validator.readthedocs.io/en/latest/validation.html>`__.
103103

104+
105+
Strict vs Pragmatic Validators
106+
=============================
107+
108+
OpenAPI 3.0 has two validator variants with different behaviors for binary format:
109+
110+
**OAS30Validator (default - pragmatic)**
111+
- Accepts Python ``bytes`` for ``type: string`` with ``format: binary``
112+
- More lenient for Python use cases where binary data is common
113+
- Use when validating Python objects directly
114+
115+
**OAS30StrictValidator**
116+
- Follows OAS spec strictly: only accepts ``str`` for ``type: string``
117+
- For ``format: binary``, only accepts base64-encoded strings
118+
- Use when strict spec compliance is required
119+
120+
Comparison Matrix
121+
----------------
122+
123+
+----------------------+---------------------------+---------------------------+
124+
| Schema | OAS30Validator (default) | OAS30StrictValidator |
125+
+======================+===========================+===========================+
126+
| ``type: string`` | | |
127+
+----------------------+---------------------------+---------------------------+
128+
| ``"test"`` | Pass | Pass |
129+
+----------------------+---------------------------+---------------------------+
130+
| ``b"test"`` | **Fail** | **Fail** |
131+
+----------------------+---------------------------+---------------------------+
132+
| ``type: string`` | | |
133+
| ``format: binary`` | | |
134+
+----------------------+---------------------------+---------------------------+
135+
| ``b"test"`` | Pass | **Fail** |
136+
+----------------------+---------------------------+---------------------------+
137+
| ``"dGVzdA=="`` | Pass (valid base64) | Pass (valid base64) |
138+
+----------------------+---------------------------+---------------------------+
139+
| ``"test"`` | Pass | **Fail** (not base64) |
140+
+----------------------+---------------------------+---------------------------+
141+
142+
.. code-block:: python
143+
144+
from openapi_schema_validator import OAS30Validator, OAS30StrictValidator
145+
146+
# Pragmatic (default) - accepts bytes for binary format
147+
validator = OAS30Validator({"type": "string", "format": "binary"})
148+
validator.validate(b"binary data") # passes
149+
150+
# Strict - follows spec precisely
151+
validator = OAS30StrictValidator({"type": "string", "format": "binary"})
152+
validator.validate(b"binary data") # raises ValidationError
153+
154+
104155
Related projects
105156
################
106157
* `openapi-core <https://github.com/python-openapi/openapi-core>`__

openapi_schema_validator/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from openapi_schema_validator._format import oas30_format_checker
2+
from openapi_schema_validator._format import oas30_strict_format_checker
23
from openapi_schema_validator._format import oas31_format_checker
34
from openapi_schema_validator.shortcuts import validate
45
from openapi_schema_validator.validators import OAS30ReadValidator
6+
from openapi_schema_validator.validators import OAS30StrictValidator
57
from openapi_schema_validator.validators import OAS30Validator
68
from openapi_schema_validator.validators import OAS30WriteValidator
79
from openapi_schema_validator.validators import OAS31Validator
@@ -15,9 +17,11 @@
1517
__all__ = [
1618
"validate",
1719
"OAS30ReadValidator",
20+
"OAS30StrictValidator",
1821
"OAS30WriteValidator",
1922
"OAS30Validator",
2023
"oas30_format_checker",
24+
"oas30_strict_format_checker",
2125
"OAS31Validator",
2226
"oas31_format_checker",
2327
]

openapi_schema_validator/_format.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,23 @@ def is_double(instance: object) -> bool:
4444
return isinstance(instance, float)
4545

4646

47-
def is_binary(instance: object) -> bool:
48-
if not isinstance(instance, (str, bytes)):
49-
return True
50-
if isinstance(instance, str):
47+
def is_binary_strict(instance: object) -> bool:
48+
# Strict: only accepts base64-encoded strings, not raw bytes
49+
if isinstance(instance, bytes):
5150
return False
51+
if isinstance(instance, str):
52+
try:
53+
b64decode(instance)
54+
return True
55+
except Exception:
56+
return False
57+
return True
58+
59+
60+
def is_binary_pragmatic(instance: object) -> bool:
61+
# Pragmatic: accepts bytes (common in Python) or base64-encoded strings
62+
if isinstance(instance, (str, bytes)):
63+
return True
5264
return True
5365

5466

@@ -72,10 +84,21 @@ def is_password(instance: object) -> bool:
7284
oas30_format_checker.checks("int64")(is_int64)
7385
oas30_format_checker.checks("float")(is_float)
7486
oas30_format_checker.checks("double")(is_double)
75-
oas30_format_checker.checks("binary")(is_binary)
87+
oas30_format_checker.checks("binary")(is_binary_pragmatic)
7688
oas30_format_checker.checks("byte", (binascii.Error, TypeError))(is_byte)
7789
oas30_format_checker.checks("password")(is_password)
7890

91+
oas30_strict_format_checker = FormatChecker()
92+
oas30_strict_format_checker.checks("int32")(is_int32)
93+
oas30_strict_format_checker.checks("int64")(is_int64)
94+
oas30_strict_format_checker.checks("float")(is_float)
95+
oas30_strict_format_checker.checks("double")(is_double)
96+
oas30_strict_format_checker.checks("binary")(is_binary_strict)
97+
oas30_strict_format_checker.checks("byte", (binascii.Error, TypeError))(
98+
is_byte
99+
)
100+
oas30_strict_format_checker.checks("password")(is_password)
101+
79102
oas31_format_checker = FormatChecker()
80103
oas31_format_checker.checks("int32")(is_int32)
81104
oas31_format_checker.checks("int64")(is_int64)

openapi_schema_validator/_keywords.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def type(
115115
instance: Any,
116116
schema: Mapping[str, Any],
117117
) -> Iterator[ValidationError]:
118+
"""Default type validator - allows Python bytes for binary format for pragmatic reasons."""
118119
if instance is None:
119120
# nullable implementation based on OAS 3.0.3
120121
# * nullable is only meaningful if its value is true
@@ -125,6 +126,34 @@ def type(
125126
return
126127
yield ValidationError("None for not nullable")
127128

129+
# Pragmatic: allow bytes for binary format (common in Python use cases)
130+
if (
131+
data_type == "string"
132+
and schema.get("format") == "binary"
133+
and isinstance(instance, bytes)
134+
):
135+
return
136+
137+
if not validator.is_type(instance, data_type):
138+
data_repr = repr(data_type)
139+
yield ValidationError(f"{instance!r} is not of type {data_repr}")
140+
141+
142+
def strict_type(
143+
validator: Any,
144+
data_type: str,
145+
instance: Any,
146+
schema: Any,
147+
) -> Any:
148+
"""
149+
Strict type validator - follows OAS spec precisely.
150+
Does NOT allow Python bytes for binary format.
151+
"""
152+
if instance is None:
153+
if schema.get("nullable") is True:
154+
return
155+
yield ValidationError("None for not nullable")
156+
128157
if not validator.is_type(instance, data_type):
129158
data_repr = repr(data_type)
130159
yield ValidationError(f"{instance!r} is not of type {data_repr}")

openapi_schema_validator/_types.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111

1212

1313
def is_string(checker: Any, instance: Any) -> bool:
14-
return isinstance(instance, (str, bytes))
14+
# Both strict and pragmatic: only accepts str for plain string type
15+
return isinstance(instance, str)
1516

1617

1718
oas30_type_checker = TypeChecker(
@@ -27,4 +28,5 @@ def is_string(checker: Any, instance: Any) -> bool:
2728
},
2829
),
2930
)
31+
3032
oas31_type_checker = draft202012_type_checker

openapi_schema_validator/validators.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from jsonschema import _keywords
55
from jsonschema import _legacy_keywords
6+
from jsonschema.exceptions import ValidationError
67
from jsonschema.validators import Draft202012Validator
78
from jsonschema.validators import create
89
from jsonschema.validators import extend
@@ -13,6 +14,13 @@
1314
from openapi_schema_validator import _types as oas_types
1415
from openapi_schema_validator._types import oas31_type_checker
1516

17+
18+
def _oas30_id_of(schema: Any) -> str:
19+
if isinstance(schema, dict):
20+
return schema.get("id", "") # type: ignore[no-any-return]
21+
return ""
22+
23+
1624
OAS30_VALIDATORS = cast(
1725
Any,
1826
{
@@ -55,6 +63,11 @@
5563
},
5664
)
5765

66+
67+
OAS30_VALIDATORS_STRICT = dict(OAS30_VALIDATORS)
68+
OAS30_VALIDATORS_STRICT["type"] = oas_keywords.strict_type
69+
70+
5871
OAS30Validator = create(
5972
meta_schema=SPECIFICATIONS.contents(
6073
"http://json-schema.org/draft-04/schema#",
@@ -65,11 +78,23 @@
6578
# NOTE: version causes conflict with global jsonschema validator
6679
# See https://github.com/python-openapi/openapi-schema-validator/pull/12
6780
# version="oas30",
68-
id_of=lambda schema: (
69-
schema.get("id", "") if isinstance(schema, dict) else ""
70-
),
81+
id_of=_oas30_id_of,
7182
)
7283

84+
85+
OAS30StrictValidator = extend(
86+
OAS30Validator,
87+
validators={
88+
"type": oas_keywords.strict_type,
89+
},
90+
type_checker=oas_types.oas30_type_checker,
91+
format_checker=oas_format.oas30_strict_format_checker,
92+
# NOTE: version causes conflict with global jsonschema validator
93+
# See https://github.com/python-openapi/openapi-schema-validator/pull/12
94+
# version="oas30-strict",
95+
)
96+
97+
7398
OAS30ReadValidator = extend(
7499
OAS30Validator,
75100
validators={

tests/integration/test_validators.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
from referencing.jsonschema import DRAFT202012
1616

1717
from openapi_schema_validator import OAS30ReadValidator
18+
from openapi_schema_validator import OAS30StrictValidator
1819
from openapi_schema_validator import OAS30Validator
1920
from openapi_schema_validator import OAS30WriteValidator
2021
from openapi_schema_validator import OAS31Validator
2122
from openapi_schema_validator import oas30_format_checker
23+
from openapi_schema_validator import oas30_strict_format_checker
2224
from openapi_schema_validator import oas31_format_checker
2325

2426

@@ -187,7 +189,6 @@ def test_oas30_formats_ignored(
187189

188190
assert result is None
189191

190-
@pytest.mark.xfail(reason="OAS 3.0 string type checker allows byte")
191192
@pytest.mark.parametrize("value", [b"test"])
192193
def test_string_disallow_binary(self, validator_class, value):
193194
schema = {"type": "string"}
@@ -205,7 +206,7 @@ def test_string_binary_valid(self, validator_class, format_checker, value):
205206

206207
assert result is None
207208

208-
@pytest.mark.parametrize("value", ["test", True, 3, 3.12, None])
209+
@pytest.mark.parametrize("value", [True, 3, 3.12, None])
209210
def test_string_binary_invalid(
210211
self, validator_class, format_checker, value
211212
):
@@ -282,7 +283,6 @@ def test_nullable_enum_with_none(self, validator_class):
282283
@pytest.mark.parametrize(
283284
"value",
284285
[
285-
b64encode(b"string"),
286286
b64encode(b"string").decode(),
287287
],
288288
)
@@ -296,7 +296,7 @@ def test_string_format_byte_valid(self, validator_class, value):
296296

297297
assert result is None
298298

299-
@pytest.mark.parametrize("value", ["string", b"string"])
299+
@pytest.mark.parametrize("value", ["string"])
300300
def test_string_format_byte_invalid(self, validator_class, value):
301301
schema = {"type": "string", "format": "byte"}
302302
validator = validator_class(
@@ -1001,3 +1001,53 @@ def test_array_prefixitems_invalid(self, validator_class, value):
10011001
"Expected at most 4 items but found 1 extra",
10021002
]
10031003
assert any(error in str(excinfo.value) for error in errors)
1004+
1005+
1006+
class TestOAS30StrictValidator:
1007+
"""
1008+
Tests for OAS30StrictValidator which follows OAS spec strictly:
1009+
- type: string only accepts str (not bytes)
1010+
- format: binary also only accepts str (no special bytes handling)
1011+
"""
1012+
1013+
def test_strict_string_rejects_bytes(self):
1014+
"""Strict validator rejects bytes for plain string type."""
1015+
schema = {"type": "string"}
1016+
validator = OAS30StrictValidator(schema)
1017+
1018+
with pytest.raises(ValidationError):
1019+
validator.validate(b"test")
1020+
1021+
def test_strict_string_accepts_str(self):
1022+
"""Strict validator accepts str for string type."""
1023+
schema = {"type": "string"}
1024+
validator = OAS30StrictValidator(schema)
1025+
1026+
result = validator.validate("test")
1027+
assert result is None
1028+
1029+
def test_strict_binary_format_rejects_bytes(self):
1030+
"""Strict validator rejects bytes even with binary format."""
1031+
schema = {"type": "string", "format": "binary"}
1032+
validator = OAS30StrictValidator(
1033+
schema, format_checker=oas30_format_checker
1034+
)
1035+
1036+
with pytest.raises(ValidationError):
1037+
validator.validate(b"test")
1038+
1039+
def test_strict_binary_format_rejects_str(self):
1040+
"""
1041+
Strict validator with binary format rejects strings.
1042+
Binary format is for bytes in OAS, not plain strings.
1043+
"""
1044+
schema = {"type": "string", "format": "binary"}
1045+
validator = OAS30StrictValidator(
1046+
schema, format_checker=oas30_strict_format_checker
1047+
)
1048+
1049+
# Binary format expects actual binary data (bytes in Python)
1050+
# Plain strings fail format validation because they are not valid base64
1051+
# Note: "test" is actually valid base64, so use "not base64" which is not
1052+
with pytest.raises(ValidationError, match="is not a 'binary'"):
1053+
validator.validate("not base64")

0 commit comments

Comments
 (0)