@crustjs/openapi 0.1.0 → 0.1.1

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Call a CLI's HTTP API and browse its OpenAPI spec from an api command
4
4
 
5
- `apiCommand({ spec, baseUrl, auth })` adds `<cli> api`, a `gh api`-style escape hatch: `--list` and `--spec` read the bundled OpenAPI document offline, and `api <path>` sends an authenticated request. It also provides the `apiClient` Context, so your own commands share the same base URL and auth.
5
+ `apiCommand({ spec, baseUrl, auth, paginate })` adds `<cli> api`, a `gh api`-style escape hatch: `--list` and `--spec` read the bundled OpenAPI document offline, and `api <path>` sends an authenticated request and prints the response body, byte for byte to a pipe or file. With the `json()` Extension, `--json` prints each mode's data as JSON on stdout. It also provides the `apiClient` Context, so your own commands share the same base URL and auth.
6
6
 
7
7
  ## Install
8
8
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CommandDefinition, CommandShape, ContextFactory, ContextInstance, ContextSetup, ExtensionFactory } from "@crustjs/core";
2
+ import { JsonValue } from "@crustjs/utils/json";
2
3
  interface ApiClientOptions {
3
4
  /** URL request paths resolve against, e.g. `https://api.example.com/v1`. */
4
5
  readonly baseUrl: string;
@@ -63,7 +64,10 @@ interface ApiCommandOptions extends ApiClientOptions {
63
64
  */
64
65
  readonly paginate?: (response: Response) => ApiRequest | undefined | Promise<ApiRequest | undefined>;
65
66
  }
66
- /** A non-2xx response to `api <path>`; its body was already printed to stdout. */
67
+ /**
68
+ * A non-2xx response to `api <path>`; its body was already printed (to stderr under `--json`).
69
+ * The `json()` Extension prints its `status` as the error's `details`.
70
+ */
67
71
  export declare class ApiResponseError extends Error {
68
72
  override name: string;
69
73
  /** The HTTP status code. */
@@ -113,14 +117,16 @@ declare const apiFlags: {
113
117
  };
114
118
  };
115
119
  /** The contributed `api` command, spelled out because the exported factory needs a declared type. */
116
- type ApiCommandDefinition = CommandDefinition<"api", readonly [], CommandShape<readonly [typeof pathArg], typeof apiFlags, {}, void, {}, undefined>, {
120
+ type ApiCommandDefinition = CommandDefinition<"api", readonly [], CommandShape<readonly [typeof pathArg], typeof apiFlags, {}, JsonValue | undefined, {}, readonly JsonValue[] | undefined>, {
117
121
  api: ApiClient;
118
122
  }>;
119
123
  /**
120
124
  * Provides the {@link apiClient} Context and contributes `api`: `api --list` and
121
125
  * `api --spec <operationId|path>` read the spec (calling its loader, if any); `api <path>` sends
122
- * a request through the client, prints the response body to stdout, and fails with
123
- * {@link ApiResponseError} on a non-2xx status.
126
+ * a request through the client, prints the response body to stdout (its exact bytes to a pipe or
127
+ * file; as text to a terminal, failing instead for a binary body), and fails with {@link ApiResponseError} on a non-2xx
128
+ * status. Under the `json()` Extension's `--json`, `api` returns its data as the command result instead: the operations, the
129
+ * definition, or the parsed response body; `--paginate` yields each page body as an event.
124
130
  */
125
131
  export declare const apiCommand: ExtensionFactory<[options: ApiCommandOptions], {
126
132
  api: ApiClient;
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CrustError, defineCommand, defineContext, defineExtension, defineExtensionId } from "@crustjs/core";
2
+ import { jsonErrorDetails } from "@crustjs/utils/json";
2
3
  // src/client.ts
3
4
  /** `path` joined below `baseUrl`, keeping the base's own path; absolute URLs pass through. */
4
5
  function resolveUrl(baseUrl, path) {
@@ -137,7 +138,10 @@ const apiClient = /* @__PURE__ */ defineContext("api").setup(async (input, optio
137
138
  });
138
139
  // src/api-command.ts
139
140
  const API = /* @__PURE__ */ defineExtensionId("crust:openapi");
140
- /** A non-2xx response to `api <path>`; its body was already printed to stdout. */
141
+ /**
142
+ * A non-2xx response to `api <path>`; its body was already printed (to stderr under `--json`).
143
+ * The `json()` Extension prints its `status` as the error's `details`.
144
+ */
141
145
  var ApiResponseError = class extends Error {
142
146
  name = "ApiResponseError";
143
147
  /** The HTTP status code. */
@@ -145,6 +149,7 @@ var ApiResponseError = class extends Error {
145
149
  constructor(status, message) {
146
150
  super(message);
147
151
  this.status = status;
152
+ Object.defineProperty(this, jsonErrorDetails, { value: { status } });
148
153
  }
149
154
  };
150
155
  const METHODS = [
@@ -201,7 +206,8 @@ function operations(spec) {
201
206
  return typeof operation === "object" && operation !== null ? [{
202
207
  method,
203
208
  path,
204
- operation
209
+ operation,
210
+ shared: resolved.parameters
205
211
  }] : [];
206
212
  });
207
213
  });
@@ -225,44 +231,116 @@ async function loadSpec(spec, signal) {
225
231
  }
226
232
  return loaded;
227
233
  }
228
- /** One line per operation: `METHOD path operationId summary`, omitting what the spec lacks. */
234
+ /** Each operation's method, path, and the `operationId` and `summary` the spec has. */
229
235
  function listOperations(spec) {
230
236
  return operations(spec).map(({ method, path, operation }) => {
231
237
  const { operationId, summary } = operation;
232
- return [
233
- method.toUpperCase(),
238
+ return {
239
+ method: method.toUpperCase(),
234
240
  path,
235
- operationId,
236
- summary
237
- ].filter((part) => typeof part === "string" && part !== "").join(" ");
241
+ ...typeof operationId === "string" && operationId !== "" ? { operationId } : {},
242
+ ...typeof summary === "string" && summary !== "" ? { summary } : {}
243
+ };
238
244
  });
239
245
  }
240
- /** Follows local `$ref`s (`#/...`), keeping sibling keys; a `$ref` back into its own chain stays as is. */
241
- function resolveRefs(value, spec, chain) {
242
- if (Array.isArray(value)) return value.map((item) => resolveRefs(item, spec, chain));
243
- if (typeof value !== "object" || value === null) return value;
244
- const { $ref, ...siblings } = value;
245
- if (typeof $ref === "string" && $ref.startsWith("#") && !chain.includes($ref)) {
246
- const target = refTarget(spec, $ref);
247
- if (target !== void 0) {
248
- const resolved = resolveRefs(target, spec, [...chain, $ref]);
246
+ function isLocalRef(ref) {
247
+ return typeof ref === "string" && ref.startsWith("#");
248
+ }
249
+ /** `value` with its local `$ref` chain followed, stopping at a cycle or a dangling `$ref`. */
250
+ function deref(spec, value) {
251
+ const seen = /* @__PURE__ */ new Set();
252
+ for (;;) {
253
+ const ref = typeof value === "object" && value !== null ? value.$ref : void 0;
254
+ if (!isLocalRef(ref) || seen.has(ref)) return value;
255
+ seen.add(ref);
256
+ const target = refTarget(spec, ref);
257
+ if (target === void 0) return value;
258
+ value = target;
259
+ }
260
+ }
261
+ /** The path item's `parameters`, minus those the operation redefines (same `name` and `in`), then the operation's. */
262
+ function mergeParameters(spec, shared, own) {
263
+ const list = (parameters) => Array.isArray(parameters) ? parameters : [];
264
+ const key = (parameter) => {
265
+ const { name, in: location } = deref(spec, parameter) ?? {};
266
+ return typeof name === "string" && typeof location === "string" ? JSON.stringify([location, name]) : void 0;
267
+ };
268
+ const redefined = new Set(list(own).map(key));
269
+ return [...list(shared).filter((parameter) => {
270
+ const id = key(parameter);
271
+ return id === void 0 || !redefined.has(id);
272
+ }), ...list(own)];
273
+ }
274
+ /**
275
+ * `value` with local `$ref`s (`#/...`) followed, each target written once: a target used once is
276
+ * inlined, keeping sibling keys; one used more often, or recursively, keeps its `$ref`s and is
277
+ * written once under `$refs`, keyed by the `$ref` text. Inlining every use instead grows
278
+ * exponentially with shared schemas, as in large public specs.
279
+ */
280
+ function resolveRefs(value, spec) {
281
+ const targets = /* @__PURE__ */ new Map();
282
+ const target = (ref) => {
283
+ if (!targets.has(ref)) targets.set(ref, refTarget(spec, ref));
284
+ return targets.get(ref);
285
+ };
286
+ const uses = /* @__PURE__ */ new Map();
287
+ const count = (node) => {
288
+ if (typeof node !== "object" || node === null) return;
289
+ for (const item of Object.values(node)) count(item);
290
+ const { $ref } = node;
291
+ if (!isLocalRef($ref) || target($ref) === void 0) return;
292
+ const seen = uses.get($ref) ?? 0;
293
+ uses.set($ref, seen + 1);
294
+ if (seen === 0) count(target($ref));
295
+ };
296
+ count(value);
297
+ const refs = {};
298
+ const write = (node) => {
299
+ if (Array.isArray(node)) return node.map(write);
300
+ if (typeof node !== "object" || node === null) return node;
301
+ const { $ref, ...siblings } = node;
302
+ const used = isLocalRef($ref) ? uses.get($ref) ?? 0 : 0;
303
+ if (used === 1) {
304
+ const resolved = write(target($ref));
249
305
  return typeof resolved === "object" && resolved !== null && !Array.isArray(resolved) ? {
250
306
  ...resolved,
251
- ...resolveRefs(siblings, spec, chain)
307
+ ...write(siblings)
252
308
  } : resolved;
253
309
  }
254
- }
255
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveRefs(item, spec, chain)]));
310
+ if (used > 1 && !Object.hasOwn(refs, $ref)) {
311
+ refs[$ref] = void 0;
312
+ refs[$ref] = write(target($ref));
313
+ }
314
+ return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, key === "$ref" ? item : write(item)]));
315
+ };
316
+ const written = write(value);
317
+ return Object.keys(refs).length > 0 ? {
318
+ ...written,
319
+ $refs: refs
320
+ } : written;
256
321
  }
257
322
  /**
258
- * The path item for a spec path, or the operation with that `operationId`, with
259
- * local `$ref`s resolved.
323
+ * The path item for a spec path, or the operation with that `operationId` with its method, path,
324
+ * and the path item's shared `parameters`; local `$ref`s resolved by {@link resolveRefs}.
260
325
  */
261
326
  function specFor(spec, target) {
262
327
  const paths = spec.paths ?? {};
263
- const found = target.startsWith("/") && Object.hasOwn(paths, target) ? paths[target] : operations(spec).find(({ operation }) => operation.operationId === target)?.operation;
264
- if (found === void 0) throw new Error(`No operation or path "${target}" in the OpenAPI spec`);
265
- return resolveRefs(found, spec, []);
328
+ if (target.startsWith("/") && Object.hasOwn(paths, target)) return resolveRefs(paths[target], spec);
329
+ const matches = operations(spec).filter(({ operation }) => operation.operationId === target);
330
+ const [match] = matches;
331
+ if (match === void 0) throw new Error(`No operation or path "${target}" in the OpenAPI spec`);
332
+ if (matches.length > 1) {
333
+ const candidates = matches.map(({ method, path }) => `${method.toUpperCase()} ${path}`);
334
+ throw new Error(`The operationId "${target}" is ambiguous in the OpenAPI spec (${candidates.join(", ")}): pass a path instead`);
335
+ }
336
+ const { method, path, operation, shared } = match;
337
+ const parameters = mergeParameters(spec, shared, operation.parameters);
338
+ return resolveRefs({
339
+ method: method.toUpperCase(),
340
+ path,
341
+ ...operation,
342
+ ...parameters.length > 0 ? { parameters } : {}
343
+ }, spec);
266
344
  }
267
345
  const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
268
346
  /** `key=value`; `true`, `false`, `null`, and JSON numbers become JSON values, like `gh api -F`. */
@@ -281,6 +359,22 @@ function parseField(field) {
281
359
  if (!Number.isFinite(number)) throw new CrustError("PARSE", `Invalid -F value for "${field.slice(0, at)}": the number is out of range.`, { flag: "field" });
282
360
  return [field.slice(0, at), number];
283
361
  }
362
+ /** RFC 9110 `token`, the grammar of a method. */
363
+ const METHOD_TOKEN = /^[!#$%&'*+.^_`|~\w-]+$/;
364
+ /** The method to send for `-X`, upper-cased, failing before any request for one fetch() would not send. */
365
+ function parseMethod(method) {
366
+ if (!METHOD_TOKEN.test(method)) throw new CrustError("PARSE", "Invalid -X value: expected an HTTP method such as GET or PATCH, without spaces.", { flag: "method" });
367
+ const normalized = method.toUpperCase();
368
+ let sent;
369
+ try {
370
+ sent = new Request("http://localhost/", { method: normalized }).method;
371
+ } catch {}
372
+ if (sent !== normalized) {
373
+ const problem = sent === void 0 ? "cannot send it" : `would send it as ${sent}`;
374
+ throw new CrustError("PARSE", `-X ${normalized}: this runtime's fetch() ${problem}.`, { flag: "method" });
375
+ }
376
+ return normalized;
377
+ }
284
378
  function parseHeader(header) {
285
379
  const at = header.indexOf(":");
286
380
  if (at <= 0) throw new CrustError("PARSE", "Invalid -H value: expected \"Name: value\".", { flag: "header" });
@@ -349,16 +443,23 @@ const apiFlags = {
349
443
  /**
350
444
  * Provides the {@link apiClient} Context and contributes `api`: `api --list` and
351
445
  * `api --spec <operationId|path>` read the spec (calling its loader, if any); `api <path>` sends
352
- * a request through the client, prints the response body to stdout, and fails with
353
- * {@link ApiResponseError} on a non-2xx status.
446
+ * a request through the client, prints the response body to stdout (its exact bytes to a pipe or
447
+ * file; as text to a terminal, failing instead for a binary body), and fails with {@link ApiResponseError} on a non-2xx
448
+ * status. Under the `json()` Extension's `--json`, `api` returns its data as the command result instead: the operations, the
449
+ * definition, or the parsed response body; `--paginate` yields each page body as an event.
354
450
  */
355
- const apiCommand = /* @__PURE__ */ defineExtension(API).factory((extension, { spec, paginate, ...client }) => extension.provide(apiClient(client)).add(defineCommand("api", { description: "Call the HTTP API, or browse its OpenAPI spec" }, (command) => command.use(apiClient).args(pathArg).flags(apiFlags).action(async ({ args, flags, ctx, signal, stdout }) => {
451
+ const apiCommand = /* @__PURE__ */ defineExtension(API).factory((extension, { spec, paginate, ...client }) => extension.provide(apiClient(client)).add(defineCommand("api", { description: "Call the HTTP API, or browse its OpenAPI spec" }, (command) => command.use(apiClient).args(pathArg).flags(apiFlags).action(async ({ args, flags, ctx, signal, stdout, stderr }) => {
452
+ const json = flags.json === true;
356
453
  if (flags.list) {
357
- for (const line of listOperations(await loadSpec(spec, signal))) stdout(line);
454
+ const listed = listOperations(await loadSpec(spec, signal));
455
+ if (json) return listed;
456
+ for (const entry of listed) stdout(Object.values(entry).join(" "));
358
457
  return;
359
458
  }
360
459
  if (flags.spec !== void 0) {
361
- stdout(JSON.stringify(specFor(await loadSpec(spec, signal), flags.spec)));
460
+ const found = specFor(await loadSpec(spec, signal), flags.spec);
461
+ if (json) return found;
462
+ stdout(JSON.stringify(found));
362
463
  return;
363
464
  }
364
465
  if (args.path === void 0) throw new CrustError("VALIDATION", "Missing required argument \"<path>\" (or pass --list or --spec)");
@@ -372,7 +473,7 @@ const apiCommand = /* @__PURE__ */ defineExtension(API).factory((extension, { sp
372
473
  }
373
474
  const fields = (flags.field ?? []).map(parseField);
374
475
  const body = flags.body === void 0 ? void 0 : await readBody(flags.body, signal);
375
- const method = (flags.method ?? (fields.length > 0 || body !== void 0 ? "POST" : "GET")).toUpperCase();
476
+ const method = parseMethod(flags.method ?? (fields.length > 0 || body !== void 0 ? "POST" : "GET"));
376
477
  const headers = new Headers((flags.header ?? []).map(parseHeader));
377
478
  let path = args.path;
378
479
  let requestBody = body;
@@ -385,36 +486,96 @@ const apiCommand = /* @__PURE__ */ defineExtension(API).factory((extension, { sp
385
486
  }
386
487
  if (requestBody !== void 0 && !headers.has("content-type")) headers.set("content-type", "application/json");
387
488
  const api = await ctx.api;
388
- let request = {
389
- path,
390
- init: {
391
- method,
392
- headers,
393
- body: requestBody
394
- }
395
- };
396
- let sentMethod = method;
397
- let response = await api.fetch(request.path, request.init);
398
- for (let pageNumber = 2;; pageNumber++) {
399
- const page = flags.paginate ? response.clone() : void 0;
400
- const text = await response.text();
401
- if (flags.paginate) stdout(compactJson(text));
402
- else if (text !== "") stdout(text.replace(/\r?\n$/, ""));
403
- if (!response.ok) throw new ApiResponseError(response.status, `${request.init?.method ?? "GET"} ${request.path}: HTTP ${response.status} ${response.statusText}`.trimEnd());
404
- const next = page && paginate ? await paginate(page) : void 0;
405
- if (next === void 0) return;
406
- const nextInit = next.init ?? request.init;
407
- const nextUrl = resolveUrl(client.baseUrl, next.path).href;
408
- const nextMethod = nextInit?.method ?? "GET";
409
- if (nextInit === request.init && nextMethod === sentMethod && nextUrl === resolveUrl(client.baseUrl, request.path).href) throw new Error(`--paginate: page ${pageNumber} repeats the previous request (${nextMethod} ${nextUrl})`);
410
- request = {
411
- path: next.path,
412
- init: nextInit
489
+ /** The response and body of each page: just one without --paginate. Fails after a non-2xx page. */
490
+ async function* pages() {
491
+ let request = {
492
+ path,
493
+ init: {
494
+ method,
495
+ headers,
496
+ body: requestBody
497
+ }
413
498
  };
414
- sentMethod = nextMethod;
415
- response = await fetchNextPage(api, request.path, request.init);
499
+ let shown = args.path;
500
+ let sentMethod = method;
501
+ let response = await api.fetch(request.path, request.init);
502
+ for (let pageNumber = 2;; pageNumber++) {
503
+ const page = flags.paginate ? response.clone() : void 0;
504
+ yield {
505
+ response,
506
+ bytes: new Uint8Array(await response.arrayBuffer())
507
+ };
508
+ if (!response.ok) throw new ApiResponseError(response.status, `${sentMethod} ${withoutQuery(shown)}: HTTP ${response.status} ${response.statusText}`.trimEnd());
509
+ const next = page && paginate ? await paginate(page) : void 0;
510
+ if (next === void 0) return;
511
+ const nextInit = next.init ?? request.init;
512
+ const nextUrl = resolveUrl(client.baseUrl, next.path).href;
513
+ const nextMethod = nextInit?.method ?? "GET";
514
+ if (nextInit === request.init && nextMethod === sentMethod && nextUrl === resolveUrl(client.baseUrl, request.path).href) throw new Error(`--paginate: page ${pageNumber} repeats the previous request (${nextMethod} ${withoutQuery(nextUrl)})`);
515
+ request = {
516
+ path: next.path,
517
+ init: nextInit
518
+ };
519
+ shown = next.path;
520
+ sentMethod = nextMethod;
521
+ response = await fetchNextPage(api, request.path, request.init);
522
+ }
523
+ }
524
+ /** Prints each page's body; under --json, yields a successful page's body as JSON data instead. */
525
+ async function* bodies() {
526
+ const { getAmbientTerminalIO, writeProcessOutput } = await import("@crustjs/utils/terminal");
527
+ let refusal;
528
+ if (flags.paginate) refusal = "--paginate prints only text and JSON bodies";
529
+ else if (json) refusal = "--json prints only text and JSON bodies, so run without it to write the bytes to stdout";
530
+ else if (getAmbientTerminalIO() !== void 0) refusal = "its bytes go only to the process's stdout, not to captured output";
531
+ else if (process.getBuiltinModule("node:tty").isatty(1)) refusal = "redirect or pipe stdout to save it instead of printing it to the terminal";
532
+ for await (const { response, bytes } of pages()) {
533
+ if (refusal === void 0) {
534
+ writeProcessOutput(1, bytes);
535
+ continue;
536
+ }
537
+ const text = bodyText(bytes);
538
+ if (text === void 0) {
539
+ if (response.ok) {
540
+ const type = response.headers.get("content-type") ?? "no Content-Type";
541
+ throw new Error(`api: ${flags.paginate ? "a --paginate page" : "the response"} body is binary (${type}); ${refusal}`);
542
+ }
543
+ continue;
544
+ }
545
+ if (!json) {
546
+ if (flags.paginate) stdout(compactJson(text));
547
+ else if (text !== "") stdout(text.replace(/\r?\n$/, ""));
548
+ } else if (!response.ok) {
549
+ if (text !== "") stderr(text.replace(/\r?\n$/, ""));
550
+ } else if (flags.paginate || text !== "") yield bodyValue(text);
551
+ }
552
+ return void 0;
416
553
  }
554
+ if (flags.paginate && json) return bodies();
555
+ let result;
556
+ for await (const value of bodies()) result = value;
557
+ return result;
417
558
  }))));
559
+ /** `path` up to its query or fragment, which may hold a credential such as an `-F` field. */
560
+ function withoutQuery(path) {
561
+ return path.replace(/[?#][\s\S]*$/, "");
562
+ }
563
+ /** The body as text, or `undefined` when it is not valid UTF-8. */
564
+ function bodyText(bytes) {
565
+ try {
566
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
567
+ } catch {
568
+ return;
569
+ }
570
+ }
571
+ /** A body as JSON data: parsed when it is JSON (numbers as doubles), the text itself otherwise. */
572
+ function bodyValue(text) {
573
+ try {
574
+ return JSON.parse(text, (_key, value) => Object.is(value, -0) ? 0 : value);
575
+ } catch {
576
+ return text;
577
+ }
578
+ }
418
579
  /** A page body as one NDJSON line: JSON compacted, any other text (even empty) as a JSON string. */
419
580
  function compactJson(text) {
420
581
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crustjs/openapi",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Call a CLI's HTTP API and browse its OpenAPI spec from an api command",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -36,17 +36,18 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@crustjs/utils": "^0.3.0"
39
+ "@crustjs/utils": "^0.3.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@crustjs/config": "0.0.0",
43
- "@crustjs/core": "0.6.0",
44
- "@crustjs/testing": "0.1.7",
43
+ "@crustjs/core": "0.6.1",
44
+ "@crustjs/extensions": "0.6.1",
45
+ "@crustjs/testing": "0.1.8",
45
46
  "vite-plus": "1.4.0"
46
47
  },
47
48
  "peerDependencies": {
48
- "@crustjs/core": "^0.6.0",
49
- "typescript": "^7.0.0"
49
+ "@crustjs/core": "^0.6.1",
50
+ "typescript": ">=5.9.0"
50
51
  },
51
52
  "peerDependenciesMeta": {
52
53
  "typescript": {