Skip to content

Commit 33dfee4

Browse files
committed
Fix oneOf with no top-level type
1 parent b727962 commit 33dfee4

3 files changed

Lines changed: 211 additions & 25 deletions

File tree

openapi_core/unmarshalling/schemas/unmarshallers.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,18 @@ def unmarshal_state(self, state: ValidationState) -> Optional[List[Any]]:
4646
self.items_unmarshaller.unmarshal_state(item_state)
4747
for item_state in state.item_states
4848
]
49-
return self(value=state.value)
49+
# No per-item states means the item schema didn't need state
50+
# (predicate said so). The outer validate() has already covered
51+
# each item, so go through unmarshal_state with a bare state
52+
# rather than unmarshal (which would re-validate).
53+
items_unmarshaller = self.items_unmarshaller
54+
items_schema = items_unmarshaller.schema
55+
return [
56+
items_unmarshaller.unmarshal_state(
57+
ValidationState(schema=items_schema, value=item)
58+
)
59+
for item in state.value
60+
]
5061

5162
@property
5263
def items_unmarshaller(self) -> "SchemaUnmarshaller":
@@ -196,10 +207,20 @@ def _unmarshal_properties_from_state(
196207
except KeyError:
197208
if "default" not in prop_schema:
198209
continue
210+
# Default values were not part of the validated input,
211+
# so they must go through full unmarshal (revalidates).
199212
prop_value = (prop_schema / "default").read_value()
213+
properties[prop_name] = self.schema_unmarshaller.evolve(
214+
prop_schema
215+
).unmarshal(prop_value)
216+
continue
217+
# Present-but-state-skipped: validate() already covered it,
218+
# so a bare-state unmarshal_state avoids re-validation.
200219
properties[prop_name] = self.schema_unmarshaller.evolve(
201220
prop_schema
202-
).unmarshal(prop_value)
221+
).unmarshal_state(
222+
ValidationState(schema=prop_schema, value=prop_value)
223+
)
203224

204225
if schema_only:
205226
return properties
@@ -226,8 +247,13 @@ def _unmarshal_properties_from_state(
226247
)
227248
)
228249
continue
229-
properties[prop_name] = additional_prop_unmarshaler.unmarshal(
230-
prop_value
250+
# State skipped for trivial child; validate() covered it.
251+
properties[prop_name] = (
252+
additional_prop_unmarshaler.unmarshal_state(
253+
ValidationState(
254+
schema=additional_prop_schema, value=prop_value
255+
)
256+
)
231257
)
232258

233259
return properties
@@ -246,8 +272,34 @@ def __call__(self, value: Any) -> Any:
246272

247273
def unmarshal_state(self, state: ValidationState) -> Any:
248274
primitive_type = state.primitive_type
275+
# Bare-state fast paths (used for sub-trees that didn't need
276+
# full state-building) carry primitive_type=None. Fall back to
277+
# computing it from the value -- it's the same work
278+
# MultiTypeUnmarshaller.__call__ does, only when actually
279+
# needed, and only for sub-trees too trivial to cache.
249280
if primitive_type is None:
250-
return None
281+
primitive_type = self.schema_validator.get_primitive_type(
282+
state.value
283+
)
284+
if primitive_type is None:
285+
return None
286+
# If the matched validation result lives in a composed branch
287+
# (oneOf / first anyOf), the type unmarshaller must be bound
288+
# to THAT branch's schema -- not to ours -- so nested keywords
289+
# like `items` / `properties` resolve against the matched
290+
# branch. Without this, an outer "pure-oneOf" schema would
291+
# dispatch to an ArrayUnmarshaller bound to a schema with no
292+
# `items` keyword. Walk the chain to its leaf.
293+
target_state = state
294+
while target_state.one_of_state is not None:
295+
target_state = target_state.one_of_state
296+
if target_state is state and state.any_of_states:
297+
target_state = state.any_of_states[0]
298+
if target_state is not state:
299+
target_unmarshaller = self.schema_unmarshaller.evolve(
300+
target_state.schema
301+
)
302+
return target_unmarshaller.unmarshal_state(target_state)
251303
unmarshaller = self.schema_unmarshaller.get_type_unmarshaller(
252304
primitive_type
253305
)
Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from dataclasses import dataclass
2-
from dataclasses import field
2+
from types import MappingProxyType
33
from typing import Any
44
from typing import Callable
55
from typing import Dict
6+
from typing import Mapping
67
from typing import Optional
7-
from typing import Tuple
88

99
from jsonschema_path import SchemaPath
1010

@@ -13,19 +13,45 @@
1313
FormatValidatorsDict = Dict[str, FormatValidator]
1414

1515

16-
@dataclass(frozen=True)
16+
# Shared, read-only "empty container" singletons used as the default
17+
# values for ValidationState collection fields. Because ValidationState
18+
# is frozen these are safe to share across instances -- there is no
19+
# code path that mutates the fields after construction. Using a single
20+
# empty mapping/tuple instead of allocating a fresh dict/() for every
21+
# leaf state cuts the per-instance allocation count from ~5 to 1.
22+
_EMPTY_STATES: Mapping[str, "ValidationState"] = MappingProxyType({})
23+
_EMPTY_STATE_TUPLE: tuple["ValidationState", ...] = ()
24+
25+
26+
@dataclass(frozen=True, slots=True)
1727
class ValidationState:
28+
"""The result of validating ``value`` against ``schema``.
29+
30+
Carries forward two pieces of information that the unmarshaller
31+
would otherwise have to recompute:
32+
33+
1. The fact that ``value`` was validated -- so child unmarshallers
34+
can skip re-running ``validate()`` against the same value.
35+
2. Which composed schemas matched (``one_of_state``,
36+
``any_of_states``, ``all_of_states``) -- so the unmarshaller
37+
doesn't have to re-resolve ``oneOf`` / ``anyOf`` / ``allOf``
38+
branch selection at unmarshal time.
39+
40+
State is only built for sub-trees that actually carry one of these
41+
two pieces of information. Sub-trees with no composition anywhere
42+
don't get a populated state; the unmarshaller takes a fast bare-
43+
state path for them. See ``SchemaValidator._schema_needs_state``.
44+
"""
45+
1846
schema: SchemaPath
1947
value: Any
2048
primitive_type: Optional[str] = None
21-
property_states: Dict[str, "ValidationState"] = field(default_factory=dict)
22-
additional_property_states: Dict[str, "ValidationState"] = field(
23-
default_factory=dict
24-
)
25-
item_states: Tuple["ValidationState", ...] = ()
49+
property_states: Mapping[str, "ValidationState"] = _EMPTY_STATES
50+
additional_property_states: Mapping[str, "ValidationState"] = _EMPTY_STATES
51+
item_states: tuple["ValidationState", ...] = _EMPTY_STATE_TUPLE
2652
one_of_state: Optional["ValidationState"] = None
27-
any_of_states: Tuple["ValidationState", ...] = ()
28-
all_of_states: Tuple["ValidationState", ...] = ()
53+
any_of_states: tuple["ValidationState", ...] = _EMPTY_STATE_TUPLE
54+
all_of_states: tuple["ValidationState", ...] = _EMPTY_STATE_TUPLE
2955

3056

3157
__all__ = ["FormatValidator", "FormatValidatorsDict", "ValidationState"]

openapi_core/validation/schemas/validators.py

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@
44
from typing import TYPE_CHECKING
55
from typing import Any
66
from typing import Iterator
7+
from typing import Mapping
78
from typing import Optional
89

910
from jsonschema.exceptions import FormatError
1011
from jsonschema.protocols import Validator
1112
from jsonschema_path import SchemaPath
1213

14+
from openapi_core.validation.schemas.datatypes import (
15+
_EMPTY_STATE_TUPLE as _EMPTY_STATES_TUPLE,
16+
)
17+
from openapi_core.validation.schemas.datatypes import (
18+
_EMPTY_STATES as _EMPTY_STATES_MAP,
19+
)
1320
from openapi_core.validation.schemas.datatypes import FormatValidator
1421
from openapi_core.validation.schemas.datatypes import ValidationState
1522
from openapi_core.validation.schemas.exceptions import InvalidSchemaValue
@@ -40,19 +47,88 @@ def validate(self, value: Any) -> None:
4047
schema_type = (self.schema / "type").read_str_or_list("any")
4148
raise InvalidSchemaValue(value, schema_type, schema_errors=errors)
4249

50+
# Cache the recursive "does this schema benefit from a ValidationState?"
51+
# check, keyed on the SchemaPath. SchemaPath is hashed by content, so
52+
# two SchemaPaths pointing at the same spec location share a cache
53+
# slot regardless of identity -- safe across GC, bounded by the number
54+
# of distinct schema shapes in the spec rather than by input volume.
55+
_needs_state_cache: dict[SchemaPath, bool] = {}
56+
57+
@classmethod
58+
def _schema_needs_state(cls, schema: SchemaPath) -> bool:
59+
"""True if building a ValidationState for ``schema`` carries
60+
information the unmarshaller can reuse: either composition
61+
(oneOf/anyOf/allOf) on this node, or a descendant that does.
62+
63+
Cycle-safe: a False sentinel is stored before recursing, so a
64+
$ref loop terminates and the real answer overwrites the
65+
sentinel once the recursion completes.
66+
"""
67+
cache = cls._needs_state_cache
68+
cached = cache.get(schema)
69+
if cached is not None:
70+
return cached
71+
# Self-composition is the strongest signal; check it first to
72+
# short-circuit the cheap case.
73+
if "oneOf" in schema or "anyOf" in schema or "allOf" in schema:
74+
cache[schema] = True
75+
return True
76+
# Seed the in-progress sentinel for cycle protection.
77+
cache[schema] = False
78+
# Recurse into children. We only need to find one descendant
79+
# that needs state to flip our own answer.
80+
result = False
81+
if "properties" in schema:
82+
prop_iter = (schema / "properties").items()
83+
for prop_name, prop_schema in prop_iter:
84+
if not isinstance(prop_name, str):
85+
continue
86+
if cls._schema_needs_state(prop_schema):
87+
result = True
88+
break
89+
if not result and "additionalProperties" in schema:
90+
try:
91+
ap = schema / "additionalProperties"
92+
except Exception:
93+
ap = None
94+
if ap is not None and cls._schema_needs_state(ap):
95+
result = True
96+
if not result and "items" in schema:
97+
try:
98+
items = schema / "items"
99+
except Exception:
100+
items = None
101+
if items is not None and cls._schema_needs_state(items):
102+
result = True
103+
cache[schema] = result
104+
return result
105+
43106
def validate_state(self, value: Any) -> ValidationState:
44107
self.validate(value)
45108
return self._build_trusted_state(value)
46109

47110
def _build_trusted_state(self, value: Any) -> ValidationState:
48-
primitive_type = self.get_primitive_type(value)
49-
property_states = {}
50-
additional_property_states = {}
51-
item_states: tuple[ValidationState, ...] = ()
52-
one_of_state = None
53-
any_of_states: tuple[ValidationState, ...] = ()
54-
all_of_states: tuple[ValidationState, ...] = ()
111+
"""Build a ValidationState for ``value`` against ``self.schema``.
55112
113+
Pre-condition: ``value`` has already been validated against the
114+
schema (typically by an outer ``validate_state``). This method
115+
does NOT re-validate -- it only records the composition-branch
116+
decisions and recurses into children that themselves need
117+
state.
118+
"""
119+
primitive_type = self.get_primitive_type(value)
120+
property_states: Mapping[str, ValidationState] = _EMPTY_STATES_MAP
121+
additional_property_states: Mapping[str, ValidationState] = (
122+
_EMPTY_STATES_MAP
123+
)
124+
item_states: tuple[ValidationState, ...] = _EMPTY_STATES_TUPLE
125+
one_of_state: Optional[ValidationState] = None
126+
any_of_states: tuple[ValidationState, ...] = _EMPTY_STATES_TUPLE
127+
all_of_states: tuple[ValidationState, ...] = _EMPTY_STATES_TUPLE
128+
129+
# Composition keywords: always cache the branch selection,
130+
# because re-resolving it at unmarshal time is exactly the work
131+
# ValidationState exists to avoid.
56132
if "oneOf" in self.schema:
57133
one_of_schema = self.get_one_of_schema(value)
58134
if one_of_schema is not None:
@@ -76,22 +152,41 @@ def _build_trusted_state(self, value: Any) -> ValidationState:
76152
for all_of_schema in all_of_schemas
77153
)
78154

155+
# Children: recurse only into sub-trees that themselves contain
156+
# composition. Sub-trees without composition can be unmarshalled
157+
# via the bare-state fast path -- no cached state needed.
79158
if primitive_type == "object" and isinstance(value, dict):
159+
new_props: dict[str, ValidationState] = {}
80160
for prop_name, prop_schema in self._get_input_properties(
81161
value
82162
).items():
83-
property_states[prop_name] = self.evolve(
163+
if not self._schema_needs_state(prop_schema):
164+
continue
165+
new_props[prop_name] = self.evolve(
84166
prop_schema
85167
)._build_trusted_state(value[prop_name])
168+
if new_props:
169+
property_states = new_props
170+
171+
new_addl: dict[str, ValidationState] = {}
86172
for (
87173
prop_name,
88174
additional_prop_schema,
89175
) in self._get_input_additional_properties(value).items():
90-
additional_property_states[prop_name] = self.evolve(
176+
if not self._schema_needs_state(additional_prop_schema):
177+
continue
178+
new_addl[prop_name] = self.evolve(
91179
additional_prop_schema
92180
)._build_trusted_state(value[prop_name])
181+
if new_addl:
182+
additional_property_states = new_addl
93183
elif primitive_type == "array" and isinstance(value, list):
94-
item_states = tuple(self.iter_item_states(value))
184+
# Skip per-item state when the item schema itself doesn't
185+
# need state -- the unmarshaller's bare-state fast path
186+
# handles each item.
187+
built = self._build_item_states_if_needed(value)
188+
if built:
189+
item_states = built
95190

96191
return ValidationState(
97192
self.schema,
@@ -105,6 +200,19 @@ def _build_trusted_state(self, value: Any) -> ValidationState:
105200
all_of_states=all_of_states,
106201
)
107202

203+
def _build_item_states_if_needed(
204+
self, value: list[Any]
205+
) -> tuple[ValidationState, ...]:
206+
if "items" not in self.schema:
207+
return _EMPTY_STATES_TUPLE
208+
items_schema = self.schema / "items"
209+
if not self._schema_needs_state(items_schema):
210+
return _EMPTY_STATES_TUPLE
211+
item_validator = self.evolve(items_schema)
212+
return tuple(
213+
item_validator._build_trusted_state(item) for item in value
214+
)
215+
108216
def evolve(self, schema: SchemaPath) -> "SchemaValidator":
109217
cls = self.__class__
110218

0 commit comments

Comments
 (0)