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,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{ spec.class_name }}: a typed TypeScript client for {{ spec.domain }}.
|
|
3
|
+
*
|
|
4
|
+
* Generated by Ghost API {{ version }} on {{ generated_on }} from the site's own traffic.
|
|
5
|
+
* Made for Node 18+ (servers, scripts, serverless functions). The types describe responses
|
|
6
|
+
* seen while exploring; they are not checked at runtime.
|
|
7
|
+
*
|
|
8
|
+
* In a web page: browsers don't let code set the User-Agent header, so this client doesn't
|
|
9
|
+
* try there (the site can't tell the calls come from it), and the browser only lets the page
|
|
10
|
+
* read the replies if the site allows cross-origin requests (CORS), which internal APIs
|
|
11
|
+
* rarely do. Call the client from your own server and have the page call that instead.
|
|
12
|
+
*
|
|
13
|
+
* This is an unofficial client for a website's internal API. It can change without
|
|
14
|
+
* notice, and the site's terms of service still apply. Re-run `ghost-api run` to refresh.
|
|
15
|
+
*/
|
|
16
|
+
{% for m in spec.models %}
|
|
17
|
+
|
|
18
|
+
{% if m.doc %}
|
|
19
|
+
/** {{ m.doc | doc }} */
|
|
20
|
+
{% endif %}
|
|
21
|
+
export interface {{ m.name }} {
|
|
22
|
+
{% for f in m.fields %}
|
|
23
|
+
{{ f | ts_prop }}{% if f.optional %}?{% endif %}: {{ f.type | ts_type }};
|
|
24
|
+
{% endfor %}
|
|
25
|
+
/** Fields not seen while exploring. */
|
|
26
|
+
[extra: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
{% endfor %}
|
|
29
|
+
|
|
30
|
+
/** An API call failed. `status` is the HTTP status (0 for network errors). */
|
|
31
|
+
export class {{ spec.error_class }} extends Error {
|
|
32
|
+
readonly status: number;
|
|
33
|
+
readonly body: unknown;
|
|
34
|
+
|
|
35
|
+
constructor(status: number, message: string, body?: unknown) {
|
|
36
|
+
super(status ? `HTTP ${status}: ${message}` : message);
|
|
37
|
+
this.name = new.target.name;
|
|
38
|
+
this.status = status;
|
|
39
|
+
this.body = body;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 401 or 403: a credential is missing, wrong or expired. */
|
|
44
|
+
export class {{ spec.error_classes.auth }} extends {{ spec.error_class }} {}
|
|
45
|
+
/** 404: no such resource (or the site moved this endpoint). */
|
|
46
|
+
export class {{ spec.error_classes.not_found }} extends {{ spec.error_class }} {}
|
|
47
|
+
/** 429 after all retries: slow down (see `maxRequestsPerSecond`). */
|
|
48
|
+
export class {{ spec.error_classes.rate_limit }} extends {{ spec.error_class }} {}
|
|
49
|
+
/** 5xx after all retries: the site has a problem. */
|
|
50
|
+
export class {{ spec.error_classes.server }} extends {{ spec.error_class }} {}
|
|
51
|
+
|
|
52
|
+
function errorFor(status: number, message: string, body?: unknown): {{ spec.error_class }} {
|
|
53
|
+
if (status === 401 || status === 403) return new {{ spec.error_classes.auth }}(status, message, body);
|
|
54
|
+
if (status === 404) return new {{ spec.error_classes.not_found }}(status, message, body);
|
|
55
|
+
if (status === 429) return new {{ spec.error_classes.rate_limit }}(status, message, body);
|
|
56
|
+
if (status >= 500) return new {{ spec.error_classes.server }}(status, message, body);
|
|
57
|
+
return new {{ spec.error_class }}(status, message, body);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface {{ spec.class_name }}Options {
|
|
61
|
+
baseUrl?: string;
|
|
62
|
+
{% for c in spec.credentials %}
|
|
63
|
+
/** Defaults to the {{ c.env }} environment variable (Node). */
|
|
64
|
+
{{ c.arg | camel }}?: string;
|
|
65
|
+
{% endfor %}
|
|
66
|
+
maxRequestsPerSecond?: number;
|
|
67
|
+
maxRetries?: number;
|
|
68
|
+
fetch?: typeof fetch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const USER_AGENT = {{ user_agent | json }};
|
|
72
|
+
const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
73
|
+
// Guards some sites put before JSON so other sites can't read it (Google: ")]}'").
|
|
74
|
+
const JSON_GUARDS = [")]}'", "while(1);", "for(;;);"];
|
|
75
|
+
const CREDENTIAL_ENV: Record<string, string> = {
|
|
76
|
+
{% for c in spec.credentials %}
|
|
77
|
+
{{ c.arg | camel | json }}: {{ c.env | json }},
|
|
78
|
+
{% endfor %}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** True in a web page (and web workers), where the User-Agent header can't be set. */
|
|
82
|
+
function inBrowser(): boolean {
|
|
83
|
+
const g = globalThis as { document?: unknown; importScripts?: unknown };
|
|
84
|
+
return g.document !== undefined || typeof g.importScripts === "function";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readEnv(name: string): string | undefined {
|
|
88
|
+
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
|
|
89
|
+
return proc?.env?.[name];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseJson(text: string): unknown {
|
|
93
|
+
let body = text.trimStart();
|
|
94
|
+
const guard = JSON_GUARDS.find((g) => body.startsWith(g));
|
|
95
|
+
if (guard) body = body.slice(guard.length).replace(/^,/, "").trimStart();
|
|
96
|
+
return JSON.parse(body);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** GraphQL variables without the ones left out. */
|
|
100
|
+
function compact(values: Record<string, unknown>): Record<string, unknown> {
|
|
101
|
+
return Object.fromEntries(Object.entries(values).filter(([, v]) => v !== undefined && v !== null));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sleep(ms: number): Promise<void> {
|
|
105
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class {{ spec.class_name }} {
|
|
109
|
+
static readonly BASE_URL = {{ spec.base_url | json }};
|
|
110
|
+
|
|
111
|
+
readonly baseUrl: string;
|
|
112
|
+
private readonly credentials: Record<string, string | undefined>;
|
|
113
|
+
private readonly interval: number;
|
|
114
|
+
private readonly maxRetries: number;
|
|
115
|
+
private readonly fetchImpl: typeof fetch;
|
|
116
|
+
private lastRequest = 0;
|
|
117
|
+
|
|
118
|
+
constructor(options: {{ spec.class_name }}Options = {}) {
|
|
119
|
+
this.baseUrl = (options.baseUrl ?? {{ spec.class_name }}.BASE_URL).replace(/\/+$/, "");
|
|
120
|
+
this.credentials = {
|
|
121
|
+
{% for c in spec.credentials %}
|
|
122
|
+
{{ c.arg | camel | json }}: options.{{ c.arg | camel }} ?? readEnv({{ c.env | json }}),
|
|
123
|
+
{% endfor %}
|
|
124
|
+
};
|
|
125
|
+
const rate = options.maxRequestsPerSecond ?? 1;
|
|
126
|
+
this.interval = rate > 0 ? 1000 / rate : 0;
|
|
127
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
128
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
129
|
+
}
|
|
130
|
+
{% for m in spec.methods %}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* {{ m.summary | doc }}
|
|
134
|
+
*
|
|
135
|
+
* `{{ m.http_method }} {{ (m.host and 'https://' ~ m.host) or spec.base_url }}{{ m.path }}`
|
|
136
|
+
*/
|
|
137
|
+
async {{ m.name | camel }}(
|
|
138
|
+
{% for p in m.required %}
|
|
139
|
+
{{ p.name | camel }}: {{ p.type | ts_type }},
|
|
140
|
+
{% endfor %}
|
|
141
|
+
{% if m.optional %}
|
|
142
|
+
opts: {
|
|
143
|
+
{% for p in m.optional %}
|
|
144
|
+
{{ p.name | camel }}?: {{ p.type | ts_type }}{% if not p.required and (p.default is none or p.default == "None") %} | null{% endif %};
|
|
145
|
+
{% endfor %}
|
|
146
|
+
} = {},
|
|
147
|
+
{% endif %}
|
|
148
|
+
): Promise<{{ m.response_type | ts_type }}> {
|
|
149
|
+
const data = await this.request(
|
|
150
|
+
"{{ m.http_method }}",
|
|
151
|
+
{% if m.host %}"https://{{ m.host }}"{% else %}this.baseUrl{% endif %} + {{ m | path_expr }},
|
|
152
|
+
{
|
|
153
|
+
{% for p in m.by_location('query') %}
|
|
154
|
+
{{ p.wire | json }}: {{ p | value }},
|
|
155
|
+
{% endfor %}
|
|
156
|
+
{% for c in m | creds(spec, 'query') %}
|
|
157
|
+
{{ c.wire | json }}: this.credential({{ c.arg | camel | json }}),
|
|
158
|
+
{% endfor %}
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
{% for name, value in m.headers.items() %}
|
|
162
|
+
{{ name | json }}: {{ value | json }},
|
|
163
|
+
{% endfor %}
|
|
164
|
+
{% for c in m | creds(spec, 'header') %}
|
|
165
|
+
{{ c.wire | json }}: this.credential({{ c.arg | camel | json }}),
|
|
166
|
+
{% endfor %}
|
|
167
|
+
},
|
|
168
|
+
{% if m.graphql %}
|
|
169
|
+
{
|
|
170
|
+
{% for key, value in m.graphql.items() %}
|
|
171
|
+
{{ key | json }}: {{ value | json }},
|
|
172
|
+
{% endfor %}
|
|
173
|
+
variables: compact({
|
|
174
|
+
{% for p in m.by_location('variable') %}
|
|
175
|
+
{{ p.wire | json }}: {{ p | value }},
|
|
176
|
+
{% endfor %}
|
|
177
|
+
}),
|
|
178
|
+
},
|
|
179
|
+
{% elif m.by_location('body') %}
|
|
180
|
+
{
|
|
181
|
+
{% for p in m.by_location('body') %}
|
|
182
|
+
{{ p.wire | json }}: {{ p | value }},
|
|
183
|
+
{% endfor %}
|
|
184
|
+
},
|
|
185
|
+
{% endif %}
|
|
186
|
+
);
|
|
187
|
+
return data as {{ m.response_type | ts_type }};
|
|
188
|
+
}
|
|
189
|
+
{% if m.page_param %}
|
|
190
|
+
{% set pp = m.page_param %}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Yield each page of `{{ m.name | camel }}()` until the results run out.
|
|
194
|
+
* Pages by {{ m.pagination.style }} using `{{ pp.wire }}`. Set `maxPages` to stop early.
|
|
195
|
+
*/
|
|
196
|
+
async *iter{{ (m.name | camel)[:1] | upper }}{{ (m.name | camel)[1:] }}(
|
|
197
|
+
{% for p in m.required %}
|
|
198
|
+
{{ p.name | camel }}: {{ p.type | ts_type }},
|
|
199
|
+
{% endfor %}
|
|
200
|
+
opts: {
|
|
201
|
+
{% for p in m.optional if p.name != pp.name %}
|
|
202
|
+
{{ p.name | camel }}?: {{ p.type | ts_type }}{% if p.default is none or p.default == "None" %} | null{% endif %};
|
|
203
|
+
{% endfor %}
|
|
204
|
+
start?: {{ pp.type | ts_type }};
|
|
205
|
+
maxPages?: number;
|
|
206
|
+
} = {},
|
|
207
|
+
): AsyncGenerator<{{ m.response_type | ts_type }}> {
|
|
208
|
+
const { start, maxPages, ...rest } = opts;
|
|
209
|
+
let position: unknown = start ?? {{ pp.default | js }};
|
|
210
|
+
let previous = "";
|
|
211
|
+
for (let pages = 0; maxPages === undefined || pages < maxPages; pages++) {
|
|
212
|
+
const page = await this.{{ m.name | camel }}(
|
|
213
|
+
{% for p in m.required %}
|
|
214
|
+
{{ p.name | camel }},
|
|
215
|
+
{% endfor %}
|
|
216
|
+
{ ...rest, {{ pp.name | camel }}: position as {{ pp.type | ts_type }} },
|
|
217
|
+
);
|
|
218
|
+
const items = findItems(page);
|
|
219
|
+
const fingerprint = JSON.stringify(items);
|
|
220
|
+
if (!items || items.length === 0 || fingerprint === previous) return;
|
|
221
|
+
yield page;
|
|
222
|
+
if (hasMore(page) === false) return;
|
|
223
|
+
previous = fingerprint;
|
|
224
|
+
{% if m.pagination.style == "cursor" %}
|
|
225
|
+
position = nextCursor(page);
|
|
226
|
+
if (position === undefined) return;
|
|
227
|
+
{% elif m.pagination.style == "offset" %}
|
|
228
|
+
position = Number(position ?? 0) + items.length;
|
|
229
|
+
{% else %}
|
|
230
|
+
position = Number(position ?? 0) + 1;
|
|
231
|
+
{% endif %}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
{% endif %}
|
|
235
|
+
{% endfor %}
|
|
236
|
+
|
|
237
|
+
// --- plumbing ---
|
|
238
|
+
|
|
239
|
+
private credential(name: string): string {
|
|
240
|
+
const value = this.credentials[name];
|
|
241
|
+
if (!value) {
|
|
242
|
+
const env = CREDENTIAL_ENV[name] ?? "";
|
|
243
|
+
throw new {{ spec.error_classes.auth }}(0, `Missing credential: pass ${name} or set ${env}`);
|
|
244
|
+
}
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async request(
|
|
249
|
+
method: string,
|
|
250
|
+
url: string,
|
|
251
|
+
query: Record<string, unknown>,
|
|
252
|
+
headers: Record<string, string>,
|
|
253
|
+
body?: Record<string, unknown>,
|
|
254
|
+
): Promise<unknown> {
|
|
255
|
+
const target = new URL(url);
|
|
256
|
+
for (const [key, value] of Object.entries(query)) {
|
|
257
|
+
if (value !== undefined && value !== null) target.searchParams.set(key, String(value));
|
|
258
|
+
}
|
|
259
|
+
const init: RequestInit = {
|
|
260
|
+
method,
|
|
261
|
+
headers: {
|
|
262
|
+
...(inBrowser() ? {} : { "User-Agent": USER_AGENT }),
|
|
263
|
+
Accept: "application/json",
|
|
264
|
+
...headers,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
if (body !== undefined) {
|
|
268
|
+
const clean = Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined && v !== null));
|
|
269
|
+
init.body = JSON.stringify(clean);
|
|
270
|
+
init.headers = { ...init.headers, "Content-Type": "application/json" };
|
|
271
|
+
}
|
|
272
|
+
for (let attempt = 0; ; attempt++) {
|
|
273
|
+
const wait = this.lastRequest + this.interval - Date.now();
|
|
274
|
+
if (wait > 0) await sleep(wait);
|
|
275
|
+
this.lastRequest = Date.now();
|
|
276
|
+
let response: Response;
|
|
277
|
+
try {
|
|
278
|
+
response = await this.fetchImpl(target, init);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (attempt >= this.maxRetries) {
|
|
281
|
+
throw new {{ spec.error_class }}(0, `network error: ${String(error)}`);
|
|
282
|
+
}
|
|
283
|
+
await sleep(1000 * 2 ** attempt);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (RETRY_STATUSES.has(response.status) && attempt < this.maxRetries) {
|
|
287
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
288
|
+
await sleep(Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter * 1000 : 1000 * 2 ** attempt);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const text = await response.text();
|
|
292
|
+
let data: unknown;
|
|
293
|
+
try {
|
|
294
|
+
data = parseJson(text);
|
|
295
|
+
} catch {
|
|
296
|
+
if (!response.ok) throw errorFor(response.status, response.statusText, text.slice(0, 500));
|
|
297
|
+
throw new {{ spec.error_class }}(response.status, "response is not JSON", text.slice(0, 500));
|
|
298
|
+
}
|
|
299
|
+
if (!response.ok) throw errorFor(response.status, response.statusText, data);
|
|
300
|
+
return data;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function findItems(data: unknown, depth = 0): unknown[] | undefined {
|
|
306
|
+
if (Array.isArray(data)) return data;
|
|
307
|
+
if (data === null || typeof data !== "object" || depth > 2) return undefined;
|
|
308
|
+
let best: unknown[] | undefined;
|
|
309
|
+
for (const value of Object.values(data)) {
|
|
310
|
+
const found = Array.isArray(value) ? value : findItems(value, depth + 1);
|
|
311
|
+
if (found && (!best || found.length > best.length)) best = found;
|
|
312
|
+
}
|
|
313
|
+
return best;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function hasMore(data: unknown): boolean | undefined {
|
|
317
|
+
if (data === null || typeof data !== "object") return undefined;
|
|
318
|
+
const record = data as Record<string, unknown>;
|
|
319
|
+
for (const key of ["has_next", "hasNext", "has_more", "hasMore"]) {
|
|
320
|
+
if (typeof record[key] === "boolean") return record[key] as boolean;
|
|
321
|
+
}
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function nextCursor(data: unknown): string | undefined {
|
|
326
|
+
if (data === null || typeof data !== "object") return undefined;
|
|
327
|
+
const record = data as Record<string, unknown>;
|
|
328
|
+
const places = [record, ...["meta", "pagination", "paging", "pageInfo", "links"]
|
|
329
|
+
.map((k) => record[k])
|
|
330
|
+
.filter((v): v is Record<string, unknown> => v !== null && typeof v === "object")];
|
|
331
|
+
for (const place of places) {
|
|
332
|
+
for (const key of ["next_cursor", "nextCursor", "cursor", "next", "after", "endCursor",
|
|
333
|
+
"next_page_token", "nextPageToken"]) {
|
|
334
|
+
const value = place[key];
|
|
335
|
+
if ((typeof value === "string" && value) || typeof value === "number") return String(value);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Data models for {{ spec.domain }}, inferred from real responses.
|
|
2
|
+
|
|
3
|
+
Generated by Ghost API {{ version }} on {{ generated_on }}. Fields that were missing or
|
|
4
|
+
null in some responses are optional. Unknown extra fields are kept, so a new field in
|
|
5
|
+
the API does not break parsing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
{% if uses_any %}
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
{% endif %}
|
|
14
|
+
from pydantic import BaseModel, ConfigDict{% if uses_field %}, Field{% endif %}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
{% for m in spec.models %}
|
|
19
|
+
{{ m.name | pyrepr }},
|
|
20
|
+
{% endfor %}
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Model(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
26
|
+
{% for m in spec.models %}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class {{ m.name }}(_Model):
|
|
30
|
+
{% if m.doc %}
|
|
31
|
+
"""{{ m.doc | doc }}"""
|
|
32
|
+
{% endif %}
|
|
33
|
+
{% for f in m.fields %}
|
|
34
|
+
{% if f.alias %}
|
|
35
|
+
{{ f.name }}: {{ f.type }} = Field({% if f.optional %}None, {% endif %}alias={{ f.alias | pyrepr }})
|
|
36
|
+
{% elif f.optional %}
|
|
37
|
+
{{ f.name }}: {{ f.type }} = None
|
|
38
|
+
{% else %}
|
|
39
|
+
{{ f.name }}: {{ f.type }}
|
|
40
|
+
{% endif %}
|
|
41
|
+
{% endfor %}
|
|
42
|
+
{% if not m.fields and not m.doc %}
|
|
43
|
+
pass
|
|
44
|
+
{% endif %}
|
|
45
|
+
{% endfor %}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# {{ dist }}
|
|
2
|
+
|
|
3
|
+
An **unofficial**, typed {{ "Python" if lang == "python" else "TypeScript" }} client for
|
|
4
|
+
[{{ spec.domain }}]({{ spec.base_url }}), generated by
|
|
5
|
+
[Ghost API](https://github.com/Danielraj5605/Ghost-api) {{ version }} from the site's own
|
|
6
|
+
traffic. It is not made or supported by {{ spec.domain }}: the API can change without notice,
|
|
7
|
+
and the site's terms of service apply. Regenerate it with `ghost-api refresh {{ spec.domain }}`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
{% if lang == "python" %}
|
|
12
|
+
```bash
|
|
13
|
+
pip install path/to/package/python # or vendor src/{{ module }}/ into your project
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Needs Python 3.10+, `httpx` and `pydantic` (installed with it).
|
|
17
|
+
{% else %}
|
|
18
|
+
```bash
|
|
19
|
+
npm install path/to/package/typescript # builds dist/ on install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Made for Node 18+ (servers, scripts, serverless functions). In a web page, browsers block
|
|
23
|
+
reading another site's replies unless it allows CORS, and don't let code set the
|
|
24
|
+
User-Agent: call this client from your own server instead.
|
|
25
|
+
{% endif %}
|
|
26
|
+
|
|
27
|
+
## Use it
|
|
28
|
+
|
|
29
|
+
{% if lang == "python" %}
|
|
30
|
+
```python
|
|
31
|
+
from {{ module }} import {{ spec.class_name }}
|
|
32
|
+
|
|
33
|
+
with {{ spec.class_name }}() as api:
|
|
34
|
+
{% if methods %}
|
|
35
|
+
result = api.{{ methods[0].method.name }}({{ methods[0].py_args }})
|
|
36
|
+
{% endif %}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The same methods with asyncio:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from {{ module }} import {{ spec.async_class }}
|
|
43
|
+
|
|
44
|
+
async with {{ spec.async_class }}() as api:
|
|
45
|
+
{% if methods %}
|
|
46
|
+
result = await api.{{ methods[0].method.name }}({{ methods[0].py_args }})
|
|
47
|
+
{% endif %}
|
|
48
|
+
```
|
|
49
|
+
{% else %}
|
|
50
|
+
```ts
|
|
51
|
+
import { {{ spec.class_name }} } from "{{ dist }}";
|
|
52
|
+
|
|
53
|
+
const api = new {{ spec.class_name }}();
|
|
54
|
+
{% if methods %}
|
|
55
|
+
const result = await api.{{ methods[0].ts_name }}({{ methods[0].ts_args }});
|
|
56
|
+
{% endif %}
|
|
57
|
+
```
|
|
58
|
+
{% endif %}
|
|
59
|
+
{% if spec.credentials %}
|
|
60
|
+
|
|
61
|
+
### Credentials
|
|
62
|
+
|
|
63
|
+
| Argument | Environment variable |
|
|
64
|
+
|---|---|
|
|
65
|
+
{% for c in spec.credentials %}
|
|
66
|
+
| `{{ c.arg if lang == "python" else (c.arg | camel) }}` | `{{ c.env }}` |
|
|
67
|
+
{% endfor %}
|
|
68
|
+
|
|
69
|
+
Pass them to the constructor, or set the environment variables.
|
|
70
|
+
{% endif %}
|
|
71
|
+
|
|
72
|
+
## Methods
|
|
73
|
+
|
|
74
|
+
| Method | Calls | Returns | What it does |
|
|
75
|
+
|---|---|---|---|
|
|
76
|
+
{% for item in methods %}
|
|
77
|
+
{% set m = item.method %}
|
|
78
|
+
| `{{ m.name if lang == "python" else item.ts_name }}` | `{{ m.http_method }} {{ m.path }}` | `{{ m.response_type }}` | {{ m.summary | replace("|", "/") | replace("\n", " ") }}{% if m.page_param %} Pages with `{{ ("iter_" ~ m.name) if lang == "python" else item.ts_iter }}()`.{% endif %} |
|
|
79
|
+
{% endfor %}
|
|
80
|
+
|
|
81
|
+
## The same calls with curl
|
|
82
|
+
|
|
83
|
+
Values are examples seen while exploring the site.
|
|
84
|
+
|
|
85
|
+
{% for item in methods %}
|
|
86
|
+
**{{ item.method.name }}**
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
{{ item.curl }}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
{% endfor %}
|
|
93
|
+
## Errors
|
|
94
|
+
|
|
95
|
+
Every failure is a `{{ spec.error_class }}` with `.status` (0 for network errors) and
|
|
96
|
+
`.body`. Catch one kind with its subclass:
|
|
97
|
+
|
|
98
|
+
| Error | When |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `{{ spec.error_classes.auth }}` | 401 / 403, or a missing credential |
|
|
101
|
+
| `{{ spec.error_classes.not_found }}` | 404 |
|
|
102
|
+
| `{{ spec.error_classes.rate_limit }}` | 429 after the retries |
|
|
103
|
+
| `{{ spec.error_classes.server }}` | 5xx after the retries |
|
|
104
|
+
|
|
105
|
+
## Manners
|
|
106
|
+
|
|
107
|
+
The client sends at most one request per second by default
|
|
108
|
+
(`{{ "max_requests_per_second" if lang == "python" else "maxRequestsPerSecond" }}`), retries
|
|
109
|
+
429 and 5xx with backoff (`{{ "max_retries" if lang == "python" else "maxRetries" }}`), and
|
|
110
|
+
identifies itself with a User-Agent naming Ghost API. Keep it that way: it's someone else's
|
|
111
|
+
server.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Generated by Ghost API. Install with: pip install . (or publish it to your own index)
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["setuptools>=77"]
|
|
4
|
+
build-backend = "setuptools.build_meta"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "{{ dist }}"
|
|
8
|
+
version = "{{ version }}"
|
|
9
|
+
description = "Unofficial typed client for {{ spec.domain }} (generated by Ghost API)"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["httpx>=0.27", "pydantic>=2.7"]
|
|
13
|
+
classifiers = ["Typing :: Typed", "Private :: Do Not Upload"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = ["pytest>=8"]
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.package-data]
|
|
22
|
+
{{ module }} = ["py.typed"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tests for {{ spec.class_name }} (generated by Ghost API {{ version }}).
|
|
2
|
+
|
|
3
|
+
By default they replay replies recorded from {{ spec.domain }} (cassettes.json): offline,
|
|
4
|
+
fast and the same every time, so they fit CI. With GHOST_API_LIVE=1 they call the real API,
|
|
5
|
+
slowly (1 request per second), with values seen while exploring; a failure then usually
|
|
6
|
+
means the site changed its API: `ghost-api diff --live` says what changed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
23
|
+
|
|
24
|
+
from {{ module }} import {{ spec.class_name }} # noqa: E402
|
|
25
|
+
|
|
26
|
+
CASSETTES = Path(__file__).parent / "cassettes.json"
|
|
27
|
+
LIVE = os.environ.get("GHOST_API_LIVE") == "1" or not CASSETTES.exists()
|
|
28
|
+
REPLIES: list[dict[str, Any]] = (
|
|
29
|
+
[] if LIVE else json.loads(CASSETTES.read_text(encoding="utf-8"))["replies"]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _replay(request: httpx.Request) -> httpx.Response:
|
|
34
|
+
"""The recorded reply closest to this request: same path first, then query values."""
|
|
35
|
+
query = dict(request.url.params)
|
|
36
|
+
operation = None
|
|
37
|
+
if request.content:
|
|
38
|
+
try:
|
|
39
|
+
body = json.loads(request.content)
|
|
40
|
+
operation = body.get("operationName") if isinstance(body, dict) else None
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
found = [
|
|
44
|
+
r for r in REPLIES
|
|
45
|
+
if r["method"] == request.method
|
|
46
|
+
and re.fullmatch(r["path_regex"], request.url.path)
|
|
47
|
+
and (operation is None or r.get("operation") in (None, operation))
|
|
48
|
+
] # fmt: skip
|
|
49
|
+
if not found:
|
|
50
|
+
message = f"no recorded reply for {request.method} {request.url.path}"
|
|
51
|
+
return httpx.Response(404, json={"error": message})
|
|
52
|
+
|
|
53
|
+
def score(reply: dict[str, Any]) -> tuple[bool, int, int]:
|
|
54
|
+
same = sum(1 for k, v in reply["query"].items() if query.get(k) == v)
|
|
55
|
+
return reply["path"] == request.url.path, same, -abs(len(reply["query"]) - len(query))
|
|
56
|
+
|
|
57
|
+
best = max(found, key=score)
|
|
58
|
+
return httpx.Response(
|
|
59
|
+
best["status"], text=best["body"], headers={"content-type": best["content_type"]}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture(scope="module")
|
|
64
|
+
def api() -> Iterator[{{ spec.class_name }}]:
|
|
65
|
+
if LIVE:
|
|
66
|
+
with {{ spec.class_name }}() as client:
|
|
67
|
+
yield client
|
|
68
|
+
return
|
|
69
|
+
offline = httpx.Client(transport=httpx.MockTransport(_replay))
|
|
70
|
+
with {{ spec.class_name }}(
|
|
71
|
+
{% for c in spec.credentials %}
|
|
72
|
+
{{ c.arg }}="offline-placeholder",
|
|
73
|
+
{% endfor %}
|
|
74
|
+
http_client=offline,
|
|
75
|
+
max_requests_per_second=0,
|
|
76
|
+
) as client:
|
|
77
|
+
yield client
|
|
78
|
+
{% for m in spec.methods if m.testable %}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_{{ m.name }}(api: {{ spec.class_name }}) -> None:
|
|
82
|
+
{% for c in m | creds(spec, 'any') %}
|
|
83
|
+
if LIVE and not os.environ.get({{ c.env | pyrepr }}):
|
|
84
|
+
pytest.skip("set {{ c.env }} to run this test against the live API")
|
|
85
|
+
{% endfor %}
|
|
86
|
+
result = api.{{ m.name }}({% for p in m.required %}{{ p.name }}={{ p.example }}{% if not loop.last %}, {% endif %}{% endfor %})
|
|
87
|
+
assert result is not None
|
|
88
|
+
{% if m.page_param %}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_iter_{{ m.name }}(api: {{ spec.class_name }}) -> None:
|
|
92
|
+
{% for c in m | creds(spec, 'any') %}
|
|
93
|
+
if LIVE and not os.environ.get({{ c.env | pyrepr }}):
|
|
94
|
+
pytest.skip("set {{ c.env }} to run this test against the live API")
|
|
95
|
+
{% endfor %}
|
|
96
|
+
pages = list(api.iter_{{ m.name }}({% for p in m.required %}{{ p.name }}={{ p.example }}, {% endfor %}max_pages=2))
|
|
97
|
+
assert len(pages) >= 1
|
|
98
|
+
{% endif %}
|
|
99
|
+
{% endfor %}
|
|
100
|
+
{% for m in spec.methods if not m.testable %}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.skip(reason="no example values were captured for its required arguments")
|
|
104
|
+
def test_{{ m.name }}() -> None: ...
|
|
105
|
+
{% endfor %}
|