44from typing import Mapping
55from typing import Sequence
66from typing import cast
7+ from urllib .parse import urldefrag
8+ from urllib .parse import urljoin
79from urllib .parse import urlsplit
810
911from jsonschema .exceptions import SchemaError
@@ -33,19 +35,26 @@ def _checked_oas30_scope_id(contents: Any) -> str | None:
3335 return DRAFT4 .id_of (contents )
3436
3537
36- _SCOPED_DRAFT4_NAME = "openapi-3.0-scoped-draft4"
3738_SCHEMA_MAP_KEYS = {
3839 "definitions" ,
3940 "dependencies" ,
4041 "patternProperties" ,
4142 "properties" ,
4243}
4344_SCHEMA_ARRAY_KEYS = {"allOf" , "anyOf" , "oneOf" }
45+ _OAS30_APPLICATORS = _SCHEMA_ARRAY_KEYS | {
46+ "additionalProperties" ,
47+ "items" ,
48+ "not" ,
49+ "properties" ,
50+ }
4451
4552
4653def _uses_draft4 (resource : Resource [Any ]) -> bool :
4754 specification = resource ._specification
48- return specification is DRAFT4 or specification .name == _SCOPED_DRAFT4_NAME
55+ return specification is DRAFT4 or isinstance (
56+ specification , _OAS30DiscoverySpecification
57+ )
4958
5059
5160def _discoverable_id (contents : Any ) -> str :
@@ -73,43 +82,68 @@ def _discoverable_subresources(
7382 and (key not in _SCHEMA_ARRAY_KEYS or isinstance (value , (list , tuple )))
7483 }
7584 for child in DRAFT4 .subresources_of (shaped ):
76- if not isinstance (child , Mapping ):
77- continue
78- if "$schema" in child :
79- if "$ref" in child :
80- child = dict (child )
81- del child ["$schema" ]
82- elif not isinstance (child ["$schema" ], str ):
83- continue
84- yield child
85+ if isinstance (child , Mapping ) and (
86+ "$ref" in child
87+ or "$schema" not in child
88+ or isinstance (child ["$schema" ], str )
89+ ):
90+ yield child
8591
8692
87- def _scoped_resource (
88- contents : Any , * , id_already_applied : bool
89- ) -> Resource [Any ]:
90- # Crawling a resource registered at its resolved URI must not apply
91- # its relative id again. Ignore malformed metadata during discovery.
92- def id_of (schema : Any ) -> str | None :
93+ class _ForeignDialectDiscovery (Exception ):
94+ """Preflight cannot establish scope through another dialect's crawl."""
95+
96+
97+ class _OAS30DiscoverySpecification :
98+ """Keep Draft 4 discovery guarded without changing schema contents."""
99+
100+ name = "openapi-3.0-discovery"
101+ subresources_of = staticmethod (_discoverable_subresources )
102+ maybe_in_subresource = staticmethod (DRAFT4 .maybe_in_subresource )
103+
104+ def __init__ (
105+ self , contents : Any , id_already_applied : bool , preflight : bool
106+ ) -> None :
107+ self ._scoped_contents = contents if id_already_applied else None
108+ self .preflight = preflight
109+
110+ def id_of (self , schema : Any ) -> str | None :
93111 identifier = _discoverable_id (schema )
94112 if (
95- ( id_already_applied and schema is contents )
113+ schema is self . _scoped_contents
96114 or not identifier
97115 or identifier .startswith ("#" )
98116 or "$ref" in schema
99117 ):
100118 return None
101119 return identifier
102120
103- specification : Specification [Any ] = cast (Any , Specification )(
104- name = _SCOPED_DRAFT4_NAME ,
105- id_of = id_of ,
106- subresources_of = _discoverable_subresources ,
107- anchors_in = lambda _ , schema : (
108- DRAFT4 .anchors_in (schema )
109- if _discoverable_id (schema ).startswith ("#" )
110- else ()
111- ),
112- maybe_in_subresource = DRAFT4 .maybe_in_subresource ,
121+ def anchors_in (self , schema : Any ) -> Any :
122+ if _discoverable_id (schema ).startswith ("#" ):
123+ return DRAFT4 .anchors_in (schema )
124+ return ()
125+
126+ def detect (self , contents : Mapping [str , Any ]) -> Any :
127+ if "$ref" in contents :
128+ return self
129+ detected = DRAFT4 .detect (contents )
130+ if detected is DRAFT4 :
131+ return self
132+ if self .preflight :
133+ raise _ForeignDialectDiscovery
134+ return detected
135+
136+ def create_resource (self , contents : Any ) -> Resource [Any ]:
137+ # Resource.from_contents calls detect() and then create_resource().
138+ create_resource = cast (Callable [[Any , Any ], Resource [Any ]], Resource )
139+ return create_resource (contents , self )
140+
141+
142+ def _scoped_resource (
143+ contents : Any , * , id_already_applied : bool , preflight : bool = False
144+ ) -> Resource [Any ]:
145+ specification = _OAS30DiscoverySpecification (
146+ contents , id_already_applied , preflight
113147 )
114148 return specification .create_resource (contents )
115149
@@ -120,6 +154,7 @@ def prepare_oas30_resolver(
120154 schema : Any ,
121155 * ,
122156 id_already_applied : bool = False ,
157+ preflight : bool = False ,
123158) -> Any :
124159 uri = resolver ._base_uri
125160 resource = scoped ._registry .get (uri )
@@ -134,7 +169,10 @@ def prepare_oas30_resolver(
134169 # Existing resources may be registered at retrieval aliases, even if
135170 # the alias equals the raw relative id. Only owned scopes suppress it.
136171 registry = scoped ._registry .with_resource (
137- uri , _scoped_resource (schema , id_already_applied = id_already_applied )
172+ uri ,
173+ _scoped_resource (
174+ schema , id_already_applied = id_already_applied , preflight = preflight
175+ ),
138176 )
139177 return scoped ._evolve (base_uri = scoped ._base_uri , registry = registry )
140178
@@ -146,8 +184,17 @@ def _register_scope(resolver: Any, scoped: Any, contents: Any) -> Any:
146184
147185 # Register the reached scope so local lookups can use it without
148186 # discovering resources in ignored siblings.
187+ parent = resolver ._registry .get (resolver ._base_uri )
188+ specification = parent ._specification if parent is not None else None
189+ preflight = (
190+ isinstance (specification , _OAS30DiscoverySpecification )
191+ and specification .preflight
192+ )
149193 registry = scoped ._registry .with_resource (
150- uri , _scoped_resource (contents , id_already_applied = True )
194+ uri ,
195+ _scoped_resource (
196+ contents , id_already_applied = True , preflight = preflight
197+ ),
151198 )
152199 return scoped ._evolve (base_uri = uri , registry = registry )
153200
@@ -199,7 +246,10 @@ def _enter_subresource(resolver: Any, schema: Mapping[str, Any]) -> Any:
199246)
200247
201248
202- def _resolve_pointer (resolver : Any , ref : str ) -> Any :
249+ def _resolve_reference (resolver : Any , ref : str ) -> Any :
250+ if not ref .startswith ("#/" ):
251+ return resolver .lookup (ref )
252+
203253 document = resolver .lookup ("#" )
204254 resource = document .resolver ._registry [document .resolver ._base_uri ]
205255 if _uses_draft4 (resource ):
@@ -209,6 +259,40 @@ def _resolve_pointer(resolver: Any, ref: str) -> Any:
209259 return resource .pointer (ref [1 :], document .resolver )
210260
211261
262+ def _preflight_discovery (
263+ resolver : Any ,
264+ ref : str ,
265+ cache : dict [int , tuple [Registry [Any ], Registry [Any ] | None ]],
266+ ) -> Any :
267+ registry = resolver ._registry
268+ if ref .startswith ("#/" ) or not registry ._uncrawled :
269+ return resolver
270+
271+ base_uri = resolver ._base_uri
272+ if ref .startswith ("#" ):
273+ uri , fragment = base_uri , ref [1 :]
274+ else :
275+ uri , fragment = urldefrag (urljoin (base_uri , ref ))
276+ if uri in registry and (
277+ not fragment
278+ or fragment .startswith ("/" )
279+ or (uri , fragment ) in registry ._anchors
280+ ):
281+ return resolver
282+
283+ # An unresolved lookup drops its crawled registry. Retain discovery so
284+ # multiple unresolved refs do not each scan the same document.
285+ identity = id (registry )
286+ if identity not in cache :
287+ try :
288+ crawled = registry .crawl ()
289+ except _ForeignDialectDiscovery :
290+ crawled = None
291+ cache [identity ] = (registry , crawled )
292+ _ , crawled = cache [identity ]
293+ return None if crawled is None else crawled .resolver (base_uri )
294+
295+
212296def declares_other_schema_dialect (schema : Any ) -> bool :
213297 # OAS 3.0 validators are not registered with jsonschema. A recognized
214298 # dialect declaration changes the runtime class unless it is a ref sibling.
@@ -249,7 +333,7 @@ def check_oas30_local_references(
249333 check_schema : Callable [[Any ], None ],
250334 registry : Registry [Any ] | None ,
251335) -> None :
252- # A target can be stored under an ignored sibling. Follow local pointers
336+ # A target can be stored under an ignored sibling. Follow local refs
253337 # separately from structural validation, using the untouched document.
254338 # Use an isolated registry for the walk. The caller's registry is only
255339 # consulted for ownership of already-registered scope URIs.
@@ -258,46 +342,70 @@ def check_oas30_local_references(
258342 DRAFT4 .create_resource (schema )
259343 )
260344 resolver = prepare_oas30_resolver (
261- resolver , resolver , schema , id_already_applied = True
345+ resolver , resolver , schema , id_already_applied = True , preflight = True
262346 )
263347 root_uri = resolver ._base_uri
264- pending = [(schema , resolver )]
265- visited : set [tuple [int , str ]] = set ()
348+ pending : list [
349+ tuple [Mapping [str , Any ], Any , tuple [Mapping [str , Any ], ...]]
350+ ] = [(schema , resolver , ())]
351+ visited : set [tuple [int , str , tuple [int , ...]]] = set ()
352+ discovery : dict [int , tuple [Registry [Any ], Registry [Any ] | None ]] = {}
266353 checked : set [int ] = set ()
267354 _mark_checked_schemas (schema , checked )
268355
269356 while pending :
270- current , resolver = pending .pop ()
271- key = (id (current ), resolver ._base_uri )
357+ current , resolver , scopes = pending .pop ()
358+ key = (id (current ), resolver ._base_uri , tuple ( map ( id , scopes )) )
272359 if key in visited :
273360 continue
274361 visited .add (key )
275362
276363 if "$ref" in current :
277364 ref = current ["$ref" ]
278- # A caller-owned scope wins at runtime and is checked by its owner.
279- if not ref .startswith ("#/" ) or (
280- resolver ._base_uri != root_uri
281- and registry is not None
282- and resolver ._base_uri in registry
283- ):
365+ # Registry discovery for anchors and URI refs can choose a
366+ # caller-owned resource. Only check those refs in isolation.
367+ if registry and not ref .startswith ("#/" ):
284368 continue
285369 try :
286- target = _resolve_pointer (resolver , ref )
287- except (Unresolvable , TypeError , ValueError ):
370+ for scope in scopes :
371+ resolver = _enter_subresource (resolver , scope )
372+ # A caller-owned scope wins at runtime.
373+ if (
374+ resolver ._base_uri != root_uri
375+ and registry is not None
376+ and resolver ._base_uri in registry
377+ ):
378+ continue
379+ resolver = _preflight_discovery (resolver , ref , discovery )
380+ if resolver is None :
381+ continue
382+ target = _resolve_reference (resolver , ref )
383+ except (
384+ Unresolvable ,
385+ TypeError ,
386+ ValueError ,
387+ _ForeignDialectDiscovery ,
388+ ):
288389 # Resolution errors belong to instance validation.
289390 continue
290391 if declares_other_schema_dialect (target .contents ):
291392 continue
292393 if id (target .contents ) not in checked :
293394 check_schema (target .contents )
294395 _mark_checked_schemas (target .contents , checked )
295- pending .append ((target .contents , target .resolver ))
396+ pending .append ((target .contents , target .resolver , () ))
296397 else :
297- pending .extend (
298- (child , _enter_subresource (resolver , child ))
299- for child in _oas30_schema_children (current )
300- )
398+ # Definitions become reachable through refs, not by being present.
399+ applicators = {
400+ key : value
401+ for key , value in current .items ()
402+ if key in _OAS30_APPLICATORS
403+ }
404+ for child in _oas30_schema_children (applicators ):
405+ child_scopes = (
406+ scopes if DRAFT4 .id_of (child ) is None else (* scopes , child )
407+ )
408+ pending .append ((child , resolver , child_scopes ))
301409
302410
303411def validate_oas30_reference (
@@ -313,10 +421,7 @@ def validate_oas30_reference(
313421 validator ._resolver , validator ._resolver , validator .schema
314422 )
315423 try :
316- if ref .startswith ("#/" ):
317- target = _resolve_pointer (resolver , ref )
318- else :
319- target = resolver .lookup (ref )
424+ target = _resolve_reference (resolver , ref )
320425 except Unresolvable as error :
321426 raise _WrappedReferencingError (error ) from error
322427 return cast (
0 commit comments