schemathesis 4.2.1__py3-none-any.whl → 4.3.0__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 +4 -3
  5. schemathesis/core/jsonschema/references.py +185 -85
  6. schemathesis/core/transforms.py +14 -6
  7. schemathesis/engine/context.py +35 -2
  8. schemathesis/generation/hypothesis/__init__.py +3 -1
  9. schemathesis/specs/openapi/adapter/parameters.py +3 -3
  10. schemathesis/specs/openapi/adapter/protocol.py +2 -0
  11. schemathesis/specs/openapi/adapter/responses.py +29 -7
  12. schemathesis/specs/openapi/adapter/v2.py +2 -0
  13. schemathesis/specs/openapi/adapter/v3_0.py +2 -0
  14. schemathesis/specs/openapi/adapter/v3_1.py +2 -0
  15. schemathesis/specs/openapi/examples.py +92 -50
  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 +168 -0
  20. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  21. schemathesis/specs/openapi/stateful/dependencies/resources.py +270 -0
  22. schemathesis/specs/openapi/stateful/dependencies/schemas.py +343 -0
  23. schemathesis/specs/openapi/stateful/inference.py +2 -1
  24. {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/METADATA +1 -1
  25. {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/RECORD +28 -21
  26. {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/WHEEL +0 -0
  27. {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/entry_points.txt +0 -0
  28. {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
@@ -109,15 +109,19 @@ def extract_top_level(
109
109
  assert isinstance(operation.schema, BaseOpenAPISchema)
110
110
 
111
111
  responses = list(operation.responses.iter_examples())
112
- seen_references: set[str] = set()
113
112
  for parameter in operation.iter_parameters():
114
113
  if "schema" in parameter.definition:
115
114
  schema = parameter.definition["schema"]
116
115
  resolver = RefResolver.from_schema(schema)
117
- seen_references.clear()
116
+ reference_path: tuple[str, ...] = ()
118
117
  definitions = [
119
118
  parameter.definition,
120
- *_expand_subschemas(schema=schema, resolver=resolver, seen_references=seen_references),
119
+ *[
120
+ expanded_schema
121
+ for expanded_schema, _ in _expand_subschemas(
122
+ schema=schema, resolver=resolver, reference_path=reference_path
123
+ )
124
+ ],
121
125
  ]
122
126
  else:
123
127
  definitions = [parameter.definition]
@@ -138,10 +142,15 @@ def extract_top_level(
138
142
  if "schema" in parameter.definition:
139
143
  schema = parameter.definition["schema"]
140
144
  resolver = RefResolver.from_schema(schema)
141
- seen_references.clear()
142
- for expanded in _expand_subschemas(schema=schema, resolver=resolver, seen_references=seen_references):
143
- if isinstance(expanded, dict) and parameter.adapter.examples_container_keyword in expanded:
144
- for value in expanded[parameter.adapter.examples_container_keyword]:
145
+ reference_path = ()
146
+ for expanded_schema, _ in _expand_subschemas(
147
+ schema=schema, resolver=resolver, reference_path=reference_path
148
+ ):
149
+ if (
150
+ isinstance(expanded_schema, dict)
151
+ and parameter.adapter.examples_container_keyword in expanded_schema
152
+ ):
153
+ for value in expanded_schema[parameter.adapter.examples_container_keyword]:
145
154
  yield ParameterExample(
146
155
  container=parameter.location.container_name, name=parameter.name, value=value
147
156
  )
@@ -152,10 +161,15 @@ def extract_top_level(
152
161
  if "schema" in body.definition:
153
162
  schema = body.definition["schema"]
154
163
  resolver = RefResolver.from_schema(schema)
155
- seen_references.clear()
164
+ reference_path = ()
156
165
  definitions = [
157
166
  body.definition,
158
- *_expand_subschemas(schema=schema, resolver=resolver, seen_references=seen_references),
167
+ *[
168
+ expanded_schema
169
+ for expanded_schema, _ in _expand_subschemas(
170
+ schema=schema, resolver=resolver, reference_path=reference_path
171
+ )
172
+ ],
159
173
  ]
160
174
  else:
161
175
  definitions = [body.definition]
@@ -172,58 +186,76 @@ def extract_top_level(
172
186
  if "schema" in body.definition:
173
187
  schema = body.definition["schema"]
174
188
  resolver = RefResolver.from_schema(schema)
175
- seen_references.clear()
176
- for expanded in _expand_subschemas(schema=schema, resolver=resolver, seen_references=seen_references):
177
- if isinstance(expanded, dict) and body.adapter.examples_container_keyword in expanded:
178
- for value in expanded[body.adapter.examples_container_keyword]:
189
+ reference_path = ()
190
+ for expanded_schema, _ in _expand_subschemas(
191
+ schema=schema, resolver=resolver, reference_path=reference_path
192
+ ):
193
+ if isinstance(expanded_schema, dict) and body.adapter.examples_container_keyword in expanded_schema:
194
+ for value in expanded_schema[body.adapter.examples_container_keyword]:
179
195
  yield BodyExample(value=value, media_type=body.media_type)
180
196
 
181
197
 
182
198
  @overload
183
199
  def _resolve_bundled(
184
- schema: dict[str, Any], resolver: RefResolver, seen_references: set[str]
185
- ) -> dict[str, Any]: ... # pragma: no cover
200
+ schema: dict[str, Any], resolver: RefResolver, reference_path: tuple[str, ...]
201
+ ) -> tuple[dict[str, Any], tuple[str, ...]]: ...
186
202
 
187
203
 
188
204
  @overload
189
- def _resolve_bundled(schema: bool, resolver: RefResolver, seen_references: set[str]) -> bool: ... # pragma: no cover
205
+ def _resolve_bundled(
206
+ schema: bool, resolver: RefResolver, reference_path: tuple[str, ...]
207
+ ) -> tuple[bool, tuple[str, ...]]: ...
190
208
 
191
209
 
192
210
  def _resolve_bundled(
193
- schema: dict[str, Any] | bool, resolver: RefResolver, seen_references: set[str]
194
- ) -> dict[str, Any] | bool:
211
+ schema: dict[str, Any] | bool, resolver: RefResolver, reference_path: tuple[str, ...]
212
+ ) -> tuple[dict[str, Any] | bool, tuple[str, ...]]:
213
+ """Resolve $ref if present."""
195
214
  if isinstance(schema, dict):
196
215
  reference = schema.get("$ref")
197
216
  if isinstance(reference, str):
198
- if reference in seen_references:
217
+ # Check if this reference is already in the current path
218
+ if reference in reference_path:
199
219
  # Try to remove recursive references to avoid infinite recursion
200
220
  remaining_references = references.sanitize(schema)
201
221
  if reference in remaining_references:
202
222
  raise InfiniteRecursiveReference(reference)
203
- seen_references.add(reference)
223
+
224
+ new_path = reference_path + (reference,)
225
+
204
226
  try:
205
- _, schema = resolver.resolve(schema["$ref"])
227
+ _, resolved_schema = resolver.resolve(reference)
206
228
  except RefResolutionError as exc:
207
229
  raise UnresolvableReference(reference) from exc
208
- return schema
230
+
231
+ return resolved_schema, new_path
232
+
233
+ return schema, reference_path
209
234
 
210
235
 
211
236
  def _expand_subschemas(
212
- *, schema: dict[str, Any] | bool, resolver: RefResolver, seen_references: set[str]
213
- ) -> Generator[dict[str, Any] | bool, None, None]:
214
- schema = _resolve_bundled(schema, resolver, seen_references)
215
- yield schema
237
+ *, schema: dict[str, Any] | bool, resolver: RefResolver, reference_path: tuple[str, ...]
238
+ ) -> Generator[tuple[dict[str, Any] | bool, tuple[str, ...]], None, None]:
239
+ """Expand schema and all its subschemas."""
240
+ schema, current_path = _resolve_bundled(schema, resolver, reference_path)
241
+ yield (schema, current_path)
242
+
216
243
  if isinstance(schema, dict):
244
+ # For anyOf/oneOf, yield each alternative with the same path
217
245
  for key in ("anyOf", "oneOf"):
218
246
  if key in schema:
219
247
  for subschema in schema[key]:
220
- yield subschema
248
+ # Each alternative starts with the current path
249
+ yield (subschema, current_path)
250
+
251
+ # For allOf, merge all alternatives
221
252
  if "allOf" in schema:
222
253
  subschema = deepclone(schema["allOf"][0])
223
- subschema = _resolve_bundled(subschema, resolver, seen_references)
254
+ subschema, _ = _resolve_bundled(subschema, resolver, current_path)
255
+
224
256
  for sub in schema["allOf"][1:]:
225
257
  if isinstance(sub, dict):
226
- sub = _resolve_bundled(sub, resolver, seen_references)
258
+ sub, _ = _resolve_bundled(sub, resolver, current_path)
227
259
  for key, value in sub.items():
228
260
  if key == "properties":
229
261
  subschema.setdefault("properties", {}).update(value)
@@ -235,7 +267,8 @@ def _expand_subschemas(
235
267
  subschema.setdefault("examples", []).append(value)
236
268
  else:
237
269
  subschema[key] = value
238
- yield subschema
270
+
271
+ yield (subschema, current_path)
239
272
 
240
273
 
241
274
  def extract_inner_examples(examples: dict[str, Any] | list, schema: BaseOpenAPISchema) -> Generator[Any, None, None]:
@@ -269,13 +302,12 @@ def extract_from_schemas(
269
302
  operation: APIOperation[OpenApiParameter, OpenApiResponses, OpenApiSecurityParameters],
270
303
  ) -> Generator[Example, None, None]:
271
304
  """Extract examples from parameters' schema definitions."""
272
- seen_references: set[str] = set()
273
305
  for parameter in operation.iter_parameters():
274
306
  schema = parameter.optimized_schema
275
307
  if isinstance(schema, bool):
276
308
  continue
277
309
  resolver = RefResolver.from_schema(schema)
278
- seen_references.clear()
310
+ reference_path: tuple[str, ...] = ()
279
311
  bundle_storage = schema.get(BUNDLE_STORAGE_KEY)
280
312
  for value in extract_from_schema(
281
313
  operation=operation,
@@ -283,7 +315,7 @@ def extract_from_schemas(
283
315
  example_keyword=parameter.adapter.example_keyword,
284
316
  examples_container_keyword=parameter.adapter.examples_container_keyword,
285
317
  resolver=resolver,
286
- seen_references=seen_references,
318
+ reference_path=reference_path,
287
319
  bundle_storage=bundle_storage,
288
320
  ):
289
321
  yield ParameterExample(container=parameter.location.container_name, name=parameter.name, value=value)
@@ -295,14 +327,14 @@ def extract_from_schemas(
295
327
  resolver = RefResolver.from_schema(schema)
296
328
  bundle_storage = schema.get(BUNDLE_STORAGE_KEY)
297
329
  for example_keyword, examples_container_keyword in (("example", "examples"), ("x-example", "x-examples")):
298
- seen_references.clear()
330
+ reference_path = ()
299
331
  for value in extract_from_schema(
300
332
  operation=operation,
301
333
  schema=schema,
302
334
  example_keyword=example_keyword,
303
335
  examples_container_keyword=examples_container_keyword,
304
336
  resolver=resolver,
305
- seen_references=seen_references,
337
+ reference_path=reference_path,
306
338
  bundle_storage=bundle_storage,
307
339
  ):
308
340
  yield BodyExample(value=value, media_type=body.media_type)
@@ -315,49 +347,57 @@ def extract_from_schema(
315
347
  example_keyword: str,
316
348
  examples_container_keyword: str,
317
349
  resolver: RefResolver,
318
- seen_references: set[str],
350
+ reference_path: tuple[str, ...],
319
351
  bundle_storage: dict[str, Any] | None,
320
352
  ) -> Generator[Any, None, None]:
321
353
  """Extract all examples from a single schema definition."""
322
354
  # This implementation supports only `properties` and `items`
323
- schema = _resolve_bundled(schema, resolver, seen_references)
355
+ schema, current_path = _resolve_bundled(schema, resolver, reference_path)
356
+
324
357
  if "properties" in schema:
325
358
  variants = {}
326
359
  required = schema.get("required", [])
327
360
  to_generate: dict[str, Any] = {}
361
+
328
362
  for name, subschema in schema["properties"].items():
329
363
  values = []
330
- for subsubschema in _expand_subschemas(
331
- schema=subschema, resolver=resolver, seen_references=seen_references
364
+ for expanded_schema, expanded_path in _expand_subschemas(
365
+ schema=subschema, resolver=resolver, reference_path=current_path
332
366
  ):
333
- if isinstance(subsubschema, bool):
334
- to_generate[name] = subsubschema
367
+ if isinstance(expanded_schema, bool):
368
+ to_generate[name] = expanded_schema
335
369
  continue
336
- if example_keyword in subsubschema:
337
- values.append(subsubschema[example_keyword])
338
- if examples_container_keyword in subsubschema and isinstance(
339
- subsubschema[examples_container_keyword], list
370
+
371
+ if example_keyword in expanded_schema:
372
+ values.append(expanded_schema[example_keyword])
373
+
374
+ if examples_container_keyword in expanded_schema and isinstance(
375
+ expanded_schema[examples_container_keyword], list
340
376
  ):
341
377
  # These are JSON Schema examples, which is an array of values
342
- values.extend(subsubschema[examples_container_keyword])
378
+ values.extend(expanded_schema[examples_container_keyword])
379
+
343
380
  # Check nested examples as well
344
381
  values.extend(
345
382
  extract_from_schema(
346
383
  operation=operation,
347
- schema=subsubschema,
384
+ schema=expanded_schema,
348
385
  example_keyword=example_keyword,
349
386
  examples_container_keyword=examples_container_keyword,
350
387
  resolver=resolver,
351
- seen_references=seen_references,
388
+ reference_path=expanded_path,
352
389
  bundle_storage=bundle_storage,
353
390
  )
354
391
  )
392
+
355
393
  if not values:
356
394
  if name in required:
357
395
  # Defer generation to only generate these variants if at least one property has examples
358
- to_generate[name] = subsubschema
396
+ to_generate[name] = expanded_schema
359
397
  continue
398
+
360
399
  variants[name] = values
400
+
361
401
  if variants:
362
402
  config = operation.schema.config.generation_for(operation=operation, phase="examples")
363
403
  for name, subschema in to_generate.items():
@@ -369,6 +409,7 @@ def extract_from_schema(
369
409
  subschema[BUNDLE_STORAGE_KEY] = bundle_storage
370
410
  generated = _generate_single_example(subschema, config)
371
411
  variants[name] = [generated]
412
+
372
413
  # Calculate the maximum number of examples any property has
373
414
  total_combos = max(len(examples) for examples in variants.values())
374
415
  # Evenly distribute examples by cycling through them
@@ -377,6 +418,7 @@ def extract_from_schema(
377
418
  name: next(islice(cycle(property_variants), idx, None))
378
419
  for name, property_variants in variants.items()
379
420
  }
421
+
380
422
  elif "items" in schema and isinstance(schema["items"], dict):
381
423
  # Each inner value should be wrapped in an array
382
424
  for value in extract_from_schema(
@@ -385,7 +427,7 @@ def extract_from_schema(
385
427
  example_keyword=example_keyword,
386
428
  examples_container_keyword=examples_container_keyword,
387
429
  resolver=resolver,
388
- seen_references=seen_references,
430
+ reference_path=current_path,
389
431
  bundle_storage=bundle_storage,
390
432
  ):
391
433
  yield [value]
@@ -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)
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Iterator
4
+
5
+ from schemathesis.core.parameters import ParameterLocation
6
+ from schemathesis.specs.openapi.stateful.dependencies import naming
7
+ from schemathesis.specs.openapi.stateful.dependencies.models import (
8
+ CanonicalizationCache,
9
+ DefinitionSource,
10
+ InputSlot,
11
+ OperationMap,
12
+ ResourceDefinition,
13
+ ResourceMap,
14
+ )
15
+ from schemathesis.specs.openapi.stateful.dependencies.resources import extract_resources_from_responses
16
+
17
+ if TYPE_CHECKING:
18
+ from schemathesis.core.compat import RefResolver
19
+ from schemathesis.specs.openapi.schemas import APIOperation
20
+
21
+
22
+ def extract_inputs(
23
+ *,
24
+ operation: APIOperation,
25
+ resources: ResourceMap,
26
+ updated_resources: set[str],
27
+ resolver: RefResolver,
28
+ canonicalization_cache: CanonicalizationCache,
29
+ ) -> Iterator[InputSlot]:
30
+ """Extract resource dependencies for an API operation from its input parameters.
31
+
32
+ Connects each parameter (e.g., `userId`) to its resource definition (`User`),
33
+ creating placeholder resources if not yet discovered from their schemas.
34
+ """
35
+ # Note: Currently limited to path parameters. Query / header / body will be supported in future releases.
36
+ for param in operation.path_parameters:
37
+ input_slot = _resolve_parameter_dependency(
38
+ parameter_name=param.name,
39
+ parameter_location=param.location,
40
+ operation=operation,
41
+ resources=resources,
42
+ updated_resources=updated_resources,
43
+ resolver=resolver,
44
+ canonicalization_cache=canonicalization_cache,
45
+ )
46
+ if input_slot is not None:
47
+ yield input_slot
48
+
49
+
50
+ def _resolve_parameter_dependency(
51
+ *,
52
+ parameter_name: str,
53
+ parameter_location: ParameterLocation,
54
+ operation: APIOperation,
55
+ resources: ResourceMap,
56
+ updated_resources: set[str],
57
+ resolver: RefResolver,
58
+ canonicalization_cache: CanonicalizationCache,
59
+ ) -> InputSlot | None:
60
+ """Connect a parameter to its resource definition, creating placeholder if needed.
61
+
62
+ Strategy:
63
+ 1. Infer resource name from parameter (`userId` -> `User`)
64
+ 2. Use existing resource if high-quality definition exists
65
+ 3. Try discovering from operation's response schemas
66
+ 4. Fall back to creating placeholder with a single field
67
+ """
68
+ resource_name = naming.from_parameter(parameter=parameter_name, path=operation.path)
69
+
70
+ if resource_name is None:
71
+ return None
72
+
73
+ resource = resources.get(resource_name)
74
+
75
+ # Upgrade low-quality resource definitions (e.g., from parameter inference)
76
+ # by searching this operation's responses for actual schema
77
+ if resource is None or resource.source < DefinitionSource.SCHEMA_WITH_PROPERTIES:
78
+ resource = _find_resource_in_responses(
79
+ operation=operation,
80
+ resource_name=resource_name,
81
+ resources=resources,
82
+ updated_resources=updated_resources,
83
+ resolver=resolver,
84
+ canonicalization_cache=canonicalization_cache,
85
+ )
86
+ if resource is not None:
87
+ resources[resource_name] = resource
88
+
89
+ # Determine resource and its field
90
+ if resource is None:
91
+ # No schema found - create placeholder resource with inferred field
92
+ #
93
+ # Example: `DELETE /users/{userId}` with no response body -> `User` resource with "userId" field
94
+ #
95
+ # Later operations with schemas will upgrade this placeholder
96
+ if resource_name in resources:
97
+ # Resource exists but was empty - update with parameter field
98
+ resources[resource_name].fields = [parameter_name]
99
+ resources[resource_name].source = DefinitionSource.PARAMETER_INFERENCE
100
+ updated_resources.add(resource_name)
101
+ resource = resources[resource_name]
102
+ else:
103
+ resource = ResourceDefinition.inferred_from_parameter(
104
+ name=resource_name,
105
+ parameter_name=parameter_name,
106
+ )
107
+ resources[resource_name] = resource
108
+ field = parameter_name
109
+ else:
110
+ # Match parameter to resource field (`userId` → `id`, `Id` → `ChannelId`, etc.)
111
+ field = (
112
+ naming.find_matching_field(
113
+ parameter=parameter_name,
114
+ resource=resource_name,
115
+ fields=resource.fields,
116
+ )
117
+ or "id"
118
+ )
119
+
120
+ return InputSlot(
121
+ resource=resource,
122
+ resource_field=field,
123
+ parameter_name=parameter_name,
124
+ parameter_location=parameter_location,
125
+ )
126
+
127
+
128
+ def _find_resource_in_responses(
129
+ *,
130
+ operation: APIOperation,
131
+ resource_name: str,
132
+ resources: ResourceMap,
133
+ updated_resources: set[str],
134
+ resolver: RefResolver,
135
+ canonicalization_cache: CanonicalizationCache,
136
+ ) -> ResourceDefinition | None:
137
+ """Search operation's successful responses for a specific resource definition.
138
+
139
+ Used when a parameter references a resource not yet discovered. Scans this
140
+ operation's response schemas hoping to find the resource definition.
141
+ """
142
+ for _, extracted in extract_resources_from_responses(
143
+ operation=operation,
144
+ resources=resources,
145
+ updated_resources=updated_resources,
146
+ resolver=resolver,
147
+ canonicalization_cache=canonicalization_cache,
148
+ ):
149
+ if extracted.resource.name == resource_name:
150
+ return extracted.resource
151
+
152
+ return None
153
+
154
+
155
+ def update_input_field_bindings(resource_name: str, operations: OperationMap) -> None:
156
+ """Update input slots field bindings after resource definition was upgraded.
157
+
158
+ When a resource's fields change (e.g., `User` upgraded from `["userId"]` to `["id", "email"]`),
159
+ existing input slots may reference stale field names. This re-evaluates field matching
160
+ for all operations using this resource.
161
+
162
+ Example:
163
+ `DELETE /users/{userId}` created `InputSlot(resource_field="userId")`
164
+ `POST /users` revealed actual fields `["id", "email"]`
165
+ This updates DELETE's `InputSlot` to use `resource_field="id"`
166
+
167
+ """
168
+ # Re-evaluate field matching for all operations referencing this resource
169
+ for operation in operations.values():
170
+ for input_slot in operation.inputs:
171
+ # Skip inputs not using this resource
172
+ if input_slot.resource.name != resource_name:
173
+ continue
174
+
175
+ # Re-match parameter to upgraded resource fields
176
+ new_field = naming.find_matching_field(
177
+ parameter=input_slot.parameter_name,
178
+ resource=resource_name,
179
+ fields=input_slot.resource.fields,
180
+ )
181
+ if new_field is not None:
182
+ input_slot.resource_field = new_field