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.
- schemathesis/__init__.py +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1219
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +682 -257
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +26 -2
- schemathesis/specs/graphql/scalars.py +77 -12
- schemathesis/specs/graphql/schemas.py +367 -148
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +555 -318
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +748 -82
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +93 -73
- schemathesis/specs/openapi/negative/mutations.py +294 -103
- schemathesis/specs/openapi/negative/utils.py +0 -9
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +647 -666
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +403 -68
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -57
- schemathesis/_hypothesis.py +0 -123
- schemathesis/auth.py +0 -214
- schemathesis/cli/callbacks.py +0 -240
- schemathesis/cli/cassettes.py +0 -351
- schemathesis/cli/context.py +0 -38
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -70
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -521
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -88
- schemathesis/exceptions.py +0 -257
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -251
- schemathesis/failures.py +0 -145
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/graphql.py +0 -5
- schemathesis/internal.py +0 -6
- schemathesis/lazy.py +0 -301
- schemathesis/models.py +0 -1113
- schemathesis/parameters.py +0 -91
- schemathesis/runner/__init__.py +0 -470
- schemathesis/runner/events.py +0 -242
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -791
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -206
- schemathesis/serializers.py +0 -253
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -10
- schemathesis/service/client.py +0 -62
- schemathesis/service/constants.py +0 -25
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -46
- schemathesis/service/hosts.py +0 -74
- schemathesis/service/metadata.py +0 -42
- schemathesis/service/models.py +0 -21
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/worker.py +0 -39
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -303
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -430
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -358
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -475
- schemathesis-3.15.4.dist-info/METADATA +0 -202
- schemathesis-3.15.4.dist-info/RECORD +0 -99
- schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
- /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
|