schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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 (251) hide show
  1. schemathesis/__init__.py +53 -25
  2. schemathesis/auths.py +507 -0
  3. schemathesis/checks.py +190 -25
  4. schemathesis/cli/__init__.py +27 -1219
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +133 -0
  7. schemathesis/cli/commands/data.py +10 -0
  8. schemathesis/cli/commands/run/__init__.py +602 -0
  9. schemathesis/cli/commands/run/context.py +228 -0
  10. schemathesis/cli/commands/run/events.py +60 -0
  11. schemathesis/cli/commands/run/executor.py +157 -0
  12. schemathesis/cli/commands/run/filters.py +53 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +45 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
  17. schemathesis/cli/commands/run/handlers/output.py +1750 -0
  18. schemathesis/cli/commands/run/loaders.py +118 -0
  19. schemathesis/cli/commands/run/validation.py +256 -0
  20. schemathesis/cli/constants.py +5 -0
  21. schemathesis/cli/core.py +19 -0
  22. schemathesis/cli/ext/fs.py +16 -0
  23. schemathesis/cli/ext/groups.py +203 -0
  24. schemathesis/cli/ext/options.py +81 -0
  25. schemathesis/config/__init__.py +202 -0
  26. schemathesis/config/_auth.py +51 -0
  27. schemathesis/config/_checks.py +268 -0
  28. schemathesis/config/_diff_base.py +101 -0
  29. schemathesis/config/_env.py +21 -0
  30. schemathesis/config/_error.py +163 -0
  31. schemathesis/config/_generation.py +157 -0
  32. schemathesis/config/_health_check.py +24 -0
  33. schemathesis/config/_operations.py +335 -0
  34. schemathesis/config/_output.py +171 -0
  35. schemathesis/config/_parameters.py +19 -0
  36. schemathesis/config/_phases.py +253 -0
  37. schemathesis/config/_projects.py +543 -0
  38. schemathesis/config/_rate_limit.py +17 -0
  39. schemathesis/config/_report.py +120 -0
  40. schemathesis/config/_validator.py +9 -0
  41. schemathesis/config/_warnings.py +89 -0
  42. schemathesis/config/schema.json +975 -0
  43. schemathesis/core/__init__.py +72 -0
  44. schemathesis/core/adapter.py +34 -0
  45. schemathesis/core/compat.py +32 -0
  46. schemathesis/core/control.py +2 -0
  47. schemathesis/core/curl.py +100 -0
  48. schemathesis/core/deserialization.py +210 -0
  49. schemathesis/core/errors.py +588 -0
  50. schemathesis/core/failures.py +316 -0
  51. schemathesis/core/fs.py +19 -0
  52. schemathesis/core/hooks.py +20 -0
  53. schemathesis/core/jsonschema/__init__.py +13 -0
  54. schemathesis/core/jsonschema/bundler.py +183 -0
  55. schemathesis/core/jsonschema/keywords.py +40 -0
  56. schemathesis/core/jsonschema/references.py +222 -0
  57. schemathesis/core/jsonschema/types.py +41 -0
  58. schemathesis/core/lazy_import.py +15 -0
  59. schemathesis/core/loaders.py +107 -0
  60. schemathesis/core/marks.py +66 -0
  61. schemathesis/core/media_types.py +79 -0
  62. schemathesis/core/output/__init__.py +46 -0
  63. schemathesis/core/output/sanitization.py +54 -0
  64. schemathesis/core/parameters.py +45 -0
  65. schemathesis/core/rate_limit.py +60 -0
  66. schemathesis/core/registries.py +34 -0
  67. schemathesis/core/result.py +27 -0
  68. schemathesis/core/schema_analysis.py +17 -0
  69. schemathesis/core/shell.py +203 -0
  70. schemathesis/core/transforms.py +144 -0
  71. schemathesis/core/transport.py +223 -0
  72. schemathesis/core/validation.py +73 -0
  73. schemathesis/core/version.py +7 -0
  74. schemathesis/engine/__init__.py +28 -0
  75. schemathesis/engine/context.py +152 -0
  76. schemathesis/engine/control.py +44 -0
  77. schemathesis/engine/core.py +201 -0
  78. schemathesis/engine/errors.py +446 -0
  79. schemathesis/engine/events.py +284 -0
  80. schemathesis/engine/observations.py +42 -0
  81. schemathesis/engine/phases/__init__.py +108 -0
  82. schemathesis/engine/phases/analysis.py +28 -0
  83. schemathesis/engine/phases/probes.py +172 -0
  84. schemathesis/engine/phases/stateful/__init__.py +68 -0
  85. schemathesis/engine/phases/stateful/_executor.py +364 -0
  86. schemathesis/engine/phases/stateful/context.py +85 -0
  87. schemathesis/engine/phases/unit/__init__.py +220 -0
  88. schemathesis/engine/phases/unit/_executor.py +459 -0
  89. schemathesis/engine/phases/unit/_pool.py +82 -0
  90. schemathesis/engine/recorder.py +254 -0
  91. schemathesis/errors.py +47 -0
  92. schemathesis/filters.py +395 -0
  93. schemathesis/generation/__init__.py +25 -0
  94. schemathesis/generation/case.py +478 -0
  95. schemathesis/generation/coverage.py +1528 -0
  96. schemathesis/generation/hypothesis/__init__.py +121 -0
  97. schemathesis/generation/hypothesis/builder.py +992 -0
  98. schemathesis/generation/hypothesis/examples.py +56 -0
  99. schemathesis/generation/hypothesis/given.py +66 -0
  100. schemathesis/generation/hypothesis/reporting.py +285 -0
  101. schemathesis/generation/meta.py +227 -0
  102. schemathesis/generation/metrics.py +93 -0
  103. schemathesis/generation/modes.py +20 -0
  104. schemathesis/generation/overrides.py +127 -0
  105. schemathesis/generation/stateful/__init__.py +37 -0
  106. schemathesis/generation/stateful/state_machine.py +294 -0
  107. schemathesis/graphql/__init__.py +15 -0
  108. schemathesis/graphql/checks.py +109 -0
  109. schemathesis/graphql/loaders.py +285 -0
  110. schemathesis/hooks.py +270 -91
  111. schemathesis/openapi/__init__.py +13 -0
  112. schemathesis/openapi/checks.py +467 -0
  113. schemathesis/openapi/generation/__init__.py +0 -0
  114. schemathesis/openapi/generation/filters.py +72 -0
  115. schemathesis/openapi/loaders.py +315 -0
  116. schemathesis/pytest/__init__.py +5 -0
  117. schemathesis/pytest/control_flow.py +7 -0
  118. schemathesis/pytest/lazy.py +341 -0
  119. schemathesis/pytest/loaders.py +36 -0
  120. schemathesis/pytest/plugin.py +357 -0
  121. schemathesis/python/__init__.py +0 -0
  122. schemathesis/python/asgi.py +12 -0
  123. schemathesis/python/wsgi.py +12 -0
  124. schemathesis/schemas.py +682 -257
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +26 -2
  127. schemathesis/specs/graphql/scalars.py +77 -12
  128. schemathesis/specs/graphql/schemas.py +367 -148
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +555 -318
  132. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  133. schemathesis/specs/openapi/adapter/parameters.py +729 -0
  134. schemathesis/specs/openapi/adapter/protocol.py +59 -0
  135. schemathesis/specs/openapi/adapter/references.py +19 -0
  136. schemathesis/specs/openapi/adapter/responses.py +368 -0
  137. schemathesis/specs/openapi/adapter/security.py +144 -0
  138. schemathesis/specs/openapi/adapter/v2.py +30 -0
  139. schemathesis/specs/openapi/adapter/v3_0.py +30 -0
  140. schemathesis/specs/openapi/adapter/v3_1.py +30 -0
  141. schemathesis/specs/openapi/analysis.py +96 -0
  142. schemathesis/specs/openapi/checks.py +748 -82
  143. schemathesis/specs/openapi/converter.py +176 -37
  144. schemathesis/specs/openapi/definitions.py +599 -4
  145. schemathesis/specs/openapi/examples.py +581 -165
  146. schemathesis/specs/openapi/expressions/__init__.py +52 -5
  147. schemathesis/specs/openapi/expressions/extractors.py +25 -0
  148. schemathesis/specs/openapi/expressions/lexer.py +34 -31
  149. schemathesis/specs/openapi/expressions/nodes.py +97 -46
  150. schemathesis/specs/openapi/expressions/parser.py +35 -13
  151. schemathesis/specs/openapi/formats.py +122 -0
  152. schemathesis/specs/openapi/media_types.py +75 -0
  153. schemathesis/specs/openapi/negative/__init__.py +93 -73
  154. schemathesis/specs/openapi/negative/mutations.py +294 -103
  155. schemathesis/specs/openapi/negative/utils.py +0 -9
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +647 -666
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +403 -68
  161. schemathesis/specs/openapi/stateful/control.py +87 -0
  162. schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
  163. schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
  164. schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
  165. schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
  166. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  167. schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
  168. schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
  169. schemathesis/specs/openapi/stateful/inference.py +254 -0
  170. schemathesis/specs/openapi/stateful/links.py +219 -78
  171. schemathesis/specs/openapi/types/__init__.py +3 -0
  172. schemathesis/specs/openapi/types/common.py +23 -0
  173. schemathesis/specs/openapi/types/v2.py +129 -0
  174. schemathesis/specs/openapi/types/v3.py +134 -0
  175. schemathesis/specs/openapi/utils.py +7 -6
  176. schemathesis/specs/openapi/warnings.py +75 -0
  177. schemathesis/transport/__init__.py +224 -0
  178. schemathesis/transport/asgi.py +26 -0
  179. schemathesis/transport/prepare.py +126 -0
  180. schemathesis/transport/requests.py +278 -0
  181. schemathesis/transport/serialization.py +329 -0
  182. schemathesis/transport/wsgi.py +175 -0
  183. schemathesis-4.4.2.dist-info/METADATA +213 -0
  184. schemathesis-4.4.2.dist-info/RECORD +192 -0
  185. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
  186. schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
  187. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -57
  189. schemathesis/_hypothesis.py +0 -123
  190. schemathesis/auth.py +0 -214
  191. schemathesis/cli/callbacks.py +0 -240
  192. schemathesis/cli/cassettes.py +0 -351
  193. schemathesis/cli/context.py +0 -38
  194. schemathesis/cli/debug.py +0 -21
  195. schemathesis/cli/handlers.py +0 -11
  196. schemathesis/cli/junitxml.py +0 -41
  197. schemathesis/cli/options.py +0 -70
  198. schemathesis/cli/output/__init__.py +0 -1
  199. schemathesis/cli/output/default.py +0 -521
  200. schemathesis/cli/output/short.py +0 -40
  201. schemathesis/constants.py +0 -88
  202. schemathesis/exceptions.py +0 -257
  203. schemathesis/extra/_aiohttp.py +0 -27
  204. schemathesis/extra/_flask.py +0 -10
  205. schemathesis/extra/_server.py +0 -16
  206. schemathesis/extra/pytest_plugin.py +0 -251
  207. schemathesis/failures.py +0 -145
  208. schemathesis/fixups/__init__.py +0 -29
  209. schemathesis/fixups/fast_api.py +0 -30
  210. schemathesis/graphql.py +0 -5
  211. schemathesis/internal.py +0 -6
  212. schemathesis/lazy.py +0 -301
  213. schemathesis/models.py +0 -1113
  214. schemathesis/parameters.py +0 -91
  215. schemathesis/runner/__init__.py +0 -470
  216. schemathesis/runner/events.py +0 -242
  217. schemathesis/runner/impl/__init__.py +0 -3
  218. schemathesis/runner/impl/core.py +0 -791
  219. schemathesis/runner/impl/solo.py +0 -85
  220. schemathesis/runner/impl/threadpool.py +0 -367
  221. schemathesis/runner/serialization.py +0 -206
  222. schemathesis/serializers.py +0 -253
  223. schemathesis/service/__init__.py +0 -18
  224. schemathesis/service/auth.py +0 -10
  225. schemathesis/service/client.py +0 -62
  226. schemathesis/service/constants.py +0 -25
  227. schemathesis/service/events.py +0 -39
  228. schemathesis/service/handler.py +0 -46
  229. schemathesis/service/hosts.py +0 -74
  230. schemathesis/service/metadata.py +0 -42
  231. schemathesis/service/models.py +0 -21
  232. schemathesis/service/serialization.py +0 -184
  233. schemathesis/service/worker.py +0 -39
  234. schemathesis/specs/graphql/loaders.py +0 -215
  235. schemathesis/specs/openapi/constants.py +0 -7
  236. schemathesis/specs/openapi/expressions/context.py +0 -12
  237. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  238. schemathesis/specs/openapi/filters.py +0 -44
  239. schemathesis/specs/openapi/links.py +0 -303
  240. schemathesis/specs/openapi/loaders.py +0 -453
  241. schemathesis/specs/openapi/parameters.py +0 -430
  242. schemathesis/specs/openapi/security.py +0 -129
  243. schemathesis/specs/openapi/validation.py +0 -24
  244. schemathesis/stateful.py +0 -358
  245. schemathesis/targets.py +0 -32
  246. schemathesis/types.py +0 -38
  247. schemathesis/utils.py +0 -475
  248. schemathesis-3.15.4.dist-info/METADATA +0 -202
  249. schemathesis-3.15.4.dist-info/RECORD +0 -99
  250. schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
  251. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -0,0 +1,447 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any, Callable, Mapping
5
+
6
+ from schemathesis.core.jsonschema import ALL_KEYWORDS
7
+ from schemathesis.core.jsonschema.bundler import BUNDLE_STORAGE_KEY, bundle
8
+ from schemathesis.core.jsonschema.types import JsonSchema, JsonSchemaObject, get_type
9
+ from schemathesis.core.transforms import encode_pointer
10
+ from schemathesis.specs.openapi.adapter.parameters import resource_name_from_ref
11
+ from schemathesis.specs.openapi.adapter.references import maybe_resolve
12
+ from schemathesis.specs.openapi.stateful.dependencies import naming
13
+
14
+ if TYPE_CHECKING:
15
+ from schemathesis.core.compat import RefResolver
16
+
17
+ ROOT_POINTER = "/"
18
+ SCHEMA_KEYS = frozenset(
19
+ {
20
+ "propertyNames",
21
+ "contains",
22
+ "if",
23
+ "items",
24
+ "oneOf",
25
+ "anyOf",
26
+ "additionalProperties",
27
+ "then",
28
+ "else",
29
+ "not",
30
+ "additionalItems",
31
+ "allOf",
32
+ }
33
+ )
34
+ SCHEMA_OBJECT_KEYS = frozenset({"dependencies", "properties", "patternProperties"})
35
+
36
+
37
+ def resolve_all_refs(schema: JsonSchemaObject) -> dict[str, Any]:
38
+ if not schema:
39
+ return schema
40
+ bundled = schema.get(BUNDLE_STORAGE_KEY, {})
41
+
42
+ resolved_cache: dict[str, dict[str, Any]] = {}
43
+
44
+ def resolve(ref: str) -> dict[str, Any]:
45
+ # All references here are bundled, therefore it is safe to avoid full reference resolving
46
+ if ref in resolved_cache:
47
+ return resolved_cache[ref]
48
+ key = ref.split("/")[-1]
49
+ # No clone needed, as it will be cloned inside `merged`
50
+ result = resolve_all_refs_inner(bundled[key], resolve=resolve)
51
+ resolved_cache[ref] = result
52
+ return result
53
+
54
+ return resolve_all_refs_inner(schema, resolve=resolve)
55
+
56
+
57
+ def resolve_all_refs_inner(schema: JsonSchema, *, resolve: Callable[[str], dict[str, Any]]) -> dict[str, Any]:
58
+ from hypothesis_jsonschema._canonicalise import merged
59
+
60
+ if schema is True:
61
+ return {}
62
+ if schema is False:
63
+ return {"not": {}}
64
+ if not schema:
65
+ return schema
66
+
67
+ reference = schema.get("$ref")
68
+ if reference is not None:
69
+ resolved = resolve(reference)
70
+ if len(schema) == 1 or (len(schema) == 2 and BUNDLE_STORAGE_KEY in schema):
71
+ return resolved
72
+ del schema["$ref"]
73
+ schema.pop(BUNDLE_STORAGE_KEY, None)
74
+ schema.pop("example", None)
75
+ return merged([resolve_all_refs_inner(schema, resolve=resolve), resolved])
76
+
77
+ for key, value in schema.items():
78
+ if key in SCHEMA_KEYS:
79
+ if isinstance(value, list):
80
+ schema[key] = [resolve_all_refs_inner(v, resolve=resolve) if isinstance(v, dict) else v for v in value]
81
+ elif isinstance(value, dict):
82
+ schema[key] = resolve_all_refs_inner(value, resolve=resolve)
83
+ if key in SCHEMA_OBJECT_KEYS:
84
+ schema[key] = {
85
+ k: resolve_all_refs_inner(v, resolve=resolve) if isinstance(v, dict) else v for k, v in value.items()
86
+ }
87
+ return schema
88
+
89
+
90
+ def canonicalize(schema: dict[str, Any], resolver: RefResolver) -> Mapping[str, Any]:
91
+ """Transform the input schema into its canonical-ish form."""
92
+ from hypothesis_jsonschema._canonicalise import canonicalish
93
+
94
+ # Canonicalisation in `hypothesis_jsonschema` requires all references to be resovable and non-recursive
95
+ # On the Schemathesis side bundling solves this problem
96
+ bundled = bundle(schema, resolver, inline_recursive=True).schema
97
+ canonicalized = canonicalish(bundled)
98
+ resolved = resolve_all_refs(canonicalized)
99
+ resolved.pop(BUNDLE_STORAGE_KEY, None)
100
+ if "allOf" in resolved or "anyOf" in resolved or "oneOf" in resolved:
101
+ return canonicalish(resolved)
102
+ return resolved
103
+
104
+
105
+ def try_unwrap_composition(schema: Mapping[str, Any], resolver: RefResolver) -> Mapping[str, Any]:
106
+ """Unwrap oneOf/anyOf if we can safely extract a single schema."""
107
+ keys = ("anyOf", "oneOf")
108
+ composition_key = None
109
+ for key in keys:
110
+ if key in schema:
111
+ composition_key = key
112
+ break
113
+
114
+ if composition_key is None:
115
+ return schema
116
+
117
+ alternatives = schema[composition_key]
118
+
119
+ if not isinstance(alternatives, list):
120
+ return schema
121
+
122
+ # Filter to interesting alternatives
123
+ interesting = _filter_composition_alternatives(alternatives, resolver)
124
+
125
+ # If no interesting alternatives, return original
126
+ if not interesting:
127
+ return schema
128
+
129
+ # If exactly one interesting alternative, unwrap it
130
+ if len(interesting) == 1:
131
+ return interesting[0]
132
+
133
+ # Pick the first one
134
+ # TODO: Support multiple alternatives
135
+ return interesting[0]
136
+
137
+
138
+ def try_unwrap_all_of(schema: Mapping[str, Any]) -> Mapping[str, Any]:
139
+ alternatives = schema.get("allOf")
140
+ if not isinstance(alternatives, list):
141
+ return schema
142
+
143
+ interesting = []
144
+
145
+ for subschema in alternatives:
146
+ if isinstance(subschema, dict) and _is_interesting_schema(subschema):
147
+ interesting.append(subschema)
148
+
149
+ if len(interesting) == 1:
150
+ return interesting[0]
151
+ return schema
152
+
153
+
154
+ def _filter_composition_alternatives(alternatives: list[dict], resolver: RefResolver) -> list[dict]:
155
+ """Filter oneOf/anyOf alternatives to keep only interesting schemas."""
156
+ interesting = []
157
+
158
+ for alt_schema in alternatives:
159
+ _, resolved = maybe_resolve(alt_schema, resolver, "")
160
+
161
+ if _is_interesting_schema(resolved):
162
+ # Keep original (with $ref)
163
+ interesting.append(alt_schema)
164
+
165
+ return interesting
166
+
167
+
168
+ def _is_interesting_schema(schema: Mapping[str, Any]) -> bool:
169
+ """Check if a schema represents interesting structured data."""
170
+ # Has $ref - definitely interesting (references a named schema)
171
+ if "$ref" in schema:
172
+ return True
173
+
174
+ ty = schema.get("type")
175
+
176
+ # Primitives are not interesting
177
+ if ty in {"string", "number", "integer", "boolean", "null"}:
178
+ return False
179
+
180
+ # Arrays - check items
181
+ if ty == "array":
182
+ items = schema.get("items")
183
+ if not isinstance(items, dict):
184
+ return False
185
+ # Recursively check if items are interesting
186
+ return _is_interesting_schema(items)
187
+
188
+ # allOf/anyOf/oneOf - interesting (composition)
189
+ if any(key in schema for key in ["allOf", "anyOf", "oneOf"]):
190
+ return True
191
+
192
+ # Objects (or untyped) - check if they have any keywords
193
+ return bool(set(schema).intersection(ALL_KEYWORDS))
194
+
195
+
196
+ @dataclass
197
+ class UnwrappedSchema:
198
+ """Result of wrapper pattern detection."""
199
+
200
+ pointer: str
201
+ schema: Mapping[str, Any]
202
+ ref: str | None
203
+
204
+ __slots__ = ("pointer", "schema", "ref")
205
+
206
+
207
+ def unwrap_schema(
208
+ schema: Mapping[str, Any], path: str, parent_ref: str | None, resolver: RefResolver
209
+ ) -> UnwrappedSchema:
210
+ # Array at root
211
+ if schema.get("type") == "array":
212
+ return UnwrappedSchema(pointer="/", schema=schema, ref=None)
213
+
214
+ properties = schema.get("properties", {})
215
+
216
+ # HAL _embedded (Spring-specific)
217
+ hal_field = _detect_hal_embedded(schema)
218
+ if hal_field:
219
+ embedded_schema = properties["_embedded"]
220
+ _, resolved_embedded = maybe_resolve(embedded_schema, resolver, "")
221
+ resource_schema = resolved_embedded.get("properties", {}).get(hal_field, {})
222
+ _, resolved_resource = maybe_resolve(resource_schema, resolver, "")
223
+
224
+ return UnwrappedSchema(
225
+ pointer=f"/_embedded/{encode_pointer(hal_field)}", schema=resolved_resource, ref=resource_schema.get("$ref")
226
+ )
227
+
228
+ # Pagination wrapper
229
+ array_field = _is_pagination_wrapper(schema=schema, path=path, parent_ref=parent_ref, resolver=resolver)
230
+ if array_field:
231
+ array_schema = properties[array_field]
232
+ _, resolved = maybe_resolve(array_schema, resolver, "")
233
+ pointer = f"/{encode_pointer(array_field)}"
234
+
235
+ uses_parent_ref = False
236
+ # Try to unwrap one more time
237
+ if resolved.get("type") == "array" or "items" in resolved:
238
+ nested_items = resolved.get("items")
239
+ if isinstance(nested_items, dict):
240
+ _, resolved_items = maybe_resolve(nested_items, resolver, "")
241
+ external_tag = _detect_externally_tagged_pattern(resolved_items, path, parent_ref)
242
+ if external_tag:
243
+ external_tag_, uses_parent_ref = external_tag
244
+ nested_properties = resolved_items["properties"][external_tag_]
245
+ _, resolved = maybe_resolve(nested_properties, resolver, "")
246
+ pointer += f"/{encode_pointer(external_tag_)}"
247
+
248
+ ref = parent_ref if uses_parent_ref else array_schema.get("$ref")
249
+ return UnwrappedSchema(pointer=pointer, schema=resolved, ref=array_schema.get("$ref"))
250
+
251
+ # External tag
252
+ external_tag = _detect_externally_tagged_pattern(schema, path, parent_ref)
253
+ if external_tag:
254
+ external_tag_, uses_parent_ref = external_tag
255
+ tagged_schema = properties[external_tag_]
256
+ _, resolved_tagged = maybe_resolve(tagged_schema, resolver, "")
257
+
258
+ resolved = try_unwrap_all_of(resolved_tagged)
259
+ ref = (
260
+ parent_ref
261
+ if uses_parent_ref
262
+ else resolved.get("$ref") or resolved_tagged.get("$ref") or tagged_schema.get("$ref")
263
+ )
264
+
265
+ _, resolved = maybe_resolve(resolved, resolver, "")
266
+ return UnwrappedSchema(pointer=f"/{encode_pointer(external_tag_)}", schema=resolved, ref=ref)
267
+
268
+ # No wrapper - single object at root
269
+ return UnwrappedSchema(pointer="/", schema=schema, ref=schema.get("$ref"))
270
+
271
+
272
+ def _detect_hal_embedded(schema: Mapping[str, Any]) -> str | None:
273
+ """Detect HAL _embedded pattern.
274
+
275
+ Spring Data REST uses: {_embedded: {users: [...]}}
276
+ """
277
+ properties = schema.get("properties", {})
278
+ embedded = properties.get("_embedded")
279
+
280
+ if not isinstance(embedded, dict):
281
+ return None
282
+
283
+ embedded_properties = embedded.get("properties", {})
284
+
285
+ # Find array properties in _embedded
286
+ for name, subschema in embedded_properties.items():
287
+ if isinstance(subschema, dict) and subschema.get("type") == "array":
288
+ # Found array in _embedded
289
+ return name
290
+
291
+ return None
292
+
293
+
294
+ def _is_pagination_wrapper(
295
+ schema: Mapping[str, Any], path: str, parent_ref: str | None, resolver: RefResolver
296
+ ) -> str | None:
297
+ """Detect if schema is a pagination wrapper."""
298
+ properties = schema.get("properties", {})
299
+
300
+ if not properties:
301
+ return None
302
+
303
+ metadata_fields = frozenset(["links", "errors"])
304
+
305
+ # Find array properties
306
+ arrays = []
307
+ for name, subschema in properties.items():
308
+ if name in metadata_fields:
309
+ continue
310
+ if isinstance(subschema, dict):
311
+ _, subschema = maybe_resolve(subschema, resolver, "")
312
+ if subschema.get("type") == "array":
313
+ arrays.append(name)
314
+
315
+ # Must have exactly one array property
316
+ if len(arrays) != 1:
317
+ return None
318
+
319
+ array_field = arrays[0]
320
+
321
+ # Check if array field name matches common patterns
322
+ common_data_fields = {"data", "items", "results", "value", "content", "elements", "records", "list"}
323
+
324
+ if parent_ref:
325
+ resource_name = resource_name_from_ref(parent_ref)
326
+ resource_name = naming.strip_affixes(resource_name, ["get", "create", "list", "delete"], ["response"])
327
+ common_data_fields.add(resource_name.lower())
328
+
329
+ if array_field.lower() not in common_data_fields:
330
+ # Check if field name matches resource-specific pattern
331
+ # Example: path="/items/runner-groups" -> resource="RunnerGroup" -> "runner_groups"
332
+ resource_name_from_path = naming.from_path(path)
333
+ if resource_name_from_path is None:
334
+ return None
335
+
336
+ candidate = naming.to_plural(naming.to_snake_case(resource_name_from_path))
337
+ if array_field.lower() != candidate:
338
+ # Field name doesn't match resource pattern
339
+ return None
340
+
341
+ # Check for pagination metadata indicators
342
+ others = [p for p in properties if p != array_field]
343
+
344
+ pagination_indicators = {
345
+ "count",
346
+ "total",
347
+ "totalcount",
348
+ "total_count",
349
+ "totalelements",
350
+ "total_elements",
351
+ "page",
352
+ "pagenumber",
353
+ "page_number",
354
+ "currentpage",
355
+ "current_page",
356
+ "next",
357
+ "previous",
358
+ "prev",
359
+ "nextpage",
360
+ "prevpage",
361
+ "nextpageurl",
362
+ "prevpageurl",
363
+ "next_page_url",
364
+ "prev_page_url",
365
+ "next_page_token",
366
+ "nextpagetoken",
367
+ "cursor",
368
+ "nextcursor",
369
+ "next_cursor",
370
+ "nextlink",
371
+ "next_link",
372
+ "endcursor",
373
+ "hasmore",
374
+ "has_more",
375
+ "hasnextpage",
376
+ "haspreviouspage",
377
+ "pagesize",
378
+ "page_size",
379
+ "perpage",
380
+ "per_page",
381
+ "limit",
382
+ "size",
383
+ "pageinfo",
384
+ "page_info",
385
+ "pagination",
386
+ "links",
387
+ "meta",
388
+ }
389
+
390
+ # Check if any other property looks like pagination metadata
391
+ has_pagination_metadata = any(
392
+ prop.lower().replace("_", "").replace("-", "") in pagination_indicators for prop in others
393
+ )
394
+
395
+ # Either there is pagination metadata or the wrapper has just items + some other field which is likely an unrecognized metadata
396
+ if has_pagination_metadata or len(properties) <= 2:
397
+ return array_field
398
+
399
+ return None
400
+
401
+
402
+ def _detect_externally_tagged_pattern(
403
+ schema: Mapping[str, Any], path: str, parent_ref: str | None
404
+ ) -> tuple[str, bool] | None:
405
+ """Detect externally tagged resource pattern.
406
+
407
+ Pattern: {ResourceName: [...]} or {resourceName: [...]}
408
+
409
+ Examples:
410
+ - GET /merchants -> {"Merchants": [...]}
411
+ - GET /users -> {"Users": [...]} or {"users": [...]}
412
+
413
+ """
414
+ properties = schema.get("properties", {})
415
+
416
+ if not properties:
417
+ return None
418
+
419
+ resource_name = naming.from_path(path)
420
+
421
+ if not resource_name:
422
+ return None
423
+
424
+ # For example, for `DataRequest`:
425
+ possible_names = {
426
+ # `datarequest`
427
+ resource_name.lower(),
428
+ # `datarequests`
429
+ naming.to_plural(resource_name.lower()),
430
+ # `data_request`
431
+ naming.to_snake_case(resource_name),
432
+ }
433
+ parent_names = set()
434
+ if parent_ref is not None:
435
+ maybe_resource_name = resource_name_from_ref(parent_ref)
436
+ parent_names.add(naming.to_plural(maybe_resource_name.lower()))
437
+ parent_names.add(naming.to_snake_case(maybe_resource_name))
438
+ possible_names = possible_names.union(parent_names)
439
+
440
+ for name, subschema in properties.items():
441
+ if name.lower() not in possible_names:
442
+ continue
443
+
444
+ if isinstance(subschema, dict) and "object" in get_type(subschema):
445
+ return name, name.lower() in parent_names
446
+
447
+ return None
@@ -0,0 +1,254 @@
1
+ """Inferencing connections between API operations.
2
+
3
+ The current implementation extracts information from the `Location` header and
4
+ generates OpenAPI links for exact and prefix matches.
5
+
6
+ When a `Location` header points to `/users/123`, the inference:
7
+
8
+ 1. Finds the exact match: `GET /users/{userId}`
9
+ 2. Finds prefix matches: `GET /users/{userId}/posts`, `GET /users/{userId}/posts/{postId}`
10
+ 3. Generates OpenAPI links with regex parameter extractors
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Any, Mapping, Union
18
+ from urllib.parse import urlsplit
19
+
20
+ from werkzeug.exceptions import MethodNotAllowed, NotFound
21
+ from werkzeug.routing import Map, MapAdapter, Rule
22
+
23
+ from schemathesis.core.adapter import ResponsesContainer
24
+ from schemathesis.core.transforms import encode_pointer
25
+ from schemathesis.specs.openapi.stateful.links import SCHEMATHESIS_LINK_EXTENSION
26
+
27
+ if TYPE_CHECKING:
28
+ from schemathesis.engine.observations import LocationHeaderEntry
29
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
30
+
31
+
32
+ @dataclass(unsafe_hash=True)
33
+ class OperationById:
34
+ """API operation identified by operationId."""
35
+
36
+ value: str
37
+ method: str
38
+ path: str
39
+
40
+ __slots__ = ("value", "method", "path")
41
+
42
+ def to_link_base(self) -> dict[str, Any]:
43
+ return {"operationId": self.value, SCHEMATHESIS_LINK_EXTENSION: {"is_inferred": True}}
44
+
45
+
46
+ @dataclass(unsafe_hash=True)
47
+ class OperationByRef:
48
+ """API operation identified by JSON reference path."""
49
+
50
+ value: str
51
+ method: str
52
+ path: str
53
+
54
+ __slots__ = ("value", "method", "path")
55
+
56
+ def to_link_base(self) -> dict[str, Any]:
57
+ return {"operationRef": self.value, SCHEMATHESIS_LINK_EXTENSION: {"is_inferred": True}}
58
+
59
+
60
+ OperationReference = Union[OperationById, OperationByRef]
61
+ # Method, path, response code, sorted path parameter names
62
+ SeenLinkKey = tuple[str, str, int, tuple[str, ...]]
63
+
64
+
65
+ @dataclass
66
+ class MatchList:
67
+ """Results of matching a location path against API operation."""
68
+
69
+ exact: OperationReference
70
+ inexact: list[OperationReference]
71
+ parameters: Mapping[str, Any]
72
+
73
+ __slots__ = ("exact", "inexact", "parameters")
74
+
75
+
76
+ @dataclass
77
+ class LinkInferencer:
78
+ """Infer OpenAPI links from Location headers for stateful testing."""
79
+
80
+ _adapter: MapAdapter
81
+ # All API operations for prefix matching
82
+ _operations: list[OperationReference]
83
+ _base_url: str | None
84
+ _base_path: str
85
+ _links_keyword: str
86
+
87
+ __slots__ = ("_adapter", "_operations", "_base_url", "_base_path", "_links_keyword")
88
+
89
+ @classmethod
90
+ def from_schema(cls, schema: BaseOpenAPISchema) -> LinkInferencer:
91
+ # NOTE: Use `matchit` for routing in the future
92
+ rules = []
93
+ operations = []
94
+ for method, path, definition in schema._operation_iter():
95
+ operation_id = definition.get("operationId")
96
+ operation: OperationById | OperationByRef
97
+ if operation_id:
98
+ operation = OperationById(operation_id, method=method, path=path)
99
+ else:
100
+ encoded_path = encode_pointer(path)
101
+ operation = OperationByRef(f"#/paths/{encoded_path}/{method}", method=method, path=path)
102
+
103
+ operations.append(operation)
104
+
105
+ # Replace `{parameter}` with `<parameter>` as angle brackets are used for parameters in werkzeug
106
+ path = re.sub(r"\{([^}]+)\}", r"<\1>", path)
107
+ rules.append(Rule(path, endpoint=operation, methods=[method.upper()]))
108
+
109
+ return cls(
110
+ _adapter=Map(rules).bind("", ""),
111
+ _operations=operations,
112
+ _base_url=schema.config.base_url,
113
+ _base_path=schema.base_path,
114
+ _links_keyword=schema.adapter.links_keyword,
115
+ )
116
+
117
+ def match(self, path: str) -> tuple[OperationReference, Mapping[str, str]] | None:
118
+ """Match path to API operation and extract path parameters."""
119
+ try:
120
+ return self._adapter.match(path)
121
+ except (NotFound, MethodNotAllowed):
122
+ return None
123
+
124
+ def _build_links_from_matches(self, matches: MatchList) -> list[dict]:
125
+ """Build links from already-found matches."""
126
+ exact = self._build_link_from_match(matches.exact, matches.parameters)
127
+ parameters = exact["parameters"]
128
+ links = [exact]
129
+ for inexact in matches.inexact:
130
+ link = inexact.to_link_base()
131
+ # Parameter extraction is the same, only operations are different
132
+ link["parameters"] = parameters
133
+ links.append(link)
134
+ return links
135
+
136
+ def _find_matches_from_normalized_location(self, normalized_location: str) -> MatchList | None:
137
+ """Find matches from an already-normalized location."""
138
+ match = self.match(normalized_location)
139
+ if not match:
140
+ # It may happen that there is no match, but it is unlikely as the API assumed to return a valid Location
141
+ # that points to an existing API operation. In such cases, if they appear in practice the logic here could be extended
142
+ # to support partial matches
143
+ return None
144
+ exact, parameters = match
145
+ if not parameters:
146
+ # Links without parameters don't make sense
147
+ return None
148
+ matches = MatchList(exact=exact, inexact=[], parameters=parameters)
149
+
150
+ # Find prefix matches, excluding the exact match
151
+ # For example:
152
+ #
153
+ # Location: /users/123 -> /users/{user_id} (exact match)
154
+ # /users/{user_id}/posts , /users/{user_id}/posts/{post_id} (partial matches)
155
+ #
156
+ for candidate in self._operations:
157
+ if candidate == exact:
158
+ continue
159
+ if candidate.path.startswith(exact.path):
160
+ matches.inexact.append(candidate)
161
+
162
+ return matches
163
+
164
+ def _build_link_from_match(
165
+ self, operation: OperationById | OperationByRef, path_parameters: Mapping[str, Any]
166
+ ) -> dict:
167
+ link = operation.to_link_base()
168
+
169
+ # Build regex expressions to extract path parameters
170
+ parameters = {}
171
+ for name in path_parameters:
172
+ # Replace the target parameter with capture group and others with non-slash matcher
173
+ pattern = operation.path
174
+ for candidate in path_parameters:
175
+ if candidate == name:
176
+ pattern = pattern.replace(f"{{{candidate}}}", "(.+)")
177
+ else:
178
+ pattern = pattern.replace(f"{{{candidate}}}", "[^/]+")
179
+
180
+ parameters[name] = f"$response.header.Location#regex:{pattern}"
181
+
182
+ link["parameters"] = parameters
183
+
184
+ return link
185
+
186
+ def _normalize_location(self, location: str) -> str | None:
187
+ """Normalize location header, handling both relative and absolute URLs."""
188
+ location = location.strip()
189
+ if not location:
190
+ return None
191
+
192
+ # Check if it's an absolute URL
193
+ if location.startswith(("http://", "https://")):
194
+ if not self._base_url:
195
+ # Can't validate absolute URLs without base_url
196
+ return None
197
+
198
+ parsed = urlsplit(location)
199
+ base_parsed = urlsplit(self._base_url)
200
+
201
+ # Must match scheme, netloc, and start with the base path
202
+ if parsed.scheme != base_parsed.scheme or parsed.netloc != base_parsed.netloc:
203
+ return None
204
+
205
+ return self._strip_base_path_from_location(parsed.path)
206
+
207
+ # Relative URL - strip base path if present, otherwise use as-is
208
+ stripped = self._strip_base_path_from_location(location)
209
+ return stripped if stripped is not None else location
210
+
211
+ def _strip_base_path_from_location(self, path: str) -> str | None:
212
+ """Strip base path from location path if it starts with base path."""
213
+ base_path = self._base_path.rstrip("/")
214
+ if not path.startswith(base_path):
215
+ return None
216
+
217
+ # Strip the base path to get relative path
218
+ relative_path = path[len(base_path) :]
219
+ return relative_path if relative_path.startswith("/") else "/" + relative_path
220
+
221
+ def inject_links(self, responses: ResponsesContainer, entries: list[LocationHeaderEntry]) -> int:
222
+ # To avoid unnecessary work, we need to skip entries that we know will produce already inferred links
223
+ seen: set[SeenLinkKey] = set()
224
+ injected = 0
225
+
226
+ for entry in entries:
227
+ location = self._normalize_location(entry.value)
228
+ if location is None:
229
+ # Skip invalid/empty locations or absolute URLs that don't match base_url
230
+ continue
231
+
232
+ matches = self._find_matches_from_normalized_location(location)
233
+ if matches is None:
234
+ # Skip locations that don't match any API apiration
235
+ continue
236
+
237
+ key = (matches.exact.method, matches.exact.path, entry.status_code, tuple(sorted(matches.parameters)))
238
+ if key in seen:
239
+ # Skip duplicate link generation for same operation/status/parameters combination
240
+ continue
241
+ seen.add(key)
242
+ # Find the right bucket for the response status or create a new one
243
+ response = responses.find_by_status_code(entry.status_code)
244
+ links: dict[str, dict[str, dict]]
245
+ if response is None:
246
+ links = {}
247
+ responses.add(str(entry.status_code), {self._links_keyword: links})
248
+ else:
249
+ links = response.definition.setdefault(self._links_keyword, {})
250
+
251
+ for idx, link in enumerate(self._build_links_from_matches(matches)):
252
+ links[f"X-Inferred-Link-{idx}"] = link
253
+ injected += 1
254
+ return injected