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.
Files changed (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. 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 %}