Skip to content

Commit 96ae4fe

Browse files
committed
Preserve registry IDs and avoid duplicate schema checks
1 parent bbe9b8e commit 96ae4fe

3 files changed

Lines changed: 146 additions & 17 deletions

File tree

‎openapi_schema_validator/_references.py‎

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def _discoverable_subresources(
8585

8686

8787
def _scoped_resource(
88-
contents: Any, *, id_already_applied: bool = True
88+
contents: Any, *, id_already_applied: bool
8989
) -> Resource[Any]:
9090
# Crawling a resource registered at its resolved URI must not apply
9191
# its relative id again. Ignore malformed metadata during discovery.
@@ -114,19 +114,27 @@ def id_of(schema: Any) -> str | None:
114114
return specification.create_resource(contents)
115115

116116

117-
def prepare_oas30_resolver(resolver: Any, scoped: Any, schema: Any) -> Any:
117+
def prepare_oas30_resolver(
118+
resolver: Any,
119+
scoped: Any,
120+
schema: Any,
121+
*,
122+
id_already_applied: bool = False,
123+
) -> Any:
118124
uri = resolver._base_uri
119125
resource = scoped._registry.get(uri)
126+
# Replacing a crawled entry would apply its id a second time.
120127
if (
121128
resource is None
122129
or resource.contents is not schema
123130
or resource._specification is not DRAFT4
131+
or uri not in scoped._registry._uncrawled
124132
):
125133
return scoped
126-
# A registry entry may be a retrieval alias for a different canonical id.
127-
identifier = _discoverable_id(schema).rstrip("#")
134+
# Existing resources may be registered at retrieval aliases, even if
135+
# the alias equals the raw relative id. Only owned scopes suppress it.
128136
registry = scoped._registry.with_resource(
129-
uri, _scoped_resource(schema, id_already_applied=uri == identifier)
137+
uri, _scoped_resource(schema, id_already_applied=id_already_applied)
130138
)
131139
return scoped._evolve(base_uri=scoped._base_uri, registry=registry)
132140

@@ -138,7 +146,9 @@ def _register_scope(resolver: Any, scoped: Any, contents: Any) -> Any:
138146

139147
# Register the reached scope so local lookups can use it without
140148
# discovering resources in ignored siblings.
141-
registry = scoped._registry.with_resource(uri, _scoped_resource(contents))
149+
registry = scoped._registry.with_resource(
150+
uri, _scoped_resource(contents, id_already_applied=True)
151+
)
142152
return scoped._evolve(base_uri=uri, registry=registry)
143153

144154

@@ -210,6 +220,30 @@ def declares_other_schema_dialect(schema: Any) -> bool:
210220
)
211221

212222

223+
def _oas30_schema_children(
224+
schema: Mapping[str, Any],
225+
) -> Iterator[Mapping[str, Any]]:
226+
for child in DRAFT4.subresources_of(schema):
227+
if isinstance(child, Mapping) and not declares_other_schema_dialect(
228+
child
229+
):
230+
yield child
231+
232+
233+
def _mark_checked_schemas(schema: Any, checked: set[int]) -> None:
234+
# The metaschema checks ordinary schema children recursively, but stops
235+
# at Reference Objects. Other dialects are outside the target check.
236+
pending = [schema]
237+
while pending:
238+
current = pending.pop()
239+
identity = id(current)
240+
if identity in checked:
241+
continue
242+
checked.add(identity)
243+
if isinstance(current, Mapping) and "$ref" not in current:
244+
pending.extend(_oas30_schema_children(current))
245+
246+
213247
def check_oas30_local_references(
214248
schema: Mapping[str, Any],
215249
check_schema: Callable[[Any], None],
@@ -223,11 +257,14 @@ def check_oas30_local_references(
223257
resolver = local_registry.resolver_with_root(
224258
DRAFT4.create_resource(schema)
225259
)
226-
resolver = prepare_oas30_resolver(resolver, resolver, schema)
260+
resolver = prepare_oas30_resolver(
261+
resolver, resolver, schema, id_already_applied=True
262+
)
227263
root_uri = resolver._base_uri
228264
pending = [(schema, resolver)]
229265
visited: set[tuple[int, str]] = set()
230-
checked = {id(schema)}
266+
checked: set[int] = set()
267+
_mark_checked_schemas(schema, checked)
231268

232269
while pending:
233270
current, resolver = pending.pop()
@@ -254,14 +291,12 @@ def check_oas30_local_references(
254291
continue
255292
if id(target.contents) not in checked:
256293
check_schema(target.contents)
257-
checked.add(id(target.contents))
294+
_mark_checked_schemas(target.contents, checked)
258295
pending.append((target.contents, target.resolver))
259296
else:
260297
pending.extend(
261298
(child, _enter_subresource(resolver, child))
262-
for child in DRAFT4.subresources_of(current)
263-
if isinstance(child, Mapping)
264-
and not declares_other_schema_dialect(child)
299+
for child in _oas30_schema_children(current)
265300
)
266301

267302

‎openapi_schema_validator/validators.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,13 +301,29 @@ def _evolve_oas30(validator: Any, **changes: Any) -> Any:
301301
return _jsonschema_evolve(validator, **changes)
302302

303303

304+
def _install_oas30_reference_hooks(validator_class: Any) -> None:
305+
initialize = validator_class.__attrs_post_init__
306+
307+
def initialize_oas30(validator: Any) -> None:
308+
creates_root = validator._resolver is None
309+
initialize(validator)
310+
if creates_root and validator._ref_resolver is None:
311+
resolver = validator._resolver
312+
validator._resolver = prepare_oas30_resolver(
313+
resolver, resolver, validator.schema, id_already_applied=True
314+
)
315+
316+
validator_class.__attrs_post_init__ = initialize_oas30
317+
validator_class.evolve = _evolve_oas30
318+
319+
304320
for _oas30_validator in (
305321
OAS30Validator,
306322
OAS30StrictValidator,
307323
OAS30ReadValidator,
308324
OAS30WriteValidator,
309325
):
310-
_oas30_validator.evolve = _evolve_oas30
326+
_install_oas30_reference_hooks(_oas30_validator)
311327

312328
OAS31Validator = _build_oas31_validator()
313329
OAS32Validator = _build_oas32_validator()
@@ -364,5 +380,5 @@ def enforce_properties(
364380
validator_class.check_schema.__func__
365381
)
366382
if validator_class.evolve is _evolve_oas30:
367-
extended_validator.evolve = _evolve_oas30
383+
_install_oas30_reference_hooks(extended_validator)
368384
return cast(type[Validator], extended_validator)

‎tests/unit/test_shortcut.py‎

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from openapi_schema_validator import OAS32Validator
1414
from openapi_schema_validator import validate
15+
from openapi_schema_validator import validators
1516
from openapi_schema_validator._regex import has_ecma_regex
1617
from openapi_schema_validator.settings import reset_settings_cache
1718
from openapi_schema_validator.shortcuts import clear_validate_cache
@@ -916,9 +917,19 @@ def test_oas30_named_anchor_ignores_malformed_siblings(aliased: bool) -> None:
916917
validate(2, schema, cls=OAS30Validator)
917918

918919

919-
def test_oas30_registry_discovery_preserves_canonical_id_of_alias() -> None:
920-
alias = "https://example.com/alias.json"
921-
canonical = "https://example.com/canonical.json"
920+
@pytest.mark.parametrize(
921+
("alias", "canonical"),
922+
[
923+
(
924+
"https://example.com/alias.json",
925+
"https://example.com/canonical.json",
926+
),
927+
("sub/nested.json", "sub/nested.json"),
928+
],
929+
)
930+
def test_oas30_registry_discovery_preserves_canonical_id_of_alias(
931+
alias: str, canonical: str
932+
) -> None:
922933
external = DRAFT4.create_resource(
923934
{
924935
"id": canonical,
@@ -931,3 +942,70 @@ def test_oas30_registry_discovery_preserves_canonical_id_of_alias() -> None:
931942

932943
validate({"name": "abc"}, schema, cls=OAS30Validator, registry=registry)
933944
OAS30Validator(schema, registry=registry).validate({"name": "abc"})
945+
946+
947+
def test_oas30_root_does_not_reapply_its_relative_id() -> None:
948+
def retrieve(uri: str) -> Resource[dict[str, str]]:
949+
assert uri == "sub/sub/root.json"
950+
return Resource.opaque({"type": "integer"})
951+
952+
schema = {
953+
"id": "sub/root.json",
954+
"type": "object",
955+
"properties": {"name": {"$ref": "sub/root.json"}},
956+
}
957+
registry = Registry(retrieve=retrieve)
958+
959+
validate({"name": 2}, schema, cls=OAS30Validator, registry=registry)
960+
OAS30Validator(schema, registry=registry).validate({"name": 2})
961+
962+
963+
def test_oas30_does_not_recrawl_caller_canonical_resource() -> None:
964+
def retrieve(uri: str) -> Resource[dict[str, str]]:
965+
assert uri == "sub/sub/nested.json"
966+
return Resource.opaque({"type": "integer"})
967+
968+
external = DRAFT4.create_resource(
969+
{
970+
"id": "sub/nested.json",
971+
"type": "object",
972+
"properties": {"name": {"$ref": "sub/nested.json"}},
973+
}
974+
)
975+
registry = Registry(retrieve=retrieve).with_resource(
976+
"alias.json", external
977+
)
978+
registry = registry.crawl()
979+
schema = {"$ref": "sub/nested.json"}
980+
981+
validate({"name": 2}, schema, cls=OAS30Validator, registry=registry)
982+
OAS30Validator(schema, registry=registry).validate({"name": 2})
983+
984+
985+
def test_oas30_preflight_checks_only_uncovered_reference_targets() -> None:
986+
hidden_pointer = "#/definitions/Ref/x-hidden"
987+
hidden = {
988+
"properties": {
989+
"name": {"type": "string"},
990+
"alias": {"$ref": f"{hidden_pointer}/properties/name"},
991+
},
992+
}
993+
schema = {
994+
"definitions": {
995+
"Known": {"properties": {"name": {"type": "string"}}},
996+
"Ref": {"$ref": hidden_pointer, "x-hidden": hidden},
997+
},
998+
"properties": {
999+
"outer": {"$ref": "#/definitions/Known"},
1000+
"inner": {"$ref": "#/definitions/Known/properties/name"},
1001+
},
1002+
}
1003+
1004+
with patch.object(
1005+
validators,
1006+
"_raise_first_schema_error",
1007+
wraps=validators._raise_first_schema_error,
1008+
) as check:
1009+
OAS30Validator.check_schema(schema)
1010+
1011+
assert [call.args[1] for call in check.call_args_list] == [schema, hidden]

0 commit comments

Comments
 (0)