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.
@@ -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, ENDPOINT_REGISTRY } from "./endpoints.js";
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
- const cache = await readTokenCache(this.config.tokenCachePath);
17
- if (isTokenCacheValid(cache)) {
18
- return normalizeToken(cache.accessToken);
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.authLogin, {
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
- await writeTokenCache(this.config.tokenCachePath, {
34
- ...envelope,
35
- accessToken,
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
- return Boolean(parsed && typeof parsed === 'object' && 'code' in parsed);
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
- const collected = [];
99
- let firstPage;
100
- let total;
101
- let nextFrom = startFrom;
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
- for (let pageCount = 0; pageCount < MAX_PAGES; pageCount++) {
104
- const remaining = requestedSize === undefined
105
- ? maxPageSize
106
- : Math.min(maxPageSize, requestedSize - collected.length);
107
- if (requestedSize !== undefined && remaining <= 0) {
108
- break;
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: nextFrom,
113
- size: remaining,
161
+ from: req.from,
162
+ size: req.size,
114
163
  });
115
164
  if (!this.isPaginatedListResponse(page)) {
116
- if (!firstPage) {
117
- return page;
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 (!firstPage) {
146
- return { total: 0, list: [] };
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 headers = {
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
- Object.entries(query).forEach(([key, value]) => {
199
- url.searchParams.set(key, String(value));
200
- });
201
- const response = await request(url, {
202
- method: endpoint.method,
203
- headers: {
204
- Authorization: authorization,
205
- },
206
- headersTimeout: this.config.timeoutMs,
207
- bodyTimeout: this.config.timeoutMs,
208
- });
209
- const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
210
- if (contentType?.includes('application/json')) {
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
- if (response.statusCode >= 400) {
218
- throw new ApiError('Download failed', undefined, response.statusCode, text);
219
- }
220
- return { text, contentType };
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
- const data = this.unwrapEnvelope(parsed, response.statusCode);
226
- if (data && typeof data === 'object' && 'url' in data && typeof data.url === 'string') {
227
- return { url: String(data.url), contentType };
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
- return { text, contentType };
237
- }
238
- if (response.statusCode >= 400) {
239
- const text = await response.body.text();
240
- throw new ApiError('Download failed', undefined, response.statusCode, text);
241
- }
242
- const buffer = await response.body.arrayBuffer();
243
- const contentDisposition = response.headers['content-disposition'];
244
- const filenameMatch = Array.isArray(contentDisposition)
245
- ? contentDisposition[0]?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
246
- : contentDisposition?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i);
247
- return {
248
- data: new Uint8Array(buffer),
249
- contentType,
250
- filename: filenameMatch ? decodeURIComponent(filenameMatch[1] || filenameMatch[2]) : undefined,
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 = ENDPOINT_REGISTRY[endpointKey];
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);