schemathesis 4.2.2__py3-none-any.whl → 4.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. schemathesis/config/__init__.py +8 -1
  2. schemathesis/config/_phases.py +14 -3
  3. schemathesis/config/schema.json +2 -1
  4. schemathesis/core/jsonschema/bundler.py +3 -2
  5. schemathesis/core/transforms.py +14 -6
  6. schemathesis/engine/context.py +35 -2
  7. schemathesis/generation/hypothesis/__init__.py +3 -1
  8. schemathesis/generation/hypothesis/builder.py +10 -2
  9. schemathesis/openapi/checks.py +13 -1
  10. schemathesis/specs/openapi/adapter/parameters.py +3 -3
  11. schemathesis/specs/openapi/adapter/protocol.py +2 -0
  12. schemathesis/specs/openapi/adapter/responses.py +29 -7
  13. schemathesis/specs/openapi/adapter/v2.py +2 -0
  14. schemathesis/specs/openapi/adapter/v3_0.py +2 -0
  15. schemathesis/specs/openapi/adapter/v3_1.py +2 -0
  16. schemathesis/specs/openapi/stateful/dependencies/__init__.py +88 -0
  17. schemathesis/specs/openapi/stateful/dependencies/inputs.py +182 -0
  18. schemathesis/specs/openapi/stateful/dependencies/models.py +270 -0
  19. schemathesis/specs/openapi/stateful/dependencies/naming.py +345 -0
  20. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  21. schemathesis/specs/openapi/stateful/dependencies/resources.py +282 -0
  22. schemathesis/specs/openapi/stateful/dependencies/schemas.py +420 -0
  23. schemathesis/specs/openapi/stateful/inference.py +2 -1
  24. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/METADATA +1 -1
  25. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/RECORD +28 -21
  26. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/WHEEL +0 -0
  27. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/entry_points.txt +0 -0
  28. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -19,7 +19,13 @@ from schemathesis.config._error import ConfigError
19
19
  from schemathesis.config._generation import GenerationConfig
20
20
  from schemathesis.config._health_check import HealthCheck
21
21
  from schemathesis.config._output import OutputConfig, SanitizationConfig, TruncationConfig
22
- from schemathesis.config._phases import CoveragePhaseConfig, PhaseConfig, PhasesConfig, StatefulPhaseConfig
22
+ from schemathesis.config._phases import (
23
+ CoveragePhaseConfig,
24
+ InferenceAlgorithm,
25
+ PhaseConfig,
26
+ PhasesConfig,
27
+ StatefulPhaseConfig,
28
+ )
23
29
  from schemathesis.config._projects import ProjectConfig, ProjectsConfig, SchemathesisWarning, get_workers_count
24
30
  from schemathesis.config._report import DEFAULT_REPORT_DIRECTORY, ReportConfig, ReportFormat, ReportsConfig
25
31
 
@@ -44,6 +50,7 @@ __all__ = [
44
50
  "PhasesConfig",
45
51
  "CoveragePhaseConfig",
46
52
  "StatefulPhaseConfig",
53
+ "InferenceAlgorithm",
47
54
  "ProjectsConfig",
48
55
  "ProjectConfig",
49
56
  "get_workers_count",
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
+ from enum import Enum
4
5
  from typing import Any
5
6
 
6
7
  from schemathesis.config._checks import ChecksConfig
@@ -125,9 +126,14 @@ class CoveragePhaseConfig(DiffBase):
125
126
  )
126
127
 
127
128
 
129
+ class InferenceAlgorithm(str, Enum):
130
+ LOCATION_HEADERS = "location-headers"
131
+ DEPENDENCY_ANALYSIS = "dependency-analysis"
132
+
133
+
128
134
  @dataclass(repr=False)
129
135
  class InferenceConfig(DiffBase):
130
- algorithms: list[str]
136
+ algorithms: list[InferenceAlgorithm]
131
137
 
132
138
  __slots__ = ("algorithms",)
133
139
 
@@ -136,12 +142,14 @@ class InferenceConfig(DiffBase):
136
142
  *,
137
143
  algorithms: list[str] | None = None,
138
144
  ) -> None:
139
- self.algorithms = algorithms if algorithms is not None else ["location-headers"]
145
+ self.algorithms = (
146
+ [InferenceAlgorithm(a) for a in algorithms] if algorithms is not None else list(InferenceAlgorithm)
147
+ )
140
148
 
141
149
  @classmethod
142
150
  def from_dict(cls, data: dict[str, Any]) -> InferenceConfig:
143
151
  return cls(
144
- algorithms=data.get("algorithms", ["location-headers"]),
152
+ algorithms=data.get("algorithms", list(InferenceAlgorithm)),
145
153
  )
146
154
 
147
155
  @property
@@ -149,6 +157,9 @@ class InferenceConfig(DiffBase):
149
157
  """Inference is enabled if any algorithms are configured."""
150
158
  return bool(self.algorithms)
151
159
 
160
+ def is_algorithm_enabled(self, algorithm: InferenceAlgorithm) -> bool:
161
+ return algorithm in self.algorithms
162
+
152
163
 
153
164
  @dataclass(repr=False)
154
165
  class StatefulPhaseConfig(DiffBase):
@@ -325,7 +325,8 @@
325
325
  "items": {
326
326
  "type": "string",
327
327
  "enum": [
328
- "location-headers"
328
+ "location-headers",
329
+ "dependency-analysis"
329
330
  ]
330
331
  },
331
332
  "uniqueItems": true
@@ -97,8 +97,9 @@ class Bundler:
97
97
 
98
98
  result = {key: _bundle_recursive(value) for key, value in current.items() if key != "$ref"}
99
99
  # Recursive references need `$ref` to be in them, which is only possible with `dict`
100
- assert isinstance(cloned, dict)
101
- result.update(cloned)
100
+ bundled_clone = _bundle_recursive(cloned)
101
+ assert isinstance(bundled_clone, dict)
102
+ result.update(bundled_clone)
102
103
  return result
103
104
  elif resolved_uri not in visited:
104
105
  # Bundle only new schemas
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Dict, List, Mapping, TypeVar, Union, overload
3
+ from typing import Any, Callable, Dict, Iterator, List, Mapping, TypeVar, Union, overload
4
4
 
5
5
  T = TypeVar("T")
6
6
 
@@ -106,6 +106,18 @@ class Unresolvable: ...
106
106
  UNRESOLVABLE = Unresolvable()
107
107
 
108
108
 
109
+ def encode_pointer(pointer: str) -> str:
110
+ return pointer.replace("~", "~0").replace("/", "~1")
111
+
112
+
113
+ def decode_pointer(value: str) -> str:
114
+ return value.replace("~1", "/").replace("~0", "~")
115
+
116
+
117
+ def iter_decoded_pointer_segments(pointer: str) -> Iterator[str]:
118
+ return map(decode_pointer, pointer.split("/")[1:])
119
+
120
+
109
121
  def resolve_pointer(document: Any, pointer: str) -> dict | list | str | int | float | None | Unresolvable:
110
122
  """Implementation is adapted from Rust's `serde-json` crate.
111
123
 
@@ -116,12 +128,8 @@ def resolve_pointer(document: Any, pointer: str) -> dict | list | str | int | fl
116
128
  if not pointer.startswith("/"):
117
129
  return UNRESOLVABLE
118
130
 
119
- def replace(value: str) -> str:
120
- return value.replace("~1", "/").replace("~0", "~")
121
-
122
- tokens = map(replace, pointer.split("/")[1:])
123
131
  target = document
124
- for token in tokens:
132
+ for token in iter_decoded_pointer_segments(pointer):
125
133
  if isinstance(target, dict):
126
134
  target = target.get(token, UNRESOLVABLE)
127
135
  if target is UNRESOLVABLE:
@@ -4,12 +4,13 @@ import time
4
4
  from dataclasses import dataclass
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
- from schemathesis.config import ProjectConfig
7
+ from schemathesis.config import InferenceAlgorithm, ProjectConfig
8
8
  from schemathesis.core import NOT_SET, NotSet
9
9
  from schemathesis.engine.control import ExecutionControl
10
10
  from schemathesis.engine.observations import Observations
11
11
  from schemathesis.generation.case import Case
12
12
  from schemathesis.schemas import APIOperation, BaseSchema
13
+ from schemathesis.specs.openapi.stateful import dependencies
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  import threading
@@ -85,9 +86,10 @@ class EngineContext:
85
86
 
86
87
  def inject_links(self) -> int:
87
88
  """Inject inferred OpenAPI links into API operations based on collected observations."""
89
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
90
+
88
91
  injected = 0
89
92
  if self.observations is not None and self.observations.location_headers:
90
- from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
91
93
  from schemathesis.specs.openapi.stateful.inference import LinkInferencer
92
94
 
93
95
  assert isinstance(self.schema, BaseOpenAPISchema)
@@ -96,6 +98,24 @@ class EngineContext:
96
98
  inferencer = LinkInferencer.from_schema(self.schema)
97
99
  for operation, entries in self.observations.location_headers.items():
98
100
  injected += inferencer.inject_links(operation.responses, entries)
101
+ if (
102
+ isinstance(self.schema, BaseOpenAPISchema)
103
+ and self.schema.config.phases.stateful.enabled
104
+ and self.schema.config.phases.stateful.inference.is_algorithm_enabled(
105
+ InferenceAlgorithm.DEPENDENCY_ANALYSIS
106
+ )
107
+ ):
108
+ graph = dependencies.analyze(self.schema)
109
+ for response_links in graph.iter_links():
110
+ operation = self.schema.get_operation_by_reference(response_links.producer_operation_ref)
111
+ response = operation.responses.get(response_links.status_code)
112
+ links = response.definition.setdefault(self.schema.adapter.links_keyword, {})
113
+
114
+ for link_name, definition in response_links.links.items():
115
+ # Find unique name if collision exists
116
+ final_name = _resolve_link_name_collision(link_name, links)
117
+ links[final_name] = definition.to_openapi()
118
+ injected += 1
99
119
 
100
120
  return injected
101
121
 
@@ -151,3 +171,16 @@ class EngineContext:
151
171
  kwargs["proxies"] = {"all": proxy}
152
172
  self._transport_kwargs_cache[key] = kwargs
153
173
  return kwargs
174
+
175
+
176
+ def _resolve_link_name_collision(proposed_name: str, existing_links: dict[str, Any]) -> str:
177
+ if proposed_name not in existing_links:
178
+ return proposed_name
179
+
180
+ # Name collision - find next available suffix
181
+ suffix = 0
182
+ while True:
183
+ candidate = f"{proposed_name}_{suffix}"
184
+ if candidate not in existing_links:
185
+ return candidate
186
+ suffix += 1
@@ -90,7 +90,9 @@ def setup() -> None:
90
90
  url, resolved = resolver.resolve(ref)
91
91
  resolver.push_scope(url)
92
92
  try:
93
- return merged([s, _resolve_all_refs(deepclone(resolved), resolver=resolver)]) # type: ignore
93
+ return merged(
94
+ [_resolve_all_refs(s, resolver=resolver), _resolve_all_refs(deepclone(resolved), resolver=resolver)]
95
+ ) # type: ignore
94
96
  finally:
95
97
  resolver.pop_scope()
96
98
 
@@ -579,8 +579,16 @@ def _iter_coverage_cases(
579
579
  ),
580
580
  schema,
581
581
  )
582
- value = next(gen, NOT_SET)
583
- assert not isinstance(value, NotSet), f"It should always be possible: {schema!r}"
582
+ value = next(
583
+ gen,
584
+ coverage.GeneratedValue(
585
+ "value",
586
+ generation_mode=GenerationMode.NEGATIVE,
587
+ description="Sample value for unsupported path parameter pattern",
588
+ parameter=name,
589
+ location="/",
590
+ ),
591
+ )
584
592
  template.add_parameter(location, name, value)
585
593
  continue
586
594
  continue
@@ -125,8 +125,20 @@ class JsonSchemaError(Failure):
125
125
  exc: ValidationError,
126
126
  config: OutputConfig | None = None,
127
127
  ) -> JsonSchemaError:
128
+ schema_path = list(exc.absolute_schema_path)
129
+
130
+ # Reorder schema to prioritize the failing keyword in the output
131
+ schema_to_display = exc.schema
132
+ if isinstance(schema_to_display, dict) and schema_path:
133
+ failing_keyword = schema_path[-1]
134
+ if isinstance(failing_keyword, str) and failing_keyword in schema_to_display:
135
+ # Create a new dict with the failing keyword first
136
+ schema_to_display = {
137
+ failing_keyword: schema_to_display[failing_keyword],
138
+ **{k: v for k, v in schema_to_display.items() if k != failing_keyword},
139
+ }
128
140
  schema = textwrap.indent(
129
- truncate_json(exc.schema, config=config or OutputConfig(), max_lines=20), prefix=" "
141
+ truncate_json(schema_to_display, config=config or OutputConfig(), max_lines=20), prefix=" "
130
142
  )
131
143
  value = textwrap.indent(
132
144
  truncate_json(exc.instance, config=config or OutputConfig(), max_lines=20), prefix=" "
@@ -324,7 +324,7 @@ def iter_parameters_v2(
324
324
  _, param = maybe_resolve(param, resolver, "")
325
325
  if param.get("in") == ParameterLocation.BODY:
326
326
  if "$ref" in param["schema"]:
327
- resource_name = _get_resource_name(param["schema"]["$ref"])
327
+ resource_name = resource_name_from_ref(param["schema"]["$ref"])
328
328
  for media_type in body_media_types:
329
329
  yield OpenApiBody.from_definition(
330
330
  definition=parameter,
@@ -375,7 +375,7 @@ def iter_parameters_v3(
375
375
  if isinstance(schema, dict):
376
376
  content = dict(content)
377
377
  if "$ref" in schema:
378
- resource_name = _get_resource_name(schema["$ref"])
378
+ resource_name = resource_name_from_ref(schema["$ref"])
379
379
  try:
380
380
  to_bundle = cast(dict[str, Any], schema)
381
381
  bundled = bundler.bundle(to_bundle, resolver, inline_recursive=True)
@@ -391,7 +391,7 @@ def iter_parameters_v3(
391
391
  )
392
392
 
393
393
 
394
- def _get_resource_name(reference: str) -> str:
394
+ def resource_name_from_ref(reference: str) -> str:
395
395
  return reference.rsplit("/", maxsplit=1)[1]
396
396
 
397
397
 
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
10
10
  from schemathesis.core.jsonschema.types import JsonSchema
11
11
 
12
12
  IterResponseExamples = Callable[[Mapping[str, Any], str], Iterator[tuple[str, object]]]
13
+ ExtractRawResponseSchema = Callable[[Mapping[str, Any]], Union["JsonSchema", None]]
13
14
  ExtractResponseSchema = Callable[[Mapping[str, Any], "RefResolver", str, str], Union["JsonSchema", None]]
14
15
  ExtractHeaderSchema = Callable[[Mapping[str, Any], "RefResolver", str, str], "JsonSchema"]
15
16
  ExtractParameterSchema = Callable[[Mapping[str, Any]], "JsonSchema"]
@@ -41,6 +42,7 @@ class SpecificationAdapter(Protocol):
41
42
  # Function to extract schema from parameter definition
42
43
  extract_parameter_schema: ExtractParameterSchema
43
44
  # Function to extract response schema from specification
45
+ extract_raw_response_schema: ExtractRawResponseSchema
44
46
  extract_response_schema: ExtractResponseSchema
45
47
  # Function to extract header schema from specification
46
48
  extract_header_schema: ExtractHeaderSchema
@@ -48,6 +48,10 @@ class OpenApiResponse:
48
48
  assert not isinstance(self._schema, NotSet)
49
49
  return self._schema
50
50
 
51
+ def get_raw_schema(self) -> JsonSchema | None:
52
+ """Raw and unresolved response schema."""
53
+ return self.adapter.extract_raw_response_schema(self.definition)
54
+
51
55
  @property
52
56
  def validator(self) -> Validator | None:
53
57
  """JSON Schema validator for this response."""
@@ -118,6 +122,9 @@ class OpenApiResponses:
118
122
  def items(self) -> ItemsView[str, OpenApiResponse]:
119
123
  return self._inner.items()
120
124
 
125
+ def get(self, key: str) -> OpenApiResponse | None:
126
+ return self._inner.get(key)
127
+
121
128
  def add(self, status_code: str, definition: dict[str, Any]) -> OpenApiResponse:
122
129
  instance = OpenApiResponse(
123
130
  status_code=status_code,
@@ -153,12 +160,16 @@ class OpenApiResponses:
153
160
  # The default response has the lowest priority
154
161
  return responses.get("default")
155
162
 
156
- def iter_examples(self) -> Iterator[tuple[str, object]]:
157
- """Iterate over all examples for all responses."""
163
+ def iter_successful_responses(self) -> Iterator[OpenApiResponse]:
164
+ """Iterate over all response definitions for successful responses."""
158
165
  for response in self._inner.values():
159
- # Check only 2xx responses
160
166
  if response.status_code.startswith("2"):
161
- yield from response.iter_examples()
167
+ yield response
168
+
169
+ def iter_examples(self) -> Iterator[tuple[str, object]]:
170
+ """Iterate over all examples for all responses."""
171
+ for response in self.iter_successful_responses():
172
+ yield from response.iter_examples()
162
173
 
163
174
 
164
175
  def _iter_resolved_responses(
@@ -178,20 +189,31 @@ def _iter_resolved_responses(
178
189
  def extract_response_schema_v2(
179
190
  response: Mapping[str, Any], resolver: RefResolver, scope: str, nullable_keyword: str
180
191
  ) -> JsonSchema | None:
181
- schema = response.get("schema")
192
+ schema = extract_raw_response_schema_v2(response)
182
193
  if schema is not None:
183
194
  return _prepare_schema(schema, resolver, scope, nullable_keyword)
184
195
  return None
185
196
 
186
197
 
198
+ def extract_raw_response_schema_v2(response: Mapping[str, Any]) -> JsonSchema | None:
199
+ return response.get("schema")
200
+
201
+
187
202
  def extract_response_schema_v3(
188
203
  response: Mapping[str, Any], resolver: RefResolver, scope: str, nullable_keyword: str
189
204
  ) -> JsonSchema | None:
205
+ schema = extract_raw_response_schema_v3(response)
206
+ if schema is not None:
207
+ return _prepare_schema(schema, resolver, scope, nullable_keyword)
208
+ return None
209
+
210
+
211
+ def extract_raw_response_schema_v3(response: Mapping[str, Any]) -> JsonSchema | None:
190
212
  options = iter(response.get("content", {}).values())
191
213
  media_type = next(options, None)
192
214
  # "schema" is an optional key in the `MediaType` object
193
- if media_type and "schema" in media_type:
194
- return _prepare_schema(media_type["schema"], resolver, scope, nullable_keyword)
215
+ if media_type is not None:
216
+ return media_type.get("schema")
195
217
  return None
196
218
 
197
219
 
@@ -5,6 +5,7 @@ from schemathesis.specs.openapi.adapter.protocol import (
5
5
  BuildPathParameter,
6
6
  ExtractHeaderSchema,
7
7
  ExtractParameterSchema,
8
+ ExtractRawResponseSchema,
8
9
  ExtractResponseSchema,
9
10
  ExtractSecurityParameters,
10
11
  IterParameters,
@@ -18,6 +19,7 @@ example_keyword = "x-example"
18
19
  examples_container_keyword = "x-examples"
19
20
 
20
21
  extract_parameter_schema: ExtractParameterSchema = parameters.extract_parameter_schema_v2
22
+ extract_raw_response_schema: ExtractRawResponseSchema = responses.extract_raw_response_schema_v2
21
23
  extract_response_schema: ExtractResponseSchema = responses.extract_response_schema_v2
22
24
  extract_header_schema: ExtractHeaderSchema = responses.extract_header_schema_v2
23
25
  iter_parameters: IterParameters = parameters.iter_parameters_v2
@@ -5,6 +5,7 @@ from schemathesis.specs.openapi.adapter.protocol import (
5
5
  BuildPathParameter,
6
6
  ExtractHeaderSchema,
7
7
  ExtractParameterSchema,
8
+ ExtractRawResponseSchema,
8
9
  ExtractResponseSchema,
9
10
  ExtractSecurityParameters,
10
11
  IterParameters,
@@ -18,6 +19,7 @@ example_keyword = "example"
18
19
  examples_container_keyword = "examples"
19
20
 
20
21
  extract_parameter_schema: ExtractParameterSchema = parameters.extract_parameter_schema_v3
22
+ extract_raw_response_schema: ExtractRawResponseSchema = responses.extract_raw_response_schema_v3
21
23
  extract_response_schema: ExtractResponseSchema = responses.extract_response_schema_v3
22
24
  extract_header_schema: ExtractHeaderSchema = responses.extract_header_schema_v3
23
25
  iter_parameters: IterParameters = parameters.iter_parameters_v3
@@ -5,6 +5,7 @@ from schemathesis.specs.openapi.adapter.protocol import (
5
5
  BuildPathParameter,
6
6
  ExtractHeaderSchema,
7
7
  ExtractParameterSchema,
8
+ ExtractRawResponseSchema,
8
9
  ExtractResponseSchema,
9
10
  ExtractSecurityParameters,
10
11
  IterParameters,
@@ -18,6 +19,7 @@ example_keyword = "example"
18
19
  examples_container_keyword = "examples"
19
20
 
20
21
  extract_parameter_schema: ExtractParameterSchema = parameters.extract_parameter_schema_v3
22
+ extract_raw_response_schema: ExtractRawResponseSchema = responses.extract_raw_response_schema_v3
21
23
  extract_response_schema: ExtractResponseSchema = responses.extract_response_schema_v3
22
24
  extract_header_schema: ExtractHeaderSchema = responses.extract_header_schema_v3
23
25
  iter_parameters: IterParameters = parameters.iter_parameters_v3
@@ -0,0 +1,88 @@
1
+ """Dependency detection between API operations for stateful testing.
2
+
3
+ Infers which operations must run before others by tracking resource creation and consumption across API operations.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ from schemathesis.core.compat import RefResolutionError
11
+ from schemathesis.core.result import Ok
12
+ from schemathesis.specs.openapi.stateful.dependencies.inputs import extract_inputs, update_input_field_bindings
13
+ from schemathesis.specs.openapi.stateful.dependencies.models import (
14
+ CanonicalizationCache,
15
+ Cardinality,
16
+ DefinitionSource,
17
+ DependencyGraph,
18
+ InputSlot,
19
+ OperationMap,
20
+ OperationNode,
21
+ OutputSlot,
22
+ ResourceDefinition,
23
+ ResourceMap,
24
+ )
25
+ from schemathesis.specs.openapi.stateful.dependencies.outputs import extract_outputs
26
+
27
+ if TYPE_CHECKING:
28
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
29
+
30
+ __all__ = [
31
+ "analyze",
32
+ "DependencyGraph",
33
+ "InputSlot",
34
+ "OutputSlot",
35
+ "Cardinality",
36
+ "ResourceDefinition",
37
+ "DefinitionSource",
38
+ ]
39
+
40
+
41
+ def analyze(schema: BaseOpenAPISchema) -> DependencyGraph:
42
+ """Build a dependency graph by inferring resource producers and consumers from API operations."""
43
+ operations: OperationMap = {}
44
+ resources: ResourceMap = {}
45
+ # Track resources that got upgraded (e.g., from parameter inference to schema definition)
46
+ # to propagate better field information to existing input slots
47
+ updated_resources: set[str] = set()
48
+ # Cache for expensive canonicalize() calls - same schemas are often processed multiple times
49
+ canonicalization_cache: CanonicalizationCache = {}
50
+
51
+ for result in schema.get_all_operations():
52
+ if isinstance(result, Ok):
53
+ operation = result.ok()
54
+ try:
55
+ inputs = extract_inputs(
56
+ operation=operation,
57
+ resources=resources,
58
+ updated_resources=updated_resources,
59
+ resolver=schema.resolver,
60
+ canonicalization_cache=canonicalization_cache,
61
+ )
62
+ outputs = extract_outputs(
63
+ operation=operation,
64
+ resources=resources,
65
+ updated_resources=updated_resources,
66
+ resolver=schema.resolver,
67
+ canonicalization_cache=canonicalization_cache,
68
+ )
69
+ operations[operation.label] = OperationNode(
70
+ method=operation.method,
71
+ path=operation.path,
72
+ inputs=list(inputs),
73
+ outputs=list(outputs),
74
+ )
75
+ except RefResolutionError:
76
+ # Skip operations with unresolvable $refs (e.g., unavailable external references or references with typos)
77
+ # These won't participate in dependency detection
78
+ continue
79
+
80
+ # Update input slots with improved resource definitions discovered during extraction
81
+ #
82
+ # Example:
83
+ # - `DELETE /users/{userId}` initially inferred `User.fields=["userId"]`
84
+ # - then `POST /users` response revealed `User.fields=["id", "email"]`
85
+ for resource in updated_resources:
86
+ update_input_field_bindings(resource, operations)
87
+
88
+ return DependencyGraph(operations=operations, resources=resources)