Skip to content

Commit 7322036

Browse files
committed
Check local reference targets in OAS 3.0 schema checks
Reference siblings are ignored by the OAS 3.0 schema check, so schemas reachable only through a local reference (next to a root $ref, or under a non-schema key such as components) were no longer checked. Follow reachable JSON pointer references from the root and check targets the structural check did not cover. References that depend on runtime resolution context are left to instance validation: external and anchor references, references inside a subschema with its own id, targets declaring another dialect, and shortcut calls with a legacy resolver or a root $schema.
1 parent 17e14d7 commit 7322036

5 files changed

Lines changed: 464 additions & 3 deletions

File tree

‎docs/validation.rst‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ In order to validate OpenAPI 3.0 schema, import and use ``OAS30Validator`` inste
161161
162162
validate({"name": "John", "age": None}, schema, cls=OAS30Validator)
163163
164+
In OpenAPI 3.0 an object with ``$ref`` is a Reference Object, and any other
165+
keys next to ``$ref`` are ignored, both when checking the schema and when
166+
validating instances. A ``$schema`` next to ``$ref`` does not switch the
167+
dialect either. Schema checks still follow local JSON pointer references
168+
(``#/...``) and check the schemas they point to, wherever those are stored in
169+
the document.
170+
164171
Default dialect resolution
165172
--------------------------
166173

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Local reference targets for OpenAPI 3.0 schema checks.
2+
3+
In OpenAPI 3.0 an object with ``$ref`` is a Reference Object and its other
4+
keys are ignored, so the structural schema check does not descend into
5+
them. A local reference can still point at a schema the structural check
6+
never visited (for example a key next to a root ``$ref``, or a non-schema
7+
key such as ``components``). This module finds those targets so they can
8+
be checked with the same metaschema.
9+
10+
Only JSON pointer references (``#`` and ``#/...``) resolved against the
11+
root document are followed. References that depend on runtime resolution
12+
context are left to instance validation: external and anchor references,
13+
references inside a subschema that sets its own ``id``, and schemas that
14+
declare a different dialect with ``$schema``.
15+
"""
16+
17+
from typing import Any
18+
from typing import Iterator
19+
from typing import Mapping
20+
from urllib.parse import unquote
21+
22+
from jsonschema.validators import validator_for
23+
24+
# Draft 4 keywords whose values are a schema, a list of schemas, or a
25+
# mapping of schemas.
26+
_SCHEMA_KEYWORDS = ("additionalItems", "additionalProperties", "items", "not")
27+
_SCHEMA_LIST_KEYWORDS = ("allOf", "anyOf", "items", "oneOf")
28+
_SCHEMA_MAP_KEYWORDS = ("dependencies", "patternProperties", "properties")
29+
30+
31+
def declares_other_dialect(schema: Any) -> bool:
32+
"""Whether ``schema`` switches jsonschema to another validator class.
33+
34+
A ``$schema`` next to a ``$ref`` is an ignored sibling and does not
35+
count.
36+
"""
37+
return (
38+
isinstance(schema, Mapping)
39+
and "$ref" not in schema
40+
and isinstance(schema.get("$schema"), str)
41+
and validator_for(schema, default=None) is not None
42+
)
43+
44+
45+
def _children(
46+
schema: Mapping[str, Any], *, include_definitions: bool
47+
) -> Iterator[Mapping[str, Any]]:
48+
for keyword in _SCHEMA_KEYWORDS:
49+
value = schema.get(keyword)
50+
if isinstance(value, Mapping):
51+
yield value
52+
for keyword in _SCHEMA_LIST_KEYWORDS:
53+
value = schema.get(keyword)
54+
if isinstance(value, list):
55+
yield from (item for item in value if isinstance(item, Mapping))
56+
map_keywords: tuple[str, ...] = _SCHEMA_MAP_KEYWORDS
57+
if include_definitions:
58+
map_keywords = (*map_keywords, "definitions")
59+
for keyword in map_keywords:
60+
value = schema.get(keyword)
61+
if isinstance(value, Mapping):
62+
yield from (
63+
item for item in value.values() if isinstance(item, Mapping)
64+
)
65+
66+
67+
def _mark_covered(schema: Mapping[str, Any], covered: set[int]) -> None:
68+
# Mirror what the structural check visits: every subschema, including
69+
# definitions, but nothing inside a Reference Object.
70+
pending = [schema]
71+
while pending:
72+
current = pending.pop()
73+
if id(current) in covered:
74+
continue
75+
covered.add(id(current))
76+
if "$ref" not in current:
77+
pending.extend(_children(current, include_definitions=True))
78+
79+
80+
def _sets_scope(schema: Any) -> bool:
81+
return (
82+
isinstance(schema, Mapping)
83+
and "$ref" not in schema
84+
and isinstance(schema.get("id"), str)
85+
)
86+
87+
88+
def _resolve_pointer(
89+
document: Mapping[str, Any], ref: str
90+
) -> tuple[Any, bool]:
91+
"""Return the pointer target and whether an ``id`` on the way to it
92+
changes its resolution scope."""
93+
target: Any = document
94+
scoped = False
95+
if ref == "#":
96+
return target, scoped
97+
for token in unquote(ref[2:]).split("/"):
98+
token = token.replace("~1", "/").replace("~0", "~")
99+
if isinstance(target, list):
100+
target = target[int(token)]
101+
else:
102+
target = target[token]
103+
scoped = scoped or _sets_scope(target)
104+
return target, scoped
105+
106+
107+
def iter_unchecked_reference_targets(
108+
schema: Mapping[str, Any],
109+
) -> Iterator[Mapping[str, Any]]:
110+
"""Yield reachable local reference targets the structural check missed.
111+
112+
Each target is yielded once. Targets inside a yielded target are
113+
treated as covered by that target's check.
114+
"""
115+
covered: set[int] = set()
116+
_mark_covered(schema, covered)
117+
118+
pending: list[tuple[Mapping[str, Any], bool]] = [(schema, False)]
119+
visited: set[tuple[int, bool]] = set()
120+
while pending:
121+
current, scoped = pending.pop()
122+
key = (id(current), scoped)
123+
if key in visited:
124+
continue
125+
visited.add(key)
126+
127+
if "$ref" not in current:
128+
if current is not schema and declares_other_dialect(current):
129+
continue
130+
# Definitions become reachable through references only.
131+
for child in _children(current, include_definitions=False):
132+
pending.append((child, scoped or _sets_scope(child)))
133+
continue
134+
135+
ref = current["$ref"]
136+
if scoped or not isinstance(ref, str):
137+
continue
138+
if ref != "#" and not ref.startswith("#/"):
139+
continue
140+
try:
141+
target, target_scoped = _resolve_pointer(schema, ref)
142+
except (LookupError, TypeError, ValueError):
143+
# Unresolvable references are reported during validation.
144+
continue
145+
if not isinstance(target, Mapping) or declares_other_dialect(target):
146+
continue
147+
if id(target) not in covered:
148+
_mark_covered(target, covered)
149+
yield target
150+
pending.append((target, target_scoped))

‎openapi_schema_validator/shortcuts.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
from openapi_schema_validator._caches import ValidatorCache
1212
from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_ID
1313
from openapi_schema_validator._dialects import OAS32_BASE_DIALECT_ID
14+
from openapi_schema_validator._references import declares_other_dialect
1415
from openapi_schema_validator.validators import OAS32Validator
1516
from openapi_schema_validator.validators import (
1617
build_enforce_properties_required_validator,
1718
)
1819
from openapi_schema_validator.validators import check_openapi_schema
20+
from openapi_schema_validator.validators import supports_reference_check
1921

2022
_LOCAL_ONLY_REGISTRY = Registry()
2123
_VALIDATOR_CACHE = ValidatorCache()
@@ -24,6 +26,8 @@
2426
def _check_schema(
2527
cls: type[Validator],
2628
schema: dict[str, Any],
29+
*,
30+
legacy_resolver: bool,
2731
) -> None:
2832
meta_schema = getattr(cls, "META_SCHEMA", None)
2933
# jsonschema's default check_schema path does not accept a custom
@@ -34,6 +38,14 @@ def _check_schema(
3438
OAS32_BASE_DIALECT_ID,
3539
):
3640
check_openapi_schema(cls, schema)
41+
elif supports_reference_check(cls):
42+
# Local references are resolved against ``schema`` only when it is
43+
# the reference root: a legacy resolver can carry another document,
44+
# and a root ``$schema`` hands validation to that dialect.
45+
check_references = not (
46+
legacy_resolver or declares_other_dialect(schema)
47+
)
48+
cls.check_schema(schema, check_references=check_references)
3749
else:
3850
cls.check_schema(schema)
3951

@@ -92,6 +104,10 @@ def validate(
92104
if not allow_remote_references:
93105
validator_kwargs.setdefault("registry", _LOCAL_ONLY_REGISTRY)
94106

107+
legacy_resolver = validator_kwargs.get("resolver") is not None or bool(
108+
args and args[0] is not None
109+
)
110+
95111
key = _VALIDATOR_CACHE.build_key(
96112
schema=schema_dict,
97113
cls=cls,
@@ -104,7 +120,7 @@ def validate(
104120

105121
if cached is None:
106122
if check_schema:
107-
_check_schema(cls, schema_dict)
123+
_check_schema(cls, schema_dict, legacy_resolver=legacy_resolver)
108124

109125
validator = cls(schema_dict, *args, **validator_kwargs)
110126
cached = _VALIDATOR_CACHE.set(
@@ -113,7 +129,7 @@ def validate(
113129
schema_checked=check_schema,
114130
)
115131
elif check_schema and not cached.schema_checked:
116-
_check_schema(cls, schema_dict)
132+
_check_schema(cls, schema_dict, legacy_resolver=legacy_resolver)
117133
_VALIDATOR_CACHE.mark_schema_checked(key)
118134
else:
119135
_VALIDATOR_CACHE.touch(key)

‎openapi_schema_validator/validators.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
from openapi_schema_validator._dialects import OAS32_BASE_DIALECT_ID
2525
from openapi_schema_validator._dialects import OAS32_BASE_DIALECT_METASCHEMA
2626
from openapi_schema_validator._dialects import register_openapi_dialect
27+
from openapi_schema_validator._references import (
28+
iter_unchecked_reference_targets,
29+
)
2730
from openapi_schema_validator._specifications import (
2831
REGISTRY as OPENAPI_SPECIFICATIONS,
2932
)
@@ -73,6 +76,8 @@ def _check_oas30_schema(
7376
cls: Any,
7477
schema: Any,
7578
format_checker: Any = _CHECK_SCHEMA_UNSET,
79+
*,
80+
check_references: bool = True,
7681
) -> None:
7782
if format_checker is _CHECK_SCHEMA_UNSET:
7883
format_checker = cls.FORMAT_CHECKER
@@ -83,15 +88,31 @@ def _check_oas30_schema(
8388
registry=OPENAPI_SPECIFICATIONS,
8489
)
8590
_raise_first_schema_error(validator, schema)
91+
if not check_references or not isinstance(schema, Mapping):
92+
return
93+
# Reference siblings are ignored above, so check local reference
94+
# targets the structural check did not reach.
95+
for target in iter_unchecked_reference_targets(schema):
96+
_raise_first_schema_error(validator, target)
8697

8798

8899
def _check_oas30_variant_schema(
89100
cls: Any,
90101
schema: Any,
91102
format_checker: Any = Draft4Validator.FORMAT_CHECKER,
103+
*,
104+
check_references: bool = True,
92105
) -> None:
93106
# These variants previously inherited jsonschema's Draft 4 format checks.
94-
_check_oas30_schema(cls, schema, format_checker)
107+
_check_oas30_schema(
108+
cls, schema, format_checker, check_references=check_references
109+
)
110+
111+
112+
def supports_reference_check(cls: Any) -> bool:
113+
"""Whether ``cls.check_schema`` accepts ``check_references``."""
114+
check = getattr(cls.check_schema, "__func__", None)
115+
return check in (_check_oas30_schema, _check_oas30_variant_schema)
95116

96117

97118
def _raise_first_schema_error(validator: Validator, schema: Any) -> None:

0 commit comments

Comments
 (0)