gangtise-openapi-cli 0.11.0 → 0.12.0
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.
- package/README.md +98 -5
- package/dist/src/cli.js +181 -68
- package/dist/src/core/asyncContent.js +13 -4
- package/dist/src/core/auth.js +1 -4
- package/dist/src/core/client.js +217 -129
- package/dist/src/core/download.js +4 -0
- package/dist/src/core/endpoints.js +138 -89
- package/dist/src/core/output.js +64 -0
- package/dist/src/core/printer.js +7 -3
- package/dist/src/core/quoteSharding.js +81 -0
- package/dist/src/core/titleCache.js +58 -10
- package/dist/src/core/transport.js +91 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/dist/src/core/client.js
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
1
5
|
import { request } from "undici";
|
|
2
6
|
import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
|
|
3
7
|
import { ApiError, ValidationError } from "./errors.js";
|
|
4
|
-
import { ENDPOINTS
|
|
8
|
+
import { ENDPOINTS } from "./endpoints.js";
|
|
5
9
|
import { getLookupData } from "./lookupData/index.js";
|
|
10
|
+
import { getDispatcher, isVerbose, logTiming, markRetryable, runWithConcurrency, withRetry } from "./transport.js";
|
|
11
|
+
const PAGINATION_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
|
|
12
|
+
const AUTH_RETRY_CODES = new Set(["8000014", "8000015"]);
|
|
6
13
|
export class GangtiseClient {
|
|
7
14
|
config;
|
|
8
15
|
refreshPromise = null;
|
|
16
|
+
memoCache = null;
|
|
9
17
|
constructor(config) {
|
|
10
18
|
this.config = config;
|
|
11
19
|
}
|
|
12
|
-
async getAuthorizationHeader() {
|
|
13
|
-
if (this.config.token) {
|
|
20
|
+
async getAuthorizationHeader(forceRefresh = false) {
|
|
21
|
+
if (this.config.token && !forceRefresh) {
|
|
14
22
|
return normalizeToken(this.config.token);
|
|
15
23
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
24
|
+
if (!forceRefresh) {
|
|
25
|
+
if (isTokenCacheValid(this.memoCache)) {
|
|
26
|
+
return normalizeToken(this.memoCache.accessToken);
|
|
27
|
+
}
|
|
28
|
+
const cache = await readTokenCache(this.config.tokenCachePath);
|
|
29
|
+
if (isTokenCacheValid(cache)) {
|
|
30
|
+
this.memoCache = cache;
|
|
31
|
+
return normalizeToken(cache.accessToken);
|
|
32
|
+
}
|
|
19
33
|
}
|
|
20
34
|
if (!this.refreshPromise) {
|
|
21
35
|
this.refreshPromise = this.doTokenRefresh().finally(() => { this.refreshPromise = null; });
|
|
@@ -24,21 +38,24 @@ export class GangtiseClient {
|
|
|
24
38
|
}
|
|
25
39
|
async doTokenRefresh() {
|
|
26
40
|
const credentials = requireAccessCredentials(this.config.accessKey, this.config.secretKey);
|
|
27
|
-
const envelope = await this.requestJson(ENDPOINTS.
|
|
41
|
+
const envelope = await this.requestJson(ENDPOINTS["auth.login"], {
|
|
28
42
|
accessKey: credentials.accessKey,
|
|
29
43
|
secretKey: credentials.secretKey,
|
|
30
44
|
}, false);
|
|
31
45
|
const accessToken = normalizeToken(envelope.accessToken);
|
|
32
46
|
const expiresAt = Math.floor(Date.now() / 1000) + envelope.expiresIn;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
expiresAt,
|
|
37
|
-
});
|
|
47
|
+
const cache = { ...envelope, accessToken, expiresAt };
|
|
48
|
+
this.memoCache = cache;
|
|
49
|
+
await writeTokenCache(this.config.tokenCachePath, cache);
|
|
38
50
|
return accessToken;
|
|
39
51
|
}
|
|
40
52
|
isEnvelope(parsed) {
|
|
41
|
-
|
|
53
|
+
if (!parsed || typeof parsed !== 'object')
|
|
54
|
+
return false;
|
|
55
|
+
const obj = parsed;
|
|
56
|
+
if (!('code' in obj))
|
|
57
|
+
return false;
|
|
58
|
+
return 'msg' in obj || 'data' in obj || 'success' in obj || 'status' in obj;
|
|
42
59
|
}
|
|
43
60
|
throwHttpError(parsed, statusCode) {
|
|
44
61
|
if (this.isEnvelope(parsed)) {
|
|
@@ -95,55 +112,73 @@ export class GangtiseClient {
|
|
|
95
112
|
const startFrom = typeof initialBody.from === 'number' && Number.isFinite(initialBody.from) ? initialBody.from : 0;
|
|
96
113
|
const requestedSize = typeof initialBody.size === 'number' && Number.isFinite(initialBody.size) ? initialBody.size : undefined;
|
|
97
114
|
const maxPageSize = endpoint.pagination?.maxPageSize ?? requestedSize ?? 20;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
115
|
+
// First page: serial — we need total before deciding how many more requests to fan out.
|
|
116
|
+
const firstPageSize = requestedSize === undefined ? maxPageSize : Math.min(maxPageSize, requestedSize);
|
|
117
|
+
const firstPage = await this.requestJson(endpoint, {
|
|
118
|
+
...initialBody,
|
|
119
|
+
from: startFrom,
|
|
120
|
+
size: firstPageSize,
|
|
121
|
+
});
|
|
122
|
+
if (!this.isPaginatedListResponse(firstPage))
|
|
123
|
+
return firstPage;
|
|
124
|
+
const total = firstPage.total;
|
|
125
|
+
const collected = [...firstPage.list];
|
|
126
|
+
// Last page reached on first request
|
|
127
|
+
if (firstPage.list.length < firstPageSize) {
|
|
128
|
+
return {
|
|
129
|
+
...firstPage,
|
|
130
|
+
total,
|
|
131
|
+
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const available = Math.max(total - startFrom, 0);
|
|
135
|
+
const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
|
|
136
|
+
if (collected.length >= target) {
|
|
137
|
+
return {
|
|
138
|
+
...firstPage,
|
|
139
|
+
total,
|
|
140
|
+
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const pageRequests = [];
|
|
144
|
+
let nextFrom = startFrom + firstPage.list.length;
|
|
145
|
+
const endFrom = startFrom + target;
|
|
146
|
+
while (nextFrom < endFrom) {
|
|
147
|
+
const remaining = endFrom - nextFrom;
|
|
148
|
+
const size = Math.min(maxPageSize, remaining);
|
|
149
|
+
pageRequests.push({ from: nextFrom, size });
|
|
150
|
+
nextFrom += size;
|
|
151
|
+
}
|
|
102
152
|
const MAX_PAGES = 1000;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
153
|
+
if (pageRequests.length + 1 > MAX_PAGES) {
|
|
154
|
+
pageRequests.length = MAX_PAGES - 1;
|
|
155
|
+
}
|
|
156
|
+
let unexpectedShape = false;
|
|
157
|
+
let totalDrift = false;
|
|
158
|
+
const pages = await runWithConcurrency(pageRequests, PAGINATION_CONCURRENCY, async (req) => {
|
|
110
159
|
const page = await this.requestJson(endpoint, {
|
|
111
160
|
...initialBody,
|
|
112
|
-
from:
|
|
113
|
-
size:
|
|
161
|
+
from: req.from,
|
|
162
|
+
size: req.size,
|
|
114
163
|
});
|
|
115
164
|
if (!this.isPaginatedListResponse(page)) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
return {
|
|
120
|
-
...firstPage,
|
|
121
|
-
total,
|
|
122
|
-
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
if (!firstPage) {
|
|
126
|
-
firstPage = page;
|
|
127
|
-
total = page.total;
|
|
128
|
-
}
|
|
129
|
-
if (page.list.length === 0) {
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
collected.push(...page.list);
|
|
133
|
-
nextFrom += page.list.length;
|
|
134
|
-
const available = total === undefined ? undefined : Math.max(total - startFrom, 0);
|
|
135
|
-
if (requestedSize !== undefined && collected.length >= requestedSize) {
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
if (available !== undefined && collected.length >= available) {
|
|
139
|
-
break;
|
|
140
|
-
}
|
|
141
|
-
if (page.list.length < remaining) {
|
|
142
|
-
break;
|
|
165
|
+
unexpectedShape = true;
|
|
166
|
+
return [];
|
|
143
167
|
}
|
|
168
|
+
if (page.total !== total)
|
|
169
|
+
totalDrift = true;
|
|
170
|
+
return page.list;
|
|
171
|
+
});
|
|
172
|
+
for (const list of pages) {
|
|
173
|
+
if (list.length === 0)
|
|
174
|
+
continue;
|
|
175
|
+
collected.push(...list);
|
|
176
|
+
}
|
|
177
|
+
if (unexpectedShape && isVerbose()) {
|
|
178
|
+
process.stderr.write(`[gangtise] warning: a page response had unexpected shape; results may be incomplete\n`);
|
|
144
179
|
}
|
|
145
|
-
if (
|
|
146
|
-
|
|
180
|
+
if (totalDrift && isVerbose()) {
|
|
181
|
+
process.stderr.write(`[gangtise] warning: 'total' changed across pages (data shifted during fetch)\n`);
|
|
147
182
|
}
|
|
148
183
|
return {
|
|
149
184
|
...firstPage,
|
|
@@ -163,100 +198,153 @@ export class GangtiseClient {
|
|
|
163
198
|
if (endpoint.path.startsWith('/guide/')) {
|
|
164
199
|
return this.readLocalLookup(endpoint);
|
|
165
200
|
}
|
|
166
|
-
const
|
|
167
|
-
'content-type': 'application/json',
|
|
168
|
-
};
|
|
169
|
-
if (useAuth) {
|
|
170
|
-
headers.Authorization = await this.getAuthorizationHeader();
|
|
171
|
-
}
|
|
172
|
-
const response = await request(new URL(endpoint.path, this.config.baseUrl), {
|
|
173
|
-
method: endpoint.method,
|
|
174
|
-
headers,
|
|
175
|
-
body: endpoint.method === 'GET' ? undefined : JSON.stringify(body ?? {}),
|
|
176
|
-
headersTimeout: this.config.timeoutMs,
|
|
177
|
-
bodyTimeout: this.config.timeoutMs,
|
|
178
|
-
});
|
|
179
|
-
const text = await response.body.text();
|
|
180
|
-
let parsed;
|
|
181
|
-
try {
|
|
182
|
-
parsed = JSON.parse(text);
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
const message = response.statusCode >= 400
|
|
186
|
-
? `API request failed (HTTP ${response.statusCode})`
|
|
187
|
-
: 'Failed to parse API response';
|
|
188
|
-
throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500));
|
|
189
|
-
}
|
|
190
|
-
if (response.statusCode >= 400) {
|
|
191
|
-
this.throwHttpError(parsed, response.statusCode);
|
|
192
|
-
}
|
|
193
|
-
return this.unwrapEnvelope(parsed, response.statusCode);
|
|
194
|
-
}
|
|
195
|
-
async download(endpoint, query) {
|
|
196
|
-
const authorization = await this.getAuthorizationHeader();
|
|
201
|
+
const dispatcher = getDispatcher();
|
|
197
202
|
const url = new URL(endpoint.path, this.config.baseUrl);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
Authorization
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
203
|
+
let authRetried = false;
|
|
204
|
+
return withRetry(async () => {
|
|
205
|
+
const headers = {
|
|
206
|
+
'content-type': 'application/json',
|
|
207
|
+
};
|
|
208
|
+
if (useAuth) {
|
|
209
|
+
headers.Authorization = await this.getAuthorizationHeader();
|
|
210
|
+
}
|
|
211
|
+
const startedAt = Date.now();
|
|
212
|
+
const response = await request(url, {
|
|
213
|
+
method: endpoint.method,
|
|
214
|
+
headers,
|
|
215
|
+
body: endpoint.method === 'GET' ? undefined : JSON.stringify(body ?? {}),
|
|
216
|
+
headersTimeout: this.config.timeoutMs,
|
|
217
|
+
bodyTimeout: this.config.timeoutMs,
|
|
218
|
+
dispatcher,
|
|
219
|
+
});
|
|
211
220
|
const text = await response.body.text();
|
|
221
|
+
logTiming(`${endpoint.method} ${endpoint.path}`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
212
222
|
let parsed;
|
|
213
223
|
try {
|
|
214
224
|
parsed = JSON.parse(text);
|
|
215
225
|
}
|
|
216
226
|
catch {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
227
|
+
const message = response.statusCode >= 400
|
|
228
|
+
? `API request failed (HTTP ${response.statusCode})`
|
|
229
|
+
: 'Failed to parse API response';
|
|
230
|
+
throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500));
|
|
221
231
|
}
|
|
222
232
|
if (response.statusCode >= 400) {
|
|
223
233
|
this.throwHttpError(parsed, response.statusCode);
|
|
224
234
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
235
|
+
try {
|
|
236
|
+
return this.unwrapEnvelope(parsed, response.statusCode);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
// Auto-recover from auth errors by forcing a token refresh once.
|
|
240
|
+
if (useAuth
|
|
241
|
+
&& !authRetried
|
|
242
|
+
&& error instanceof ApiError
|
|
243
|
+
&& error.code
|
|
244
|
+
&& AUTH_RETRY_CODES.has(error.code)
|
|
245
|
+
&& (this.config.accessKey && this.config.secretKey)) {
|
|
246
|
+
authRetried = true;
|
|
247
|
+
this.memoCache = null;
|
|
248
|
+
await this.getAuthorizationHeader(true);
|
|
249
|
+
throw markRetryable(new ApiError(error.message, error.code, error.statusCode, error.details));
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}, {
|
|
254
|
+
onRetry: (attempt, error, delay) => {
|
|
255
|
+
if (!isVerbose())
|
|
256
|
+
return;
|
|
257
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
258
|
+
process.stderr.write(`[gangtise] retry ${attempt} after ${delay.toFixed(0)}ms: ${msg.slice(0, 120)}\n`);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async download(endpoint, query, options) {
|
|
263
|
+
const dispatcher = getDispatcher();
|
|
264
|
+
const url = new URL(endpoint.path, this.config.baseUrl);
|
|
265
|
+
Object.entries(query).forEach(([key, value]) => {
|
|
266
|
+
url.searchParams.set(key, String(value));
|
|
267
|
+
});
|
|
268
|
+
return withRetry(async () => {
|
|
269
|
+
const authorization = await this.getAuthorizationHeader();
|
|
270
|
+
const startedAt = Date.now();
|
|
271
|
+
const response = await request(url, {
|
|
272
|
+
method: endpoint.method,
|
|
273
|
+
headers: { Authorization: authorization },
|
|
274
|
+
headersTimeout: this.config.timeoutMs,
|
|
275
|
+
bodyTimeout: this.config.timeoutMs,
|
|
276
|
+
dispatcher,
|
|
277
|
+
});
|
|
278
|
+
const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
|
|
279
|
+
if (contentType?.includes('application/json')) {
|
|
280
|
+
const text = await response.body.text();
|
|
281
|
+
logTiming(`GET ${endpoint.path} (json)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
282
|
+
let parsed;
|
|
283
|
+
try {
|
|
284
|
+
parsed = JSON.parse(text);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
if (response.statusCode >= 400) {
|
|
288
|
+
throw new ApiError('Download failed', undefined, response.statusCode, text);
|
|
289
|
+
}
|
|
290
|
+
return { text, contentType };
|
|
291
|
+
}
|
|
292
|
+
if (response.statusCode >= 400) {
|
|
293
|
+
this.throwHttpError(parsed, response.statusCode);
|
|
294
|
+
}
|
|
295
|
+
const data = this.unwrapEnvelope(parsed, response.statusCode);
|
|
296
|
+
if (data && typeof data === 'object' && 'url' in data && typeof data.url === 'string') {
|
|
297
|
+
return { url: String(data.url), contentType };
|
|
298
|
+
}
|
|
299
|
+
return { text: JSON.stringify(data, null, 2), contentType };
|
|
300
|
+
}
|
|
301
|
+
if (contentType?.includes('text/plain') || contentType?.includes('text/html')) {
|
|
302
|
+
const text = await response.body.text();
|
|
303
|
+
logTiming(`GET ${endpoint.path} (text)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
304
|
+
if (response.statusCode >= 400) {
|
|
305
|
+
throw new ApiError('Download failed', undefined, response.statusCode, text);
|
|
306
|
+
}
|
|
307
|
+
return { text, contentType };
|
|
228
308
|
}
|
|
229
|
-
return { text: JSON.stringify(data, null, 2), contentType };
|
|
230
|
-
}
|
|
231
|
-
if (contentType?.includes('text/plain') || contentType?.includes('text/html')) {
|
|
232
|
-
const text = await response.body.text();
|
|
233
309
|
if (response.statusCode >= 400) {
|
|
310
|
+
const text = await response.body.text();
|
|
234
311
|
throw new ApiError('Download failed', undefined, response.statusCode, text);
|
|
235
312
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
313
|
+
const contentDisposition = response.headers['content-disposition'];
|
|
314
|
+
const filenameMatch = Array.isArray(contentDisposition)
|
|
315
|
+
? contentDisposition[0]?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
|
|
316
|
+
: contentDisposition?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i);
|
|
317
|
+
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1] || filenameMatch[2]) : undefined;
|
|
318
|
+
// Stream directly to disk when caller already knows the destination
|
|
319
|
+
if (options?.streamTo) {
|
|
320
|
+
await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
|
|
321
|
+
await pipeline(response.body, createWriteStream(options.streamTo));
|
|
322
|
+
logTiming(`GET ${endpoint.path} (stream)`, Date.now() - startedAt, `${response.statusCode}`);
|
|
323
|
+
return { contentType, filename, savedPath: options.streamTo };
|
|
324
|
+
}
|
|
325
|
+
const buffer = await response.body.arrayBuffer();
|
|
326
|
+
logTiming(`GET ${endpoint.path} (binary)`, Date.now() - startedAt, `${response.statusCode}, ${buffer.byteLength}B`);
|
|
327
|
+
return {
|
|
328
|
+
data: new Uint8Array(buffer),
|
|
329
|
+
contentType,
|
|
330
|
+
filename,
|
|
331
|
+
};
|
|
332
|
+
}, {
|
|
333
|
+
onRetry: (attempt, error, delay) => {
|
|
334
|
+
if (!isVerbose())
|
|
335
|
+
return;
|
|
336
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
337
|
+
process.stderr.write(`[gangtise] download retry ${attempt} after ${delay.toFixed(0)}ms: ${msg.slice(0, 120)}\n`);
|
|
338
|
+
},
|
|
339
|
+
});
|
|
252
340
|
}
|
|
253
|
-
async call(endpointKey, body, query) {
|
|
254
|
-
const endpoint =
|
|
341
|
+
async call(endpointKey, body, query, options) {
|
|
342
|
+
const endpoint = ENDPOINTS[endpointKey];
|
|
255
343
|
if (!endpoint) {
|
|
256
344
|
throw new ApiError(`Unknown endpoint key: ${endpointKey}`);
|
|
257
345
|
}
|
|
258
346
|
if (endpoint.kind === 'download') {
|
|
259
|
-
return this.download(endpoint, query ?? {});
|
|
347
|
+
return this.download(endpoint, query ?? {}, options);
|
|
260
348
|
}
|
|
261
349
|
if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
|
|
262
350
|
return this.requestPaginated(endpoint, body);
|
|
@@ -71,6 +71,10 @@ export async function saveDownloadResult(result, fallbackName, output) {
|
|
|
71
71
|
throw new DownloadError("Unexpected download response");
|
|
72
72
|
}
|
|
73
73
|
const file = result;
|
|
74
|
+
if (typeof file.savedPath === "string") {
|
|
75
|
+
process.stdout.write(`${file.savedPath}\n`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
74
78
|
if (file.data instanceof Uint8Array) {
|
|
75
79
|
const outputPath = output ?? file.filename ?? (fallbackName + extFromContentType(file.contentType));
|
|
76
80
|
await saveOutputIfNeeded(file.data, outputPath);
|