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,518 @@
|
|
|
1
|
+
"""{{ spec.class_name }}: a typed Python client for {{ spec.domain }}.
|
|
2
|
+
|
|
3
|
+
Generated by Ghost API {{ version }} on {{ generated_on }} from the site's own traffic.
|
|
4
|
+
Two clients with the same methods: `{{ spec.class_name }}` (regular code) and
|
|
5
|
+
`{{ spec.async_class }}` (asyncio). Errors are typed: catch `{{ spec.error_class }}` for
|
|
6
|
+
any failure, or `{{ spec.error_classes.not_found }}`, `{{ spec.error_classes.auth }}`,
|
|
7
|
+
`{{ spec.error_classes.rate_limit }}` or `{{ spec.error_classes.server }}` for one kind.
|
|
8
|
+
|
|
9
|
+
This is an unofficial client for a website's internal API. It can change without
|
|
10
|
+
notice, and the site's terms of service still apply. Re-run `ghost-api run` to refresh.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
{% if spec.credentials %}
|
|
17
|
+
import os
|
|
18
|
+
{% endif %}
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
{% set paged = spec.methods | selectattr("page_param") | list %}
|
|
22
|
+
{% if paged %}
|
|
23
|
+
from collections.abc import AsyncIterator, Iterator
|
|
24
|
+
{% endif %}
|
|
25
|
+
from typing import Any
|
|
26
|
+
{% if spec.methods | map(attribute="params") | sum(start=[]) | selectattr("location", "equalto", "path") | list %}
|
|
27
|
+
from urllib.parse import quote
|
|
28
|
+
{% endif %}
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
from pydantic import BaseModel, TypeAdapter
|
|
32
|
+
|
|
33
|
+
{% if spec.client_imports and packaged %}
|
|
34
|
+
from .models import (
|
|
35
|
+
{% for name in spec.client_imports %}
|
|
36
|
+
{{ name }},
|
|
37
|
+
{% endfor %}
|
|
38
|
+
)
|
|
39
|
+
{% elif spec.client_imports %}
|
|
40
|
+
try:
|
|
41
|
+
from .models import (
|
|
42
|
+
{% for name in spec.client_imports %}
|
|
43
|
+
{{ name }},
|
|
44
|
+
{% endfor %}
|
|
45
|
+
)
|
|
46
|
+
except ImportError: # used as loose files rather than a package
|
|
47
|
+
from models import ( # type: ignore[no-redef, unused-ignore]
|
|
48
|
+
{% for name in spec.client_imports %}
|
|
49
|
+
{{ name }},
|
|
50
|
+
{% endfor %}
|
|
51
|
+
)
|
|
52
|
+
{% endif %}
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"{{ spec.class_name }}",
|
|
56
|
+
"{{ spec.async_class }}",
|
|
57
|
+
"{{ spec.error_class }}",
|
|
58
|
+
{% for name in spec.error_classes.values() %}
|
|
59
|
+
"{{ name }}",
|
|
60
|
+
{% endfor %}
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
USER_AGENT = "{{ user_agent }}"
|
|
64
|
+
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
|
65
|
+
# Guards some sites put before JSON so other sites can't read it (Google: ")]}'").
|
|
66
|
+
JSON_GUARDS = (")]}'", "while(1);", "for(;;);")
|
|
67
|
+
# Environment variable for each credential.
|
|
68
|
+
CREDENTIAL_ENV: dict[str, str] = {{ spec.credentials | env_map | pyrepr }}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class {{ spec.error_class }}(Exception):
|
|
72
|
+
"""An API call failed. `status` is the HTTP status (0 for network errors)."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, status: int, message: str, body: Any = None) -> None:
|
|
75
|
+
super().__init__(f"HTTP {status}: {message}" if status else message)
|
|
76
|
+
self.status = status
|
|
77
|
+
self.body = body
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class {{ spec.error_classes.auth }}({{ spec.error_class }}):
|
|
81
|
+
"""401 or 403: a credential is missing, wrong or expired."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class {{ spec.error_classes.not_found }}({{ spec.error_class }}):
|
|
85
|
+
"""404: no such resource (or the site moved this endpoint)."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class {{ spec.error_classes.rate_limit }}({{ spec.error_class }}):
|
|
89
|
+
"""429 after all retries: slow down (see `max_requests_per_second`)."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class {{ spec.error_classes.server }}({{ spec.error_class }}):
|
|
93
|
+
"""5xx after all retries: the site has a problem."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _error_for(status: int, message: str, body: Any = None) -> {{ spec.error_class }}:
|
|
97
|
+
kind: type[{{ spec.error_class }}] = {{ spec.error_class }}
|
|
98
|
+
if status in (401, 403):
|
|
99
|
+
kind = {{ spec.error_classes.auth }}
|
|
100
|
+
elif status == 404:
|
|
101
|
+
kind = {{ spec.error_classes.not_found }}
|
|
102
|
+
elif status == 429:
|
|
103
|
+
kind = {{ spec.error_classes.rate_limit }}
|
|
104
|
+
elif status >= 500:
|
|
105
|
+
kind = {{ spec.error_classes.server }}
|
|
106
|
+
return kind(status, message, body)
|
|
107
|
+
{% macro signature(m) %}
|
|
108
|
+
self,
|
|
109
|
+
{% for p in m.required %}
|
|
110
|
+
{{ p.name }}: {{ p.annotation }},
|
|
111
|
+
{% endfor %}
|
|
112
|
+
{% if m.optional %}
|
|
113
|
+
*,
|
|
114
|
+
{% for p in m.optional %}
|
|
115
|
+
{{ p.name }}: {{ p.annotation }} = {{ p.default_literal }},
|
|
116
|
+
{% endfor %}
|
|
117
|
+
{% endif %}
|
|
118
|
+
{% endmacro %}
|
|
119
|
+
{% macro request_args(m) %}
|
|
120
|
+
"{{ m.http_method }}",
|
|
121
|
+
{% if m.host %}"https://{{ m.host }}"{% else %}self.base_url{% endif %} + {{ m | path_expr }},
|
|
122
|
+
params={
|
|
123
|
+
{% for p in m.by_location('query') %}
|
|
124
|
+
{{ p.wire | pyrepr }}: {{ p.value_expr }},
|
|
125
|
+
{% endfor %}
|
|
126
|
+
{% for c in m | creds(spec, 'query') %}
|
|
127
|
+
{{ c.wire | pyrepr }}: self._credential({{ c.arg | pyrepr }}),
|
|
128
|
+
{% endfor %}
|
|
129
|
+
},
|
|
130
|
+
{% if m.graphql %}
|
|
131
|
+
json={
|
|
132
|
+
{% for key, value in m.graphql.items() %}
|
|
133
|
+
{{ key | pyrepr }}: {{ value | pyrepr }},
|
|
134
|
+
{% endfor %}
|
|
135
|
+
"variables": _compact({
|
|
136
|
+
{% for p in m.by_location('variable') %}
|
|
137
|
+
{{ p.wire | pyrepr }}: {{ p.value_expr }},
|
|
138
|
+
{% endfor %}
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
{% elif m.by_location('body') %}
|
|
142
|
+
json={
|
|
143
|
+
{% for p in m.by_location('body') %}
|
|
144
|
+
{{ p.wire | pyrepr }}: {{ p.value_expr }},
|
|
145
|
+
{% endfor %}
|
|
146
|
+
},
|
|
147
|
+
{% endif %}
|
|
148
|
+
headers={
|
|
149
|
+
{% for name, value in m.headers.items() %}
|
|
150
|
+
{{ name | pyrepr }}: {{ value | pyrepr }},
|
|
151
|
+
{% endfor %}
|
|
152
|
+
{% for c in m | creds(spec, 'header') %}
|
|
153
|
+
{{ c.wire | pyrepr }}: self._credential({{ c.arg | pyrepr }}),
|
|
154
|
+
{% endfor %}
|
|
155
|
+
},
|
|
156
|
+
{% endmacro %}
|
|
157
|
+
{% macro iter_signature(m) %}
|
|
158
|
+
{% set pp = m.page_param %}
|
|
159
|
+
self,
|
|
160
|
+
{% for p in m.required %}
|
|
161
|
+
{{ p.name }}: {{ p.annotation }},
|
|
162
|
+
{% endfor %}
|
|
163
|
+
*,
|
|
164
|
+
{% for p in m.optional if p.name != pp.name %}
|
|
165
|
+
{{ p.name }}: {{ p.annotation }} = {{ p.default_literal }},
|
|
166
|
+
{% endfor %}
|
|
167
|
+
start: {{ pp.annotation }} = {{ pp.default_literal }},
|
|
168
|
+
max_pages: int | None = None,
|
|
169
|
+
{% endmacro %}
|
|
170
|
+
{% macro page_loop(m, aw) %}
|
|
171
|
+
{% set pp = m.page_param %}
|
|
172
|
+
"""Yield each page of `{{ m.name }}()` until the results run out.
|
|
173
|
+
|
|
174
|
+
Pages by {{ m.pagination.style }} using `{{ pp.wire }}`. Set `max_pages` to stop early.
|
|
175
|
+
"""
|
|
176
|
+
position: Any = start
|
|
177
|
+
previous: list[Any] | None = None
|
|
178
|
+
pages = 0
|
|
179
|
+
while max_pages is None or pages < max_pages:
|
|
180
|
+
page = {{ aw }}self.{{ m.name }}(
|
|
181
|
+
{% for p in m.required %}
|
|
182
|
+
{{ p.name }},
|
|
183
|
+
{% endfor %}
|
|
184
|
+
{% for p in m.optional if p.name != pp.name %}
|
|
185
|
+
{{ p.name }}={{ p.name }},
|
|
186
|
+
{% endfor %}
|
|
187
|
+
{{ pp.name }}=position,
|
|
188
|
+
)
|
|
189
|
+
data = _dump(page)
|
|
190
|
+
items = _find_items(data)
|
|
191
|
+
if not items or items == previous:
|
|
192
|
+
return
|
|
193
|
+
yield page
|
|
194
|
+
pages += 1
|
|
195
|
+
if _has_more(data) is False:
|
|
196
|
+
return
|
|
197
|
+
previous = items
|
|
198
|
+
{% if m.pagination.style == "cursor" %}
|
|
199
|
+
position = _next_cursor(data)
|
|
200
|
+
if position is None:
|
|
201
|
+
return
|
|
202
|
+
{% elif m.pagination.style == "offset" %}
|
|
203
|
+
position = (position or 0) + len(items)
|
|
204
|
+
{% else %}
|
|
205
|
+
position = (position or 0) + 1
|
|
206
|
+
{% endif %}
|
|
207
|
+
{% endmacro %}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class _Base:
|
|
211
|
+
BASE_URL = "{{ spec.base_url }}"
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
base_url: str,
|
|
216
|
+
{% for c in spec.credentials %}
|
|
217
|
+
{{ c.arg }}: str | None,
|
|
218
|
+
{% endfor %}
|
|
219
|
+
max_requests_per_second: float,
|
|
220
|
+
max_retries: int,
|
|
221
|
+
) -> None:
|
|
222
|
+
self.base_url = base_url.rstrip("/")
|
|
223
|
+
{% for c in spec.credentials %}
|
|
224
|
+
self.{{ c.arg }} = {{ c.arg }} or os.environ.get("{{ c.env }}")
|
|
225
|
+
{% endfor %}
|
|
226
|
+
self._interval = 1.0 / max_requests_per_second if max_requests_per_second > 0 else 0.0
|
|
227
|
+
self._last_request = 0.0
|
|
228
|
+
self._max_retries = max_retries
|
|
229
|
+
|
|
230
|
+
def _credential(self, name: str) -> str:
|
|
231
|
+
value: str | None = getattr(self, name)
|
|
232
|
+
if not value:
|
|
233
|
+
env = CREDENTIAL_ENV.get(name, "")
|
|
234
|
+
raise {{ spec.error_classes.auth }}(0, f"Missing credential: pass {name}=... or set {env}")
|
|
235
|
+
return value
|
|
236
|
+
|
|
237
|
+
def _wait(self) -> float:
|
|
238
|
+
"""Seconds to wait before the next request, to keep to the rate limit."""
|
|
239
|
+
wait = self._last_request + self._interval - time.monotonic()
|
|
240
|
+
self._last_request = time.monotonic() + max(wait, 0.0)
|
|
241
|
+
return max(wait, 0.0)
|
|
242
|
+
|
|
243
|
+
def _retry_delay(self, response: httpx.Response | None, attempt: int) -> float | None:
|
|
244
|
+
"""How long to wait before retrying, or None to stop retrying."""
|
|
245
|
+
if attempt >= self._max_retries:
|
|
246
|
+
return None
|
|
247
|
+
if response is None: # network error
|
|
248
|
+
return float(2**attempt)
|
|
249
|
+
if response.status_code not in RETRY_STATUSES:
|
|
250
|
+
return None
|
|
251
|
+
retry_after = response.headers.get("retry-after", "")
|
|
252
|
+
return float(retry_after) if retry_after.isdigit() else float(2**attempt)
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _result(response: httpx.Response) -> Any:
|
|
256
|
+
if response.status_code >= 400:
|
|
257
|
+
raise _error_for(response.status_code, response.reason_phrase, _safe_json(response))
|
|
258
|
+
try:
|
|
259
|
+
return _parse_json(response.text)
|
|
260
|
+
except ValueError as exc:
|
|
261
|
+
raise {{ spec.error_class }}(
|
|
262
|
+
response.status_code, "response is not JSON", response.text[:500]
|
|
263
|
+
) from exc
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class {{ spec.class_name }}(_Base):
|
|
267
|
+
"""Client for {{ spec.domain }}.
|
|
268
|
+
|
|
269
|
+
Use it as a context manager so the connection is closed:
|
|
270
|
+
|
|
271
|
+
with {{ spec.class_name }}() as api:
|
|
272
|
+
{% if spec.methods %}
|
|
273
|
+
result = api.{{ spec.methods[0].name }}({% for p in spec.methods[0].required %}{{ p.name }}={{ p.example or '...' }}{% if not loop.last %}, {% endif %}{% endfor %})
|
|
274
|
+
{% endif %}
|
|
275
|
+
{% if spec.credentials %}
|
|
276
|
+
|
|
277
|
+
Credentials are read from these environment variables unless passed in:
|
|
278
|
+
{% for c in spec.credentials %}
|
|
279
|
+
{{ c.arg }}: {{ c.env }}
|
|
280
|
+
{% endfor %}
|
|
281
|
+
{% endif %}
|
|
282
|
+
|
|
283
|
+
Pass `http_client=httpx.Client(event_hooks=...)` to log or change every request.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
def __init__(
|
|
287
|
+
self,
|
|
288
|
+
base_url: str = _Base.BASE_URL,
|
|
289
|
+
*,
|
|
290
|
+
{% for c in spec.credentials %}
|
|
291
|
+
{{ c.arg }}: str | None = None,
|
|
292
|
+
{% endfor %}
|
|
293
|
+
timeout: float = 30.0,
|
|
294
|
+
max_requests_per_second: float = 1.0,
|
|
295
|
+
max_retries: int = 3,
|
|
296
|
+
http_client: httpx.Client | None = None,
|
|
297
|
+
) -> None:
|
|
298
|
+
super().__init__(
|
|
299
|
+
base_url,
|
|
300
|
+
{% for c in spec.credentials %}
|
|
301
|
+
{{ c.arg }},
|
|
302
|
+
{% endfor %}
|
|
303
|
+
max_requests_per_second,
|
|
304
|
+
max_retries,
|
|
305
|
+
)
|
|
306
|
+
self._http = http_client or httpx.Client(
|
|
307
|
+
timeout=timeout, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def close(self) -> None:
|
|
311
|
+
self._http.close()
|
|
312
|
+
|
|
313
|
+
def __enter__(self) -> {{ spec.class_name }}:
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
def __exit__(self, *exc: object) -> None:
|
|
317
|
+
self.close()
|
|
318
|
+
{% for m in spec.methods %}
|
|
319
|
+
|
|
320
|
+
def {{ m.name }}(
|
|
321
|
+
{{ signature(m) }} ) -> {{ m.response_type }}:
|
|
322
|
+
"""{{ m.summary | doc }}
|
|
323
|
+
|
|
324
|
+
`{{ m.http_method }} {{ (m.host and 'https://' ~ m.host) or spec.base_url }}{{ m.path }}`
|
|
325
|
+
"""
|
|
326
|
+
data = self._request(
|
|
327
|
+
{{ request_args(m) }} )
|
|
328
|
+
return TypeAdapter({{ m.response_type }}).validate_python(data)
|
|
329
|
+
{% if m.page_param %}
|
|
330
|
+
|
|
331
|
+
def iter_{{ m.name }}(
|
|
332
|
+
{{ iter_signature(m) }} ) -> Iterator[{{ m.response_type }}]:
|
|
333
|
+
{{ page_loop(m, "") }}{% endif %}
|
|
334
|
+
{% endfor %}
|
|
335
|
+
|
|
336
|
+
def _request(
|
|
337
|
+
self,
|
|
338
|
+
method: str,
|
|
339
|
+
url: str,
|
|
340
|
+
params: dict[str, Any],
|
|
341
|
+
headers: dict[str, str],
|
|
342
|
+
json: dict[str, Any] | None = None,
|
|
343
|
+
) -> Any:
|
|
344
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
345
|
+
if json is not None:
|
|
346
|
+
json = {k: v for k, v in json.items() if v is not None}
|
|
347
|
+
attempt = 0
|
|
348
|
+
while True:
|
|
349
|
+
time.sleep(self._wait())
|
|
350
|
+
try:
|
|
351
|
+
response = self._http.request(
|
|
352
|
+
method, url, params=params, headers=headers, json=json
|
|
353
|
+
)
|
|
354
|
+
except httpx.HTTPError as exc:
|
|
355
|
+
delay = self._retry_delay(None, attempt)
|
|
356
|
+
if delay is None:
|
|
357
|
+
raise {{ spec.error_class }}(0, f"network error: {exc}") from exc
|
|
358
|
+
else:
|
|
359
|
+
delay = self._retry_delay(response, attempt)
|
|
360
|
+
if delay is None:
|
|
361
|
+
return self._result(response)
|
|
362
|
+
time.sleep(delay)
|
|
363
|
+
attempt += 1
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class {{ spec.async_class }}(_Base):
|
|
367
|
+
"""The same client for asyncio:
|
|
368
|
+
|
|
369
|
+
async with {{ spec.async_class }}() as api:
|
|
370
|
+
{% if spec.methods %}
|
|
371
|
+
result = await api.{{ spec.methods[0].name }}({% for p in spec.methods[0].required %}{{ p.name }}={{ p.example or '...' }}{% if not loop.last %}, {% endif %}{% endfor %})
|
|
372
|
+
{% endif %}
|
|
373
|
+
|
|
374
|
+
The rate limit applies per client: share one client between tasks.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
def __init__(
|
|
378
|
+
self,
|
|
379
|
+
base_url: str = _Base.BASE_URL,
|
|
380
|
+
*,
|
|
381
|
+
{% for c in spec.credentials %}
|
|
382
|
+
{{ c.arg }}: str | None = None,
|
|
383
|
+
{% endfor %}
|
|
384
|
+
timeout: float = 30.0,
|
|
385
|
+
max_requests_per_second: float = 1.0,
|
|
386
|
+
max_retries: int = 3,
|
|
387
|
+
http_client: httpx.AsyncClient | None = None,
|
|
388
|
+
) -> None:
|
|
389
|
+
super().__init__(
|
|
390
|
+
base_url,
|
|
391
|
+
{% for c in spec.credentials %}
|
|
392
|
+
{{ c.arg }},
|
|
393
|
+
{% endfor %}
|
|
394
|
+
max_requests_per_second,
|
|
395
|
+
max_retries,
|
|
396
|
+
)
|
|
397
|
+
self._http = http_client or httpx.AsyncClient(
|
|
398
|
+
timeout=timeout, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
399
|
+
)
|
|
400
|
+
self._lock = asyncio.Lock()
|
|
401
|
+
|
|
402
|
+
async def aclose(self) -> None:
|
|
403
|
+
await self._http.aclose()
|
|
404
|
+
|
|
405
|
+
async def __aenter__(self) -> {{ spec.async_class }}:
|
|
406
|
+
return self
|
|
407
|
+
|
|
408
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
409
|
+
await self.aclose()
|
|
410
|
+
{% for m in spec.methods %}
|
|
411
|
+
|
|
412
|
+
async def {{ m.name }}(
|
|
413
|
+
{{ signature(m) }} ) -> {{ m.response_type }}:
|
|
414
|
+
"""{{ m.summary | doc }}"""
|
|
415
|
+
data = await self._request(
|
|
416
|
+
{{ request_args(m) }} )
|
|
417
|
+
return TypeAdapter({{ m.response_type }}).validate_python(data)
|
|
418
|
+
{% if m.page_param %}
|
|
419
|
+
|
|
420
|
+
async def iter_{{ m.name }}(
|
|
421
|
+
{{ iter_signature(m) }} ) -> AsyncIterator[{{ m.response_type }}]:
|
|
422
|
+
{{ page_loop(m, "await ") }}{% endif %}
|
|
423
|
+
{% endfor %}
|
|
424
|
+
|
|
425
|
+
async def _request(
|
|
426
|
+
self,
|
|
427
|
+
method: str,
|
|
428
|
+
url: str,
|
|
429
|
+
params: dict[str, Any],
|
|
430
|
+
headers: dict[str, str],
|
|
431
|
+
json: dict[str, Any] | None = None,
|
|
432
|
+
) -> Any:
|
|
433
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
434
|
+
if json is not None:
|
|
435
|
+
json = {k: v for k, v in json.items() if v is not None}
|
|
436
|
+
attempt = 0
|
|
437
|
+
while True:
|
|
438
|
+
async with self._lock: # one request at a time keeps the rate limit honest
|
|
439
|
+
await asyncio.sleep(self._wait())
|
|
440
|
+
try:
|
|
441
|
+
response = await self._http.request(
|
|
442
|
+
method, url, params=params, headers=headers, json=json
|
|
443
|
+
)
|
|
444
|
+
except httpx.HTTPError as exc:
|
|
445
|
+
delay = self._retry_delay(None, attempt)
|
|
446
|
+
if delay is None:
|
|
447
|
+
raise {{ spec.error_class }}(0, f"network error: {exc}") from exc
|
|
448
|
+
else:
|
|
449
|
+
delay = self._retry_delay(response, attempt)
|
|
450
|
+
if delay is None:
|
|
451
|
+
return self._result(response)
|
|
452
|
+
await asyncio.sleep(delay)
|
|
453
|
+
attempt += 1
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _compact(values: dict[str, Any]) -> dict[str, Any]:
|
|
457
|
+
"""GraphQL variables without the ones left out (None)."""
|
|
458
|
+
return {k: v for k, v in values.items() if v is not None}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _parse_json(text: str) -> Any:
|
|
462
|
+
stripped = text.lstrip()
|
|
463
|
+
for guard in JSON_GUARDS:
|
|
464
|
+
if stripped.startswith(guard):
|
|
465
|
+
stripped = stripped[len(guard) :].lstrip(",").lstrip()
|
|
466
|
+
break
|
|
467
|
+
return json.loads(stripped)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _safe_json(response: httpx.Response) -> Any:
|
|
471
|
+
try:
|
|
472
|
+
return _parse_json(response.text)
|
|
473
|
+
except ValueError:
|
|
474
|
+
return response.text[:500]
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _dump(value: Any) -> Any:
|
|
478
|
+
if isinstance(value, BaseModel):
|
|
479
|
+
return value.model_dump(by_alias=True)
|
|
480
|
+
if isinstance(value, list):
|
|
481
|
+
return [_dump(v) for v in value]
|
|
482
|
+
return value
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _find_items(data: Any, depth: int = 0) -> list[Any] | None:
|
|
486
|
+
"""The main list of records in a response."""
|
|
487
|
+
if isinstance(data, list):
|
|
488
|
+
return data
|
|
489
|
+
if not isinstance(data, dict) or depth > 2:
|
|
490
|
+
return None
|
|
491
|
+
best: list[Any] | None = None
|
|
492
|
+
for value in data.values():
|
|
493
|
+
found = value if isinstance(value, list) else _find_items(value, depth + 1)
|
|
494
|
+
if found is not None and (best is None or len(found) > len(best)):
|
|
495
|
+
best = found
|
|
496
|
+
return best
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _has_more(data: Any) -> bool | None:
|
|
500
|
+
if isinstance(data, dict):
|
|
501
|
+
for key in ("has_next", "hasNext", "has_more", "hasMore"):
|
|
502
|
+
if isinstance(data.get(key), bool):
|
|
503
|
+
return bool(data[key])
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _next_cursor(data: Any) -> str | None:
|
|
508
|
+
if not isinstance(data, dict):
|
|
509
|
+
return None
|
|
510
|
+
places = [data] + [data[k] for k in ("meta", "pagination", "paging", "pageInfo", "links")
|
|
511
|
+
if isinstance(data.get(k), dict)]
|
|
512
|
+
for place in places:
|
|
513
|
+
for key in ("next_cursor", "nextCursor", "cursor", "next", "after", "endCursor",
|
|
514
|
+
"next_page_token", "nextPageToken"):
|
|
515
|
+
value = place.get(key)
|
|
516
|
+
if isinstance(value, (str, int)) and not isinstance(value, bool) and str(value):
|
|
517
|
+
return str(value)
|
|
518
|
+
return None
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for {{ spec.class_name }} (generated by Ghost API {{ version }}).
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test {{ module }}.test.ts (Node 23.6+ runs TypeScript directly)
|
|
5
|
+
*
|
|
6
|
+
* By default they replay replies recorded from {{ spec.domain }} (cassettes.json): offline,
|
|
7
|
+
* fast and the same every time, so they fit CI. With GHOST_API_LIVE=1 they call the real
|
|
8
|
+
* API, slowly (1 request per second); a failure then usually means the site changed its API.
|
|
9
|
+
*/
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { {{ spec.class_name }} } from "./{{ module }}.ts";
|
|
14
|
+
|
|
15
|
+
interface Reply {
|
|
16
|
+
method: string;
|
|
17
|
+
path: string;
|
|
18
|
+
path_regex: string;
|
|
19
|
+
query: Record<string, string>;
|
|
20
|
+
operation: string | null;
|
|
21
|
+
status: number;
|
|
22
|
+
content_type: string;
|
|
23
|
+
body: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const CASSETTES = new URL("./cassettes.json", import.meta.url);
|
|
27
|
+
const LIVE = process.env.GHOST_API_LIVE === "1" || !existsSync(CASSETTES);
|
|
28
|
+
const REPLIES: Reply[] = LIVE ? [] : JSON.parse(readFileSync(CASSETTES, "utf-8")).replies;
|
|
29
|
+
|
|
30
|
+
/** The recorded reply closest to a request: same path first, then query values. */
|
|
31
|
+
async function replay(input: URL | RequestInfo, init?: RequestInit): Promise<Response> {
|
|
32
|
+
const url = new URL(String(input));
|
|
33
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
34
|
+
let operation: string | null = null;
|
|
35
|
+
if (typeof init?.body === "string") {
|
|
36
|
+
try {
|
|
37
|
+
operation = JSON.parse(init.body).operationName ?? null;
|
|
38
|
+
} catch {
|
|
39
|
+
operation = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const found = REPLIES.filter(
|
|
43
|
+
(r) =>
|
|
44
|
+
r.method === method &&
|
|
45
|
+
new RegExp(`^${r.path_regex}$`).test(url.pathname) &&
|
|
46
|
+
(operation === null || r.operation === null || r.operation === operation),
|
|
47
|
+
);
|
|
48
|
+
if (found.length === 0) {
|
|
49
|
+
const error = `no recorded reply for ${method} ${url.pathname}`;
|
|
50
|
+
return new Response(JSON.stringify({ error }), { status: 404 });
|
|
51
|
+
}
|
|
52
|
+
const score = (r: Reply): number[] => [
|
|
53
|
+
r.path === url.pathname ? 1 : 0,
|
|
54
|
+
Object.entries(r.query).filter(([k, v]) => url.searchParams.get(k) === v).length,
|
|
55
|
+
-Math.abs(Object.keys(r.query).length - [...url.searchParams.keys()].length),
|
|
56
|
+
];
|
|
57
|
+
const better = (a: number[], b: number[]): boolean => {
|
|
58
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
|
59
|
+
return false;
|
|
60
|
+
};
|
|
61
|
+
const best = found.reduce((a, b) => (better(score(b), score(a)) ? b : a));
|
|
62
|
+
return new Response(best.body, { status: best.status, headers: { "content-type": best.content_type } });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const api = LIVE
|
|
66
|
+
? new {{ spec.class_name }}()
|
|
67
|
+
: new {{ spec.class_name }}({
|
|
68
|
+
{% for c in spec.credentials %}
|
|
69
|
+
{{ c.arg | camel }}: "offline-placeholder",
|
|
70
|
+
{% endfor %}
|
|
71
|
+
fetch: replay as typeof fetch,
|
|
72
|
+
maxRequestsPerSecond: 0,
|
|
73
|
+
});
|
|
74
|
+
{% for m in spec.methods if m.testable %}
|
|
75
|
+
|
|
76
|
+
test("test_{{ m.name }}", async (t) => {
|
|
77
|
+
{% for c in m | creds(spec, 'any') %}
|
|
78
|
+
if (LIVE && !process.env[{{ c.env | json }}]) {
|
|
79
|
+
t.skip("set {{ c.env }} to run this test against the live API");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
{% endfor %}
|
|
83
|
+
const result = await api.{{ m.name | camel }}({% for p in m.required %}{{ p.example | js }}{% if not loop.last %}, {% endif %}{% endfor %});
|
|
84
|
+
assert.ok(result !== null && result !== undefined);
|
|
85
|
+
});
|
|
86
|
+
{% if m.page_param %}
|
|
87
|
+
|
|
88
|
+
test("test_iter_{{ m.name }}", async (t) => {
|
|
89
|
+
{% for c in m | creds(spec, 'any') %}
|
|
90
|
+
if (LIVE && !process.env[{{ c.env | json }}]) {
|
|
91
|
+
t.skip("set {{ c.env }} to run this test against the live API");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
{% endfor %}
|
|
95
|
+
const pages = [];
|
|
96
|
+
for await (const page of api.iter{{ (m.name | camel)[:1] | upper }}{{ (m.name | camel)[1:] }}({% for p in m.required %}{{ p.example | js }}, {% endfor %}{ maxPages: 2 })) {
|
|
97
|
+
pages.push(page);
|
|
98
|
+
}
|
|
99
|
+
assert.ok(pages.length >= 1);
|
|
100
|
+
});
|
|
101
|
+
{% endif %}
|
|
102
|
+
{% endfor %}
|
|
103
|
+
{% for m in spec.methods if not m.testable %}
|
|
104
|
+
|
|
105
|
+
test("test_{{ m.name }}", { skip: "no example values were captured for its required arguments" }, () => {});
|
|
106
|
+
{% endfor %}
|