@crustjs/openapi 0.0.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
@@ -1,3 +1,15 @@
1
1
  # @crustjs/openapi
2
2
 
3
- This `0.0.0` version is an empty placeholder, published once so the package can use npm trusted publishing. Do not install it. The first real release will be `0.1.0`. See https://crustjs.com.
3
+ Call a CLI's HTTP API and browse its OpenAPI spec from an api command
4
+
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
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @crustjs/openapi
11
+ ```
12
+
13
+ ## Documentation
14
+
15
+ Full docs: [crustjs.com/docs/modules/openapi](https://crustjs.com/docs/modules/openapi)
@@ -0,0 +1,134 @@
1
+ import { CommandDefinition, CommandShape, ContextFactory, ContextInstance, ContextSetup, ExtensionFactory } from "@crustjs/core";
2
+ import { JsonValue } from "@crustjs/utils/json";
3
+ interface ApiClientOptions {
4
+ /** URL request paths resolve against, e.g. `https://api.example.com/v1`. */
5
+ readonly baseUrl: string;
6
+ /**
7
+ * Headers added to requests to the base URL's origin, e.g. an `Authorization` header, or any
8
+ * default header such as `User-Agent` or an API-version header. All of them are treated as
9
+ * credentials: they are never sent off the base URL's origin.
10
+ * Called at most once per invocation, on the first request that sends credentials: never
11
+ * for a command that only reads the Context, nor for requests with `auth: false`.
12
+ */
13
+ readonly auth?: (input: ContextSetup) => Readonly<Record<string, string>> | undefined | Promise<Readonly<Record<string, string>> | undefined>;
14
+ }
15
+ /** The `api` Context value: `fetch()` bound to the API's base URL and auth. */
16
+ interface ApiClient {
17
+ readonly baseUrl: string;
18
+ /**
19
+ * `fetch()` for `path` below {@link ApiClient.baseUrl} (an absolute URL is used as is, but
20
+ * must be on the base URL's origin: otherwise it rejects with a `TypeError` before any request),
21
+ * with the `auth` headers the request does not set itself, and the invocation's signal
22
+ * unless `init.signal` is given. Redirects are followed here
23
+ * (up to 20 per request, http: and https: only); one to another origin than the base URL's
24
+ * continues without `Authorization`, `Cookie`, `Proxy-Authorization`, or any header named like an `auth` header.
25
+ * With `auth: false` (e.g. a public operation), neither the request nor its redirects
26
+ * call `auth` or carry its headers.
27
+ */
28
+ fetch(path: string, init?: RequestInit & {
29
+ readonly auth?: false;
30
+ }): Promise<Response>;
31
+ }
32
+ /**
33
+ * The shared API client Context. {@link apiCommand} provides it, so app commands
34
+ * declare `.use(apiClient)` and call `(await ctx.api).fetch("/users")`.
35
+ */
36
+ export declare const apiClient: ContextFactory<"api", ApiClientOptions, ApiClient>;
37
+ /**
38
+ * An OpenAPI document; only `paths` and local `$ref` targets are read. Keys of `paths`
39
+ * not starting with `/`, such as `x-*` extensions, are skipped.
40
+ */
41
+ interface OpenApiSpec {
42
+ readonly paths?: Readonly<Record<string, unknown>>;
43
+ readonly [key: string]: unknown;
44
+ }
45
+ /** A request for {@link ApiClient.fetch}; without `init`, the previous request's is reused. */
46
+ interface ApiRequest {
47
+ readonly path: string;
48
+ readonly init?: RequestInit;
49
+ }
50
+ interface ApiCommandOptions extends ApiClientOptions {
51
+ /**
52
+ * The OpenAPI document, e.g. an imported JSON file bundled with the CLI and read offline;
53
+ * or a loader for it, called with the invocation's signal only when `api --list` or
54
+ * `api --spec` runs, e.g. a lazy `import()` of the JSON file that keeps it out of startup.
55
+ */
56
+ readonly spec: OpenApiSpec | ((input: {
57
+ signal: AbortSignal;
58
+ }) => OpenApiSpec | Promise<OpenApiSpec>);
59
+ /**
60
+ * The next page's request for `--paginate`, or `undefined` after the last page.
61
+ * Receives a copy of each successful response, so it may read the body. The next page
62
+ * must be http(s); off the base URL's origin it is requested without credential headers.
63
+ * Returning the previous request again (same URL and method, `init` reused) fails instead of looping.
64
+ */
65
+ readonly paginate?: (response: Response) => ApiRequest | undefined | Promise<ApiRequest | undefined>;
66
+ }
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
+ */
71
+ export declare class ApiResponseError extends Error {
72
+ override name: string;
73
+ /** The HTTP status code. */
74
+ readonly status: number;
75
+ constructor(status: number, message: string);
76
+ }
77
+ declare const pathArg: {
78
+ readonly name: "path";
79
+ readonly type: "string";
80
+ readonly description: "Request path below the API base URL, e.g. /users";
81
+ };
82
+ declare const apiFlags: {
83
+ readonly list: {
84
+ readonly type: "boolean";
85
+ readonly noNegate: true;
86
+ readonly description: "List the API's operations";
87
+ };
88
+ readonly spec: {
89
+ readonly type: "string";
90
+ readonly description: "Print the OpenAPI definition of an operationId or path";
91
+ };
92
+ readonly method: {
93
+ readonly type: "string";
94
+ readonly short: "X";
95
+ readonly description: "HTTP method; default GET, or POST with fields or a body";
96
+ };
97
+ readonly header: {
98
+ readonly type: "string";
99
+ readonly short: "H";
100
+ readonly multiple: true;
101
+ readonly description: "Request header, 'Key: Value'";
102
+ };
103
+ readonly field: {
104
+ readonly type: "string";
105
+ readonly short: "F";
106
+ readonly multiple: true;
107
+ readonly description: "key=value; true, false, null, and numbers are sent as JSON";
108
+ };
109
+ readonly body: {
110
+ readonly type: "string";
111
+ readonly description: "Request body: inline, @file, or - for stdin";
112
+ };
113
+ readonly paginate: {
114
+ readonly type: "boolean";
115
+ readonly noNegate: true;
116
+ readonly description: "Fetch every page, printing each response body as one JSON line";
117
+ };
118
+ };
119
+ /** The contributed `api` command, spelled out because the exported factory needs a declared type. */
120
+ type ApiCommandDefinition = CommandDefinition<"api", readonly [], CommandShape<readonly [typeof pathArg], typeof apiFlags, {}, JsonValue | undefined, {}, readonly JsonValue[] | undefined>, {
121
+ api: ApiClient;
122
+ }>;
123
+ /**
124
+ * Provides the {@link apiClient} Context and contributes `api`: `api --list` and
125
+ * `api --spec <operationId|path>` read the spec (calling its loader, if any); `api <path>` sends
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.
130
+ */
131
+ export declare const apiCommand: ExtensionFactory<[options: ApiCommandOptions], {
132
+ api: ApiClient;
133
+ }, readonly [ContextInstance<"api", ApiClient, {}, {}>], {}, readonly [ApiCommandDefinition], never, {}>;
134
+ export type { ApiClient, ApiClientOptions, ApiCommandOptions, ApiRequest, OpenApiSpec };
package/dist/index.js ADDED
@@ -0,0 +1,588 @@
1
+ import { CrustError, defineCommand, defineContext, defineExtension, defineExtensionId } from "@crustjs/core";
2
+ import { jsonErrorDetails } from "@crustjs/utils/json";
3
+ // src/client.ts
4
+ /** `path` joined below `baseUrl`, keeping the base's own path; absolute URLs pass through. */
5
+ function resolveUrl(baseUrl, path) {
6
+ return URL.canParse(path) ? new URL(path) : new URL(`${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`);
7
+ }
8
+ /**
9
+ * `path` resolved like {@link ApiClient.fetch} does for a first request, which must stay on
10
+ * `baseUrl`'s origin, so credentials set for the API never go to a host the caller named;
11
+ * only redirects and next pages may leave it. Throws a `TypeError` for any other origin.
12
+ */
13
+ function baseOriginUrl(baseUrl, path) {
14
+ const url = resolveUrl(baseUrl, path);
15
+ const { origin } = new URL(baseUrl);
16
+ if (url.origin !== origin) throw new TypeError(`Cannot request ${url.href}: only URLs on ${origin} are allowed`);
17
+ return url;
18
+ }
19
+ const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
20
+ 301,
21
+ 302,
22
+ 303,
23
+ 307,
24
+ 308
25
+ ]);
26
+ const MAX_REDIRECTS = 20;
27
+ const BODY_HEADERS = [
28
+ "content-encoding",
29
+ "content-language",
30
+ "content-location",
31
+ "content-type"
32
+ ];
33
+ /** `response` reporting `redirected`, as `fetch()` does after following a redirect, on its clones too. */
34
+ function markRedirected(response) {
35
+ const clone = response.clone.bind(response);
36
+ return Object.defineProperties(response, {
37
+ redirected: { value: true },
38
+ clone: { value: () => markRedirected(clone()) }
39
+ });
40
+ }
41
+ const nextPageFetchers = /* @__PURE__ */ new WeakMap();
42
+ /**
43
+ * `client.fetch()` for a next page a response named: like a redirect target, it must be
44
+ * http(s) and gets no credential headers off the base URL's origin, even ones the request set.
45
+ */
46
+ function fetchNextPage(client, path, init) {
47
+ const fetchNext = nextPageFetchers.get(client);
48
+ if (fetchNext === void 0) throw new TypeError("fetchNextPage: not an apiClient Context value");
49
+ return fetchNext(path, init);
50
+ }
51
+ /**
52
+ * The shared API client Context. {@link apiCommand} provides it, so app commands
53
+ * declare `.use(apiClient)` and call `(await ctx.api).fetch("/users")`.
54
+ */
55
+ const apiClient = /* @__PURE__ */ defineContext("api").setup(async (input, options) => {
56
+ const origin = new URL(options.baseUrl).origin;
57
+ const credentialHeaders = [
58
+ "authorization",
59
+ "cookie",
60
+ "proxy-authorization"
61
+ ];
62
+ let authHeaders;
63
+ async function loadAuthHeaders() {
64
+ const headers = new Headers(await options.auth?.(input));
65
+ credentialHeaders.push(...headers.keys());
66
+ return headers;
67
+ }
68
+ const firstHop = (url, { auth, ...init } = {}) => ({
69
+ url,
70
+ init: {
71
+ ...init,
72
+ headers: new Headers(init.headers),
73
+ signal: init.signal ?? input.signal
74
+ },
75
+ auth: auth !== false,
76
+ redirects: 0
77
+ });
78
+ /**
79
+ * The request for `target` after `from`, for a redirect with `status` or, without one,
80
+ * a next page. Every move to a URL a response named goes through here, so the protocol,
81
+ * redirect limit, and credential rules cannot be bypassed.
82
+ */
83
+ function step(from, target, status) {
84
+ from.init.signal?.throwIfAborted();
85
+ if (target.protocol !== "http:" && target.protocol !== "https:") throw new TypeError(`Cannot request ${target.href}: only http: and https: URLs`);
86
+ const headers = new Headers(from.init.headers);
87
+ let { method = "GET", body } = from.init;
88
+ if (status !== void 0) {
89
+ if (from.redirects === MAX_REDIRECTS) throw new TypeError(`fetch ${from.url.href}: too many redirects`);
90
+ const upper = method.toUpperCase();
91
+ if (status === 303 ? upper !== "GET" && upper !== "HEAD" : status <= 302 && upper === "POST") {
92
+ method = "GET";
93
+ body = null;
94
+ for (const name of BODY_HEADERS) headers.delete(name);
95
+ }
96
+ }
97
+ if (target.origin !== origin) for (const name of credentialHeaders) headers.delete(name);
98
+ const redirects = from.redirects + (status === void 0 ? 0 : 1);
99
+ return {
100
+ ...from,
101
+ url: target,
102
+ init: {
103
+ ...from.init,
104
+ method,
105
+ body,
106
+ headers
107
+ },
108
+ redirects
109
+ };
110
+ }
111
+ async function send(hop) {
112
+ const redirect = hop.init.redirect ?? "follow";
113
+ for (;;) {
114
+ const headers = new Headers(hop.init.headers);
115
+ if (hop.auth && hop.url.origin === origin) {
116
+ for (const [name, value] of await (authHeaders ??= loadAuthHeaders())) if (!headers.has(name)) headers.set(name, value);
117
+ }
118
+ const response = await fetch(hop.url, {
119
+ ...hop.init,
120
+ headers,
121
+ redirect: redirect === "follow" ? "manual" : redirect
122
+ });
123
+ const location = response.headers.get("location");
124
+ if (redirect !== "follow" || !REDIRECT_STATUSES.has(response.status) || location === null) return hop.redirects > 0 ? markRedirected(response) : response;
125
+ await response.body?.cancel();
126
+ hop = step(hop, new URL(location, hop.url), response.status);
127
+ }
128
+ }
129
+ const client = {
130
+ baseUrl: options.baseUrl,
131
+ fetch: async (path, init) => send(firstHop(baseOriginUrl(options.baseUrl, path), init))
132
+ };
133
+ nextPageFetchers.set(client, (path, init) => {
134
+ const target = resolveUrl(options.baseUrl, path);
135
+ return send(step(firstHop(target, init), target));
136
+ });
137
+ return client;
138
+ });
139
+ // src/api-command.ts
140
+ const API = /* @__PURE__ */ defineExtensionId("crust:openapi");
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
+ */
145
+ var ApiResponseError = class extends Error {
146
+ name = "ApiResponseError";
147
+ /** The HTTP status code. */
148
+ status;
149
+ constructor(status, message) {
150
+ super(message);
151
+ this.status = status;
152
+ Object.defineProperty(this, jsonErrorDetails, { value: { status } });
153
+ }
154
+ };
155
+ const METHODS = [
156
+ "get",
157
+ "put",
158
+ "post",
159
+ "delete",
160
+ "options",
161
+ "head",
162
+ "patch",
163
+ "trace"
164
+ ];
165
+ /**
166
+ * The value a local `$ref`'s JSON Pointer fragment (`#/...`, URI-encoded or not) points to
167
+ * in `spec`, or `undefined`, also for other fragments such as `#anchor`.
168
+ */
169
+ function refTarget(spec, ref) {
170
+ let pointer;
171
+ try {
172
+ pointer = decodeURIComponent(ref.slice(1));
173
+ } catch {
174
+ return;
175
+ }
176
+ if (pointer !== "" && !pointer.startsWith("/")) return void 0;
177
+ let target = spec;
178
+ for (const token of pointer.split("/").slice(1)) {
179
+ const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
180
+ target = typeof target === "object" && target !== null && Object.hasOwn(target, key) ? target[key] : void 0;
181
+ }
182
+ return target;
183
+ }
184
+ /** A Path Item with its own local `$ref`s (e.g. to `components.pathItems`) followed; sibling keys win. */
185
+ function pathItem(spec, item) {
186
+ let current = item;
187
+ const seen = /* @__PURE__ */ new Set();
188
+ for (let ref = current.$ref; typeof ref === "string" && ref.startsWith("#") && !seen.has(ref); ref = current.$ref) {
189
+ seen.add(ref);
190
+ const target = refTarget(spec, ref);
191
+ if (typeof target !== "object" || target === null || Array.isArray(target)) break;
192
+ const { $ref: _ref, ...siblings } = current;
193
+ current = {
194
+ ...target,
195
+ ...siblings
196
+ };
197
+ }
198
+ return current;
199
+ }
200
+ function operations(spec) {
201
+ return Object.entries(spec.paths ?? {}).flatMap(([path, item]) => {
202
+ if (!path.startsWith("/") || typeof item !== "object" || item === null) return [];
203
+ const resolved = pathItem(spec, item);
204
+ return METHODS.flatMap((method) => {
205
+ const operation = resolved[method];
206
+ return typeof operation === "object" && operation !== null ? [{
207
+ method,
208
+ path,
209
+ operation,
210
+ shared: resolved.parameters
211
+ }] : [];
212
+ });
213
+ });
214
+ }
215
+ /** `spec`, or what its loader returns; a failure other than cancellation becomes a plain `Error`. */
216
+ async function loadSpec(spec, signal) {
217
+ if (typeof spec !== "function") return spec;
218
+ let loaded;
219
+ try {
220
+ loaded = await spec({ signal });
221
+ } catch (cause) {
222
+ if (signal.aborted && cause === signal.reason || cause instanceof Error && cause.name === "AbortError") throw cause;
223
+ const reason = cause instanceof Error ? cause.message : String(cause);
224
+ throw new Error(`api: cannot load the OpenAPI document (${reason})`, { cause });
225
+ }
226
+ const tag = Object.prototype.toString.call(loaded).slice(8, -1);
227
+ const prototype = tag === "Object" ? Object.getPrototypeOf(loaded) : void 0;
228
+ if (tag !== "Object" || prototype !== Object.prototype && prototype !== null) {
229
+ const kind = tag === "Object" ? "class instance" : tag.toLowerCase();
230
+ throw new Error(`api: cannot load the OpenAPI document (expected a plain object, got ${kind})`);
231
+ }
232
+ return loaded;
233
+ }
234
+ /** Each operation's method, path, and the `operationId` and `summary` the spec has. */
235
+ function listOperations(spec) {
236
+ return operations(spec).map(({ method, path, operation }) => {
237
+ const { operationId, summary } = operation;
238
+ return {
239
+ method: method.toUpperCase(),
240
+ path,
241
+ ...typeof operationId === "string" && operationId !== "" ? { operationId } : {},
242
+ ...typeof summary === "string" && summary !== "" ? { summary } : {}
243
+ };
244
+ });
245
+ }
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));
305
+ return typeof resolved === "object" && resolved !== null && !Array.isArray(resolved) ? {
306
+ ...resolved,
307
+ ...write(siblings)
308
+ } : resolved;
309
+ }
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;
321
+ }
322
+ /**
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}.
325
+ */
326
+ function specFor(spec, target) {
327
+ const paths = spec.paths ?? {};
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);
344
+ }
345
+ const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
346
+ /** `key=value`; `true`, `false`, `null`, and JSON numbers become JSON values, like `gh api -F`. */
347
+ function parseField(field) {
348
+ const at = field.indexOf("=");
349
+ if (at <= 0) throw new CrustError("PARSE", "Invalid -F value: expected key=value.", { flag: "field" });
350
+ const value = field.slice(at + 1);
351
+ const literals = {
352
+ true: true,
353
+ false: false,
354
+ null: null
355
+ };
356
+ if (Object.hasOwn(literals, value)) return [field.slice(0, at), literals[value]];
357
+ if (!JSON_NUMBER.test(value)) return [field.slice(0, at), value];
358
+ const number = Number(value);
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" });
360
+ return [field.slice(0, at), number];
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
+ }
378
+ function parseHeader(header) {
379
+ const at = header.indexOf(":");
380
+ if (at <= 0) throw new CrustError("PARSE", "Invalid -H value: expected \"Name: value\".", { flag: "header" });
381
+ const entry = [header.slice(0, at).trim(), header.slice(at + 1).trim()];
382
+ try {
383
+ new Headers().append(...entry);
384
+ } catch {
385
+ throw new CrustError("PARSE", "Invalid -H value: the header name or value has characters HTTP does not allow.", { flag: "header" });
386
+ }
387
+ return entry;
388
+ }
389
+ /** Inline text, `@path` (relative to the working directory), or `-` (all of stdin). */
390
+ async function readBody(value, signal) {
391
+ if (value !== "-" && !value.startsWith("@")) return value;
392
+ try {
393
+ return value === "-" ? await (await import("@crustjs/utils/process")).readStdin(signal) : process.getBuiltinModule("node:fs").readFileSync(value.slice(1), "utf8");
394
+ } catch (cause) {
395
+ if (signal.aborted && cause === signal.reason) throw cause;
396
+ const name = value === "-" ? "stdin" : `"${value.slice(1)}"`;
397
+ const reason = cause instanceof Error ? ` (${cause.message})` : "";
398
+ throw new CrustError("PARSE", `--body: cannot read ${name}${reason}`, { flag: "body" }).withCause(cause);
399
+ }
400
+ }
401
+ const pathArg = {
402
+ name: "path",
403
+ type: "string",
404
+ description: "Request path below the API base URL, e.g. /users"
405
+ };
406
+ const apiFlags = {
407
+ list: {
408
+ type: "boolean",
409
+ noNegate: true,
410
+ description: "List the API's operations"
411
+ },
412
+ spec: {
413
+ type: "string",
414
+ description: "Print the OpenAPI definition of an operationId or path"
415
+ },
416
+ method: {
417
+ type: "string",
418
+ short: "X",
419
+ description: "HTTP method; default GET, or POST with fields or a body"
420
+ },
421
+ header: {
422
+ type: "string",
423
+ short: "H",
424
+ multiple: true,
425
+ description: "Request header, 'Key: Value'"
426
+ },
427
+ field: {
428
+ type: "string",
429
+ short: "F",
430
+ multiple: true,
431
+ description: "key=value; true, false, null, and numbers are sent as JSON"
432
+ },
433
+ body: {
434
+ type: "string",
435
+ description: "Request body: inline, @file, or - for stdin"
436
+ },
437
+ paginate: {
438
+ type: "boolean",
439
+ noNegate: true,
440
+ description: "Fetch every page, printing each response body as one JSON line"
441
+ }
442
+ };
443
+ /**
444
+ * Provides the {@link apiClient} Context and contributes `api`: `api --list` and
445
+ * `api --spec <operationId|path>` read the spec (calling its loader, if any); `api <path>` sends
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.
450
+ */
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;
453
+ if (flags.list) {
454
+ const listed = listOperations(await loadSpec(spec, signal));
455
+ if (json) return listed;
456
+ for (const entry of listed) stdout(Object.values(entry).join(" "));
457
+ return;
458
+ }
459
+ if (flags.spec !== void 0) {
460
+ const found = specFor(await loadSpec(spec, signal), flags.spec);
461
+ if (json) return found;
462
+ stdout(JSON.stringify(found));
463
+ return;
464
+ }
465
+ if (args.path === void 0) throw new CrustError("VALIDATION", "Missing required argument \"<path>\" (or pass --list or --spec)");
466
+ if (flags.paginate && paginate === void 0) throw new Error("--paginate: this CLI does not configure pagination");
467
+ let url;
468
+ try {
469
+ url = baseOriginUrl(client.baseUrl, args.path);
470
+ } catch (cause) {
471
+ const message = cause instanceof Error ? cause.message : String(cause);
472
+ throw new CrustError("PARSE", message, { argument: "path" }).withCause(cause);
473
+ }
474
+ const fields = (flags.field ?? []).map(parseField);
475
+ const body = flags.body === void 0 ? void 0 : await readBody(flags.body, signal);
476
+ const method = parseMethod(flags.method ?? (fields.length > 0 || body !== void 0 ? "POST" : "GET"));
477
+ const headers = new Headers((flags.header ?? []).map(parseHeader));
478
+ let path = args.path;
479
+ let requestBody = body;
480
+ if (fields.length > 0) {
481
+ if (body !== void 0 || method === "GET" || method === "HEAD") {
482
+ const query = new URLSearchParams(fields.map(([key, value]) => [key, String(value)])).toString();
483
+ url.search = url.search === "" ? query : `${url.search.slice(1)}&${query}`;
484
+ path = url.href;
485
+ } else requestBody = JSON.stringify(Object.fromEntries(fields));
486
+ }
487
+ if (requestBody !== void 0 && !headers.has("content-type")) headers.set("content-type", "application/json");
488
+ const api = await ctx.api;
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
+ }
498
+ };
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;
553
+ }
554
+ if (flags.paginate && json) return bodies();
555
+ let result;
556
+ for await (const value of bodies()) result = value;
557
+ return result;
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
+ }
579
+ /** A page body as one NDJSON line: JSON compacted, any other text (even empty) as a JSON string. */
580
+ function compactJson(text) {
581
+ try {
582
+ JSON.parse(text);
583
+ } catch {
584
+ return JSON.stringify(text);
585
+ }
586
+ return text.replace(/"(?:[^"\\]|\\.)*"|\s+/g, (token) => token[0] === "\"" ? token : "");
587
+ }
588
+ export { ApiResponseError, apiClient, apiCommand };
package/package.json CHANGED
@@ -1,17 +1,67 @@
1
1
  {
2
- "name": "@crustjs/openapi",
3
- "version": "0.0.0",
4
- "description": "Placeholder for the first release of @crustjs/openapi. Do not install this version.",
5
- "license": "MIT",
6
- "author": "chenxin-yan",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/chenxin-yan/crust.git",
10
- "directory": "packages/openapi"
11
- },
12
- "homepage": "https://crustjs.com",
13
- "files": [],
14
- "publishConfig": {
15
- "access": "public"
16
- }
17
- }
2
+ "name": "@crustjs/openapi",
3
+ "version": "0.1.1",
4
+ "description": "Call a CLI's HTTP API and browse its OpenAPI spec from an api command",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "license": "MIT",
8
+ "author": "chenxin-yan",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/chenxin-yan/crust.git",
12
+ "directory": "packages/openapi"
13
+ },
14
+ "homepage": "https://crustjs.com",
15
+ "bugs": {
16
+ "url": "https://github.com/chenxin-yan/crust/issues"
17
+ },
18
+ "keywords": [
19
+ "cli",
20
+ "crust",
21
+ "openapi",
22
+ "api",
23
+ "http",
24
+ "bun"
25
+ ],
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@crustjs/utils": "^0.3.1"
40
+ },
41
+ "devDependencies": {
42
+ "@crustjs/config": "0.0.0",
43
+ "@crustjs/core": "0.6.1",
44
+ "@crustjs/extensions": "0.6.1",
45
+ "@crustjs/testing": "0.1.8",
46
+ "vite-plus": "1.4.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@crustjs/core": "^0.6.1",
50
+ "typescript": ">=5.9.0"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "typescript": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "engines": {
58
+ "bun": ">=1.4.0",
59
+ "node": ">=24"
60
+ },
61
+ "scripts": {
62
+ "build": "vp run build:task",
63
+ "dev": "vp pack --watch",
64
+ "check:types": "vp run check:types:task",
65
+ "test": "vp run test:task"
66
+ }
67
+ }