ghost-api-cli 0.2.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.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""Everything the templates need to render a client, derived from the endpoint map.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
- Only read-only endpoints become methods (GET, and search/query POSTs), the same
|
|
5
|
+
rule the replayer uses. Endpoints that could not be replayed are left out.
|
|
6
|
+
- Path parameters and changing required parameters are required arguments.
|
|
7
|
+
- Parameters that never changed get their observed value as a default.
|
|
8
|
+
- The pagination parameter is optional and starts at the observed value.
|
|
9
|
+
- Secret-looking parameters/headers become credentials: constructor arguments that
|
|
10
|
+
default to an environment variable. Their values are never written (NFR-16).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
21
|
+
|
|
22
|
+
from ghost_api.analysis.analyzer import endpoint_keys
|
|
23
|
+
from ghost_api.analysis.graphql import graphql_payload, split_template
|
|
24
|
+
from ghost_api.analysis.models import Endpoint
|
|
25
|
+
from ghost_api.analysis.url_grouping import looks_like_id
|
|
26
|
+
from ghost_api.capture.filters import REDACTED, is_sensitive_name
|
|
27
|
+
from ghost_api.capture.models import CapturedRequest, RawRequest
|
|
28
|
+
from ghost_api.generator.models_gen import ModelBuilder, ModelDef
|
|
29
|
+
from ghost_api.generator.naming import EndpointNames, snake, unique
|
|
30
|
+
from ghost_api.probe.replay import is_replayable
|
|
31
|
+
|
|
32
|
+
PY_TYPES = {"integer": "int", "number": "float", "boolean": "bool", "string": "str"}
|
|
33
|
+
# Headers the client sets itself, or that only browsers send.
|
|
34
|
+
SKIP_HEADERS = {
|
|
35
|
+
"content-type",
|
|
36
|
+
"content-length",
|
|
37
|
+
"user-agent",
|
|
38
|
+
"accept-encoding",
|
|
39
|
+
"host",
|
|
40
|
+
"connection",
|
|
41
|
+
"cookie",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Fields are generated as always present only with enough evidence; otherwise every field
|
|
46
|
+
# may be missing. Found on Turf Town: a field present in all 24 venues of one area (over 4
|
|
47
|
+
# pages) was missing for a venue in another area. Evidence is replies to at least 2
|
|
48
|
+
# different inputs (paging aside) across MIN_REPLIES_FOR_REQUIRED replies, or simply
|
|
49
|
+
# MANY_REPLIES replies.
|
|
50
|
+
MIN_REPLIES_FOR_REQUIRED = 3
|
|
51
|
+
MANY_REPLIES = 6
|
|
52
|
+
_PAGING_KEYS = {
|
|
53
|
+
"page", "offset", "skip", "limit", "cursor", "start", "per_page", "perpage", "page_size",
|
|
54
|
+
"pagesize", "size", "after", "before", "from", "hitsperpage", "hits_per_page",
|
|
55
|
+
} # fmt: skip
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def trust_required(reqs: list[CapturedRequest]) -> bool:
|
|
59
|
+
"""Is "seen in every sample" good evidence for "always present" for this endpoint?"""
|
|
60
|
+
replies = {r.resp_body for r in reqs}
|
|
61
|
+
if len(replies) >= MANY_REPLIES:
|
|
62
|
+
return True
|
|
63
|
+
inputs = {_input_signature(r) for r in reqs}
|
|
64
|
+
return len(replies) >= MIN_REPLIES_FOR_REQUIRED and len(inputs) >= 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _input_signature(req: CapturedRequest) -> str:
|
|
68
|
+
query = {
|
|
69
|
+
k: v
|
|
70
|
+
for k, v in parse_qsl(urlsplit(req.url).query, keep_blank_values=True)
|
|
71
|
+
if k.lower() not in _PAGING_KEYS
|
|
72
|
+
}
|
|
73
|
+
body = _json_dict(req.req_body) or {}
|
|
74
|
+
body = {k: v for k, v in body.items() if k.lower() not in _PAGING_KEYS}
|
|
75
|
+
return json.dumps([sorted(query.items()), body], sort_keys=True, default=str)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ParamSpec:
|
|
80
|
+
name: str # Python argument name
|
|
81
|
+
wire: str # name on the wire
|
|
82
|
+
location: Literal["path", "query", "body", "variable"] # variable: a GraphQL variable
|
|
83
|
+
type: str # Python type, e.g. "int"
|
|
84
|
+
required: bool
|
|
85
|
+
default: str | None = None # Python literal, when there is one
|
|
86
|
+
example: str | None = None # Python literal used by generated tests
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def mutable_default(self) -> bool:
|
|
90
|
+
return bool(self.default) and self.default[:1] in ("[", "{") # type: ignore[index]
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def annotation(self) -> str:
|
|
94
|
+
if self.required or (self.default not in (None, "None") and not self.mutable_default):
|
|
95
|
+
return self.type
|
|
96
|
+
return f"{self.type} | None"
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def default_literal(self) -> str:
|
|
100
|
+
if self.default is None or self.mutable_default:
|
|
101
|
+
return "None"
|
|
102
|
+
return self.default
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def value_expr(self) -> str:
|
|
106
|
+
"""The value to send: list/dict defaults are filled in at call time."""
|
|
107
|
+
if self.mutable_default:
|
|
108
|
+
return f"{self.name} if {self.name} is not None else {self.default}"
|
|
109
|
+
return self.name
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class CredentialSpec:
|
|
114
|
+
arg: str # constructor argument
|
|
115
|
+
wire: str # header or query parameter name
|
|
116
|
+
location: Literal["header", "query"]
|
|
117
|
+
env: str # environment variable that supplies it
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class MethodSpec:
|
|
122
|
+
name: str
|
|
123
|
+
endpoint_key: str # Endpoint.key this method calls
|
|
124
|
+
summary: str
|
|
125
|
+
http_method: str
|
|
126
|
+
host: str
|
|
127
|
+
path: str # template with {python_name} placeholders
|
|
128
|
+
params: list[ParamSpec]
|
|
129
|
+
response_type: str
|
|
130
|
+
headers: dict[str, str]
|
|
131
|
+
credentials: list[str] # CredentialSpec.arg values this method needs
|
|
132
|
+
pagination: dict[str, Any] | None = None
|
|
133
|
+
page_param: ParamSpec | None = None
|
|
134
|
+
replayable: bool | None = None
|
|
135
|
+
# GraphQL: the operation sent with every call (operationName, query, extensions); the
|
|
136
|
+
# method's "variable" parameters fill in its variables.
|
|
137
|
+
graphql: dict[str, Any] | None = None
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def required(self) -> list[ParamSpec]:
|
|
141
|
+
return [p for p in self.params if p.required]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def optional(self) -> list[ParamSpec]:
|
|
145
|
+
return [p for p in self.params if not p.required]
|
|
146
|
+
|
|
147
|
+
def by_location(self, location: str) -> list[ParamSpec]:
|
|
148
|
+
return [p for p in self.params if p.location == location]
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def testable(self) -> bool:
|
|
152
|
+
return self.replayable is not False and all(p.example is not None for p in self.required)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class ClientSpec:
|
|
157
|
+
class_name: str
|
|
158
|
+
domain: str
|
|
159
|
+
base_url: str
|
|
160
|
+
methods: list[MethodSpec] = field(default_factory=list)
|
|
161
|
+
models: list[ModelDef] = field(default_factory=list)
|
|
162
|
+
credentials: list[CredentialSpec] = field(default_factory=list)
|
|
163
|
+
skipped: list[str] = field(default_factory=list) # endpoint labels left out, with why
|
|
164
|
+
version: str = "1.0.0" # the client's version (drift.py), used by the packages
|
|
165
|
+
changelog: str = "" # CHANGELOG.md for the packages
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def error_class(self) -> str:
|
|
169
|
+
return self.class_name.removesuffix("Client") + "Error"
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def error_classes(self) -> dict[str, str]:
|
|
173
|
+
"""Typed errors, each a subclass of `error_class`: kind -> class name."""
|
|
174
|
+
stem = self.class_name.removesuffix("Client")
|
|
175
|
+
return {
|
|
176
|
+
"auth": f"{stem}AuthError", # 401, 403
|
|
177
|
+
"not_found": f"{stem}NotFoundError", # 404
|
|
178
|
+
"rate_limit": f"{stem}RateLimitError", # 429
|
|
179
|
+
"server": f"{stem}ServerError", # 5xx
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def async_class(self) -> str:
|
|
184
|
+
return f"Async{self.class_name}"
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def reserved_names(self) -> set[str]:
|
|
188
|
+
return {self.class_name, self.async_class, self.error_class, *self.error_classes.values()}
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def model_names(self) -> list[str]:
|
|
192
|
+
return [m.name for m in self.models]
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def client_imports(self) -> list[str]:
|
|
196
|
+
"""Models the client module refers to directly (response types)."""
|
|
197
|
+
used = " ".join(m.response_type for m in self.methods)
|
|
198
|
+
return [n for n in self.model_names if re.search(rf"\b{n}\b", used)]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_spec(
|
|
202
|
+
domain: str,
|
|
203
|
+
start_url: str,
|
|
204
|
+
endpoints: list[Endpoint],
|
|
205
|
+
requests: list[CapturedRequest],
|
|
206
|
+
class_name: str,
|
|
207
|
+
names: dict[str, EndpointNames],
|
|
208
|
+
allow: set[str] | None = None,
|
|
209
|
+
) -> ClientSpec:
|
|
210
|
+
"""`allow`: endpoints the user has checked only read data, by label
|
|
211
|
+
("POST /api/search" or "POST api.example.com/search"). Ghost API never decides
|
|
212
|
+
that by itself for a POST without a read word in its URL."""
|
|
213
|
+
parts = urlsplit(start_url)
|
|
214
|
+
spec = ClientSpec(class_name, domain, f"{parts.scheme}://{parts.netloc}")
|
|
215
|
+
main_host = (parts.hostname or "").lower()
|
|
216
|
+
keys = endpoint_keys(requests)
|
|
217
|
+
samples: dict[str, list[CapturedRequest]] = defaultdict(list)
|
|
218
|
+
for key, req in zip(keys, requests, strict=False):
|
|
219
|
+
samples[key].append(req)
|
|
220
|
+
|
|
221
|
+
builder = ModelBuilder(reserved_names=spec.reserved_names)
|
|
222
|
+
env_prefix = snake(class_name.removesuffix("Client")).upper()
|
|
223
|
+
creds: dict[tuple[str, str], CredentialSpec] = {}
|
|
224
|
+
|
|
225
|
+
for ep in endpoints:
|
|
226
|
+
reqs = samples.get(ep.key, [])
|
|
227
|
+
reason = _skip_reason(ep, reqs, allowed=is_allowed(ep, allow or set()))
|
|
228
|
+
if reason:
|
|
229
|
+
spec.skipped.append(f"{ep.method} {ep.host}{ep.path_template}: {reason}")
|
|
230
|
+
continue
|
|
231
|
+
n = names[ep.key]
|
|
232
|
+
used: set[str] = {"self"}
|
|
233
|
+
params: list[ParamSpec] = []
|
|
234
|
+
path, operation = split_template(ep.path_template)
|
|
235
|
+
for pp in ep.path_params:
|
|
236
|
+
arg = unique(snake(pp.name), used)
|
|
237
|
+
path = path.replace("{" + pp.name + "}", "{" + arg + "}")
|
|
238
|
+
params.append(
|
|
239
|
+
ParamSpec(
|
|
240
|
+
arg,
|
|
241
|
+
pp.name,
|
|
242
|
+
"path",
|
|
243
|
+
PY_TYPES.get(pp.type, "str"),
|
|
244
|
+
True,
|
|
245
|
+
example=_literal(pp.examples[0], pp.type) if pp.examples else None,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
method_creds: list[str] = []
|
|
250
|
+
page = ep.pagination or {}
|
|
251
|
+
for qp in ep.query_params:
|
|
252
|
+
if qp.sensitive or is_sensitive_name(qp.name):
|
|
253
|
+
method_creds.append(_credential(creds, qp.name, "query", env_prefix))
|
|
254
|
+
continue
|
|
255
|
+
arg = unique(snake(qp.name), used)
|
|
256
|
+
first = qp.examples[0] if qp.examples else None
|
|
257
|
+
literal = _literal(first, qp.type) if first is not None else None
|
|
258
|
+
is_page = page.get("location") == "query" and page.get("param") == qp.name
|
|
259
|
+
if qp.constant or is_page:
|
|
260
|
+
# A cursor starts empty (first page); page/offset start where we saw them.
|
|
261
|
+
cursor = is_page and page.get("style") == "cursor"
|
|
262
|
+
start = None if cursor else literal
|
|
263
|
+
params.append(
|
|
264
|
+
ParamSpec(
|
|
265
|
+
arg,
|
|
266
|
+
qp.name,
|
|
267
|
+
"query",
|
|
268
|
+
# Cursors are opaque tokens, even when they look like numbers: the
|
|
269
|
+
# reply's next_cursor is passed back as it came.
|
|
270
|
+
"str" if cursor else PY_TYPES.get(qp.type, "str"),
|
|
271
|
+
False,
|
|
272
|
+
default=start,
|
|
273
|
+
example=start,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
required = qp.required and is_user_input(qp.name, qp.examples)
|
|
278
|
+
params.append(
|
|
279
|
+
ParamSpec(
|
|
280
|
+
arg,
|
|
281
|
+
qp.name,
|
|
282
|
+
"query",
|
|
283
|
+
PY_TYPES.get(qp.type, "str"),
|
|
284
|
+
required,
|
|
285
|
+
# Optional settings default to a value seen working.
|
|
286
|
+
default=None if required or not qp.required else literal,
|
|
287
|
+
example=literal,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
graphql: dict[str, Any] | None = None
|
|
292
|
+
if operation:
|
|
293
|
+
payloads = [
|
|
294
|
+
p for p in (graphql_payload(r.method, r.url, r.req_body) for r in reqs) if p
|
|
295
|
+
]
|
|
296
|
+
graphql = {k: payloads[-1][k] for k in ("operationName", "query", "extensions")
|
|
297
|
+
if payloads and payloads[-1].get(k)} # fmt: skip
|
|
298
|
+
graphql.setdefault("operationName", operation)
|
|
299
|
+
variables = [p["variables"] for p in payloads if isinstance(p.get("variables"), dict)]
|
|
300
|
+
params += _value_params(variables, used, {}, "variable")
|
|
301
|
+
else:
|
|
302
|
+
params += _body_params(reqs, used, page)
|
|
303
|
+
|
|
304
|
+
headers: dict[str, str] = {}
|
|
305
|
+
sample_headers = reqs[-1].req_headers if reqs else {}
|
|
306
|
+
for h in ep.required_headers:
|
|
307
|
+
if h.lower() == "cookie" or is_sensitive_name(h):
|
|
308
|
+
method_creds.append(_credential(creds, h, "header", env_prefix))
|
|
309
|
+
elif h.lower() not in SKIP_HEADERS:
|
|
310
|
+
value = next((v for k, v in sample_headers.items() if k.lower() == h.lower()), "")
|
|
311
|
+
if value and value != REDACTED:
|
|
312
|
+
headers[h] = value
|
|
313
|
+
|
|
314
|
+
builder.trust_required = trust_required(reqs)
|
|
315
|
+
response_type = builder.root_type(ep.response_schema, n.response_model, n.summary)
|
|
316
|
+
builder.trust_required = True
|
|
317
|
+
page_param = next(
|
|
318
|
+
(
|
|
319
|
+
p
|
|
320
|
+
for p in params
|
|
321
|
+
if page and p.wire == page.get("param") and p.location == page.get("location")
|
|
322
|
+
),
|
|
323
|
+
None,
|
|
324
|
+
)
|
|
325
|
+
spec.methods.append(
|
|
326
|
+
MethodSpec(
|
|
327
|
+
name=n.method_name,
|
|
328
|
+
endpoint_key=ep.key,
|
|
329
|
+
summary=n.summary,
|
|
330
|
+
http_method=ep.method,
|
|
331
|
+
host="" if ep.host == main_host else ep.host,
|
|
332
|
+
path=path,
|
|
333
|
+
params=params,
|
|
334
|
+
response_type=response_type,
|
|
335
|
+
headers=headers,
|
|
336
|
+
credentials=sorted(set(method_creds)),
|
|
337
|
+
pagination=page or None,
|
|
338
|
+
page_param=page_param if page.get("confirmed") else None,
|
|
339
|
+
replayable=ep.replayable,
|
|
340
|
+
graphql=graphql,
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
spec.models = builder.models
|
|
344
|
+
spec.credentials = sorted(creds.values(), key=lambda c: c.arg)
|
|
345
|
+
return spec
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def is_allowed(ep: Endpoint, allow: set[str]) -> bool:
|
|
349
|
+
labels = {
|
|
350
|
+
ep.key,
|
|
351
|
+
f"{ep.method} {ep.path_template}",
|
|
352
|
+
f"{ep.method} {ep.host}{ep.path_template}",
|
|
353
|
+
ep.label,
|
|
354
|
+
}
|
|
355
|
+
return bool(labels & {a.strip() for a in allow})
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _skip_reason(ep: Endpoint, reqs: list[CapturedRequest], allowed: bool = False) -> str | None:
|
|
359
|
+
probe = RawRequest(ep.method, f"https://{ep.host}{ep.path_template}", {}, None)
|
|
360
|
+
if not allowed and not is_replayable(probe):
|
|
361
|
+
return "may change data, so no method is generated (use --allow if it only reads)"
|
|
362
|
+
if not allowed and ep.replayable is False:
|
|
363
|
+
return "could not be called outside the browser"
|
|
364
|
+
if ep.method == "POST" and reqs and not _json_dict(reqs[-1].req_body) and reqs[-1].req_body:
|
|
365
|
+
return "request body is not JSON"
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _body_params(
|
|
370
|
+
reqs: list[CapturedRequest], used: set[str], page: dict[str, Any]
|
|
371
|
+
) -> list[ParamSpec]:
|
|
372
|
+
bodies = [b for b in (_json_dict(r.req_body) for r in reqs) if b is not None]
|
|
373
|
+
return _value_params(bodies, used, page, "body")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _value_params(
|
|
377
|
+
bodies: list[dict[str, Any]],
|
|
378
|
+
used: set[str],
|
|
379
|
+
page: dict[str, Any],
|
|
380
|
+
location: Literal["body", "variable"],
|
|
381
|
+
) -> list[ParamSpec]:
|
|
382
|
+
"""Parameters from the keys of JSON bodies (or GraphQL variables) seen in requests."""
|
|
383
|
+
if not bodies:
|
|
384
|
+
return []
|
|
385
|
+
values: dict[str, list[Any]] = defaultdict(list)
|
|
386
|
+
for body in bodies:
|
|
387
|
+
for k, v in body.items():
|
|
388
|
+
values[k].append(v)
|
|
389
|
+
params = []
|
|
390
|
+
for key, seen in values.items():
|
|
391
|
+
if is_sensitive_name(key) or any(v == REDACTED for v in seen):
|
|
392
|
+
continue # never generate secrets; rare in bodies
|
|
393
|
+
arg = unique(snake(key), used)
|
|
394
|
+
py_type = _py_type(seen)
|
|
395
|
+
literal = repr(seen[0]) if _is_literal(seen[0]) else None
|
|
396
|
+
constant = len(bodies) > 1 and all(v == seen[0] for v in seen)
|
|
397
|
+
is_page = page.get("location") == "body" and page.get("param") == key
|
|
398
|
+
if constant or is_page or len(seen) < len(bodies):
|
|
399
|
+
params.append(
|
|
400
|
+
ParamSpec(
|
|
401
|
+
arg,
|
|
402
|
+
key,
|
|
403
|
+
location,
|
|
404
|
+
py_type,
|
|
405
|
+
False,
|
|
406
|
+
default=literal if (constant or is_page) else None,
|
|
407
|
+
example=literal,
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
elif is_user_input(key, [str(v) for v in seen]):
|
|
411
|
+
params.append(ParamSpec(arg, key, location, py_type, True, example=literal))
|
|
412
|
+
else:
|
|
413
|
+
# Varies between captured requests but isn't user input (a setting or
|
|
414
|
+
# filter): optional, defaulting to a value seen working.
|
|
415
|
+
params.append(
|
|
416
|
+
ParamSpec(arg, key, location, py_type, False, default=literal, example=literal)
|
|
417
|
+
)
|
|
418
|
+
return params
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# Names of parameters a caller is expected to supply.
|
|
422
|
+
_INPUT_NAMES = {
|
|
423
|
+
"q", "query", "search", "searchterm", "search_query", "term", "keyword", "keywords",
|
|
424
|
+
"text", "id", "slug", "key", "sku", "username", "user", "name", "url",
|
|
425
|
+
} # fmt: skip
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def is_user_input(name: str, examples: list[str]) -> bool:
|
|
429
|
+
"""True for search terms and identifiers; False for settings like sort or filters."""
|
|
430
|
+
n = snake(name)
|
|
431
|
+
if n in _INPUT_NAMES or n.endswith(("_id", "_slug", "_query")):
|
|
432
|
+
return True
|
|
433
|
+
# ID-shaped strings (UUIDs, hashes) are inputs too; plain numbers like 1 or 30 are
|
|
434
|
+
# usually settings, so they don't count on their own.
|
|
435
|
+
return bool(examples) and all(
|
|
436
|
+
looks_like_id(str(v)) and not str(v).lstrip("-").isdigit() for v in examples
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _credential(
|
|
441
|
+
creds: dict[tuple[str, str], CredentialSpec], wire: str, location: str, prefix: str
|
|
442
|
+
) -> str:
|
|
443
|
+
key = (wire.lower(), location)
|
|
444
|
+
if key not in creds:
|
|
445
|
+
arg = snake(wire)
|
|
446
|
+
creds[key] = CredentialSpec(arg, wire, location, f"{prefix}_{arg.upper()}") # type: ignore[arg-type]
|
|
447
|
+
return creds[key].arg
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _json_dict(body: str | None) -> dict[str, Any] | None:
|
|
451
|
+
try:
|
|
452
|
+
data = json.loads(body or "")
|
|
453
|
+
except ValueError:
|
|
454
|
+
return None
|
|
455
|
+
return data if isinstance(data, dict) else None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _literal(value: str, json_type: str) -> str:
|
|
459
|
+
if json_type == "integer":
|
|
460
|
+
return str(int(value))
|
|
461
|
+
if json_type == "number":
|
|
462
|
+
return str(float(value))
|
|
463
|
+
if json_type == "boolean":
|
|
464
|
+
return str(value.lower() == "true")
|
|
465
|
+
return repr(value)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _is_literal(value: Any) -> bool:
|
|
469
|
+
return isinstance(value, str | int | float | bool | type(None) | list | dict)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _py_type(values: list[Any]) -> str:
|
|
473
|
+
kinds = {type(v) for v in values if v is not None}
|
|
474
|
+
if kinds == {bool}:
|
|
475
|
+
return "bool"
|
|
476
|
+
if kinds == {int}:
|
|
477
|
+
return "int"
|
|
478
|
+
if kinds <= {int, float} and kinds:
|
|
479
|
+
return "float"
|
|
480
|
+
if kinds == {str}:
|
|
481
|
+
return "str"
|
|
482
|
+
if kinds == {list}:
|
|
483
|
+
return "list[Any]"
|
|
484
|
+
if kinds == {dict}:
|
|
485
|
+
return "dict[str, Any]"
|
|
486
|
+
return "Any"
|