@crustjs/openapi 0.0.0 → 0.1.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 +13 -1
- package/dist/index.d.ts +128 -0
- package/dist/index.js +427 -0
- package/package.json +65 -16
package/README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
# @crustjs/openapi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Call a CLI's HTTP API and browse its OpenAPI spec from an api command
|
|
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.
|
|
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)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { CommandDefinition, CommandShape, ContextFactory, ContextInstance, ContextSetup, ExtensionFactory } from "@crustjs/core";
|
|
2
|
+
interface ApiClientOptions {
|
|
3
|
+
/** URL request paths resolve against, e.g. `https://api.example.com/v1`. */
|
|
4
|
+
readonly baseUrl: string;
|
|
5
|
+
/**
|
|
6
|
+
* Headers added to requests to the base URL's origin, e.g. an `Authorization` header, or any
|
|
7
|
+
* default header such as `User-Agent` or an API-version header. All of them are treated as
|
|
8
|
+
* credentials: they are never sent off the base URL's origin.
|
|
9
|
+
* Called at most once per invocation, on the first request that sends credentials: never
|
|
10
|
+
* for a command that only reads the Context, nor for requests with `auth: false`.
|
|
11
|
+
*/
|
|
12
|
+
readonly auth?: (input: ContextSetup) => Readonly<Record<string, string>> | undefined | Promise<Readonly<Record<string, string>> | undefined>;
|
|
13
|
+
}
|
|
14
|
+
/** The `api` Context value: `fetch()` bound to the API's base URL and auth. */
|
|
15
|
+
interface ApiClient {
|
|
16
|
+
readonly baseUrl: string;
|
|
17
|
+
/**
|
|
18
|
+
* `fetch()` for `path` below {@link ApiClient.baseUrl} (an absolute URL is used as is, but
|
|
19
|
+
* must be on the base URL's origin: otherwise it rejects with a `TypeError` before any request),
|
|
20
|
+
* with the `auth` headers the request does not set itself, and the invocation's signal
|
|
21
|
+
* unless `init.signal` is given. Redirects are followed here
|
|
22
|
+
* (up to 20 per request, http: and https: only); one to another origin than the base URL's
|
|
23
|
+
* continues without `Authorization`, `Cookie`, `Proxy-Authorization`, or any header named like an `auth` header.
|
|
24
|
+
* With `auth: false` (e.g. a public operation), neither the request nor its redirects
|
|
25
|
+
* call `auth` or carry its headers.
|
|
26
|
+
*/
|
|
27
|
+
fetch(path: string, init?: RequestInit & {
|
|
28
|
+
readonly auth?: false;
|
|
29
|
+
}): Promise<Response>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The shared API client Context. {@link apiCommand} provides it, so app commands
|
|
33
|
+
* declare `.use(apiClient)` and call `(await ctx.api).fetch("/users")`.
|
|
34
|
+
*/
|
|
35
|
+
export declare const apiClient: ContextFactory<"api", ApiClientOptions, ApiClient>;
|
|
36
|
+
/**
|
|
37
|
+
* An OpenAPI document; only `paths` and local `$ref` targets are read. Keys of `paths`
|
|
38
|
+
* not starting with `/`, such as `x-*` extensions, are skipped.
|
|
39
|
+
*/
|
|
40
|
+
interface OpenApiSpec {
|
|
41
|
+
readonly paths?: Readonly<Record<string, unknown>>;
|
|
42
|
+
readonly [key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
/** A request for {@link ApiClient.fetch}; without `init`, the previous request's is reused. */
|
|
45
|
+
interface ApiRequest {
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly init?: RequestInit;
|
|
48
|
+
}
|
|
49
|
+
interface ApiCommandOptions extends ApiClientOptions {
|
|
50
|
+
/**
|
|
51
|
+
* The OpenAPI document, e.g. an imported JSON file bundled with the CLI and read offline;
|
|
52
|
+
* or a loader for it, called with the invocation's signal only when `api --list` or
|
|
53
|
+
* `api --spec` runs, e.g. a lazy `import()` of the JSON file that keeps it out of startup.
|
|
54
|
+
*/
|
|
55
|
+
readonly spec: OpenApiSpec | ((input: {
|
|
56
|
+
signal: AbortSignal;
|
|
57
|
+
}) => OpenApiSpec | Promise<OpenApiSpec>);
|
|
58
|
+
/**
|
|
59
|
+
* The next page's request for `--paginate`, or `undefined` after the last page.
|
|
60
|
+
* Receives a copy of each successful response, so it may read the body. The next page
|
|
61
|
+
* must be http(s); off the base URL's origin it is requested without credential headers.
|
|
62
|
+
* Returning the previous request again (same URL and method, `init` reused) fails instead of looping.
|
|
63
|
+
*/
|
|
64
|
+
readonly paginate?: (response: Response) => ApiRequest | undefined | Promise<ApiRequest | undefined>;
|
|
65
|
+
}
|
|
66
|
+
/** A non-2xx response to `api <path>`; its body was already printed to stdout. */
|
|
67
|
+
export declare class ApiResponseError extends Error {
|
|
68
|
+
override name: string;
|
|
69
|
+
/** The HTTP status code. */
|
|
70
|
+
readonly status: number;
|
|
71
|
+
constructor(status: number, message: string);
|
|
72
|
+
}
|
|
73
|
+
declare const pathArg: {
|
|
74
|
+
readonly name: "path";
|
|
75
|
+
readonly type: "string";
|
|
76
|
+
readonly description: "Request path below the API base URL, e.g. /users";
|
|
77
|
+
};
|
|
78
|
+
declare const apiFlags: {
|
|
79
|
+
readonly list: {
|
|
80
|
+
readonly type: "boolean";
|
|
81
|
+
readonly noNegate: true;
|
|
82
|
+
readonly description: "List the API's operations";
|
|
83
|
+
};
|
|
84
|
+
readonly spec: {
|
|
85
|
+
readonly type: "string";
|
|
86
|
+
readonly description: "Print the OpenAPI definition of an operationId or path";
|
|
87
|
+
};
|
|
88
|
+
readonly method: {
|
|
89
|
+
readonly type: "string";
|
|
90
|
+
readonly short: "X";
|
|
91
|
+
readonly description: "HTTP method; default GET, or POST with fields or a body";
|
|
92
|
+
};
|
|
93
|
+
readonly header: {
|
|
94
|
+
readonly type: "string";
|
|
95
|
+
readonly short: "H";
|
|
96
|
+
readonly multiple: true;
|
|
97
|
+
readonly description: "Request header, 'Key: Value'";
|
|
98
|
+
};
|
|
99
|
+
readonly field: {
|
|
100
|
+
readonly type: "string";
|
|
101
|
+
readonly short: "F";
|
|
102
|
+
readonly multiple: true;
|
|
103
|
+
readonly description: "key=value; true, false, null, and numbers are sent as JSON";
|
|
104
|
+
};
|
|
105
|
+
readonly body: {
|
|
106
|
+
readonly type: "string";
|
|
107
|
+
readonly description: "Request body: inline, @file, or - for stdin";
|
|
108
|
+
};
|
|
109
|
+
readonly paginate: {
|
|
110
|
+
readonly type: "boolean";
|
|
111
|
+
readonly noNegate: true;
|
|
112
|
+
readonly description: "Fetch every page, printing each response body as one JSON line";
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
/** 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>, {
|
|
117
|
+
api: ApiClient;
|
|
118
|
+
}>;
|
|
119
|
+
/**
|
|
120
|
+
* Provides the {@link apiClient} Context and contributes `api`: `api --list` and
|
|
121
|
+
* `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.
|
|
124
|
+
*/
|
|
125
|
+
export declare const apiCommand: ExtensionFactory<[options: ApiCommandOptions], {
|
|
126
|
+
api: ApiClient;
|
|
127
|
+
}, readonly [ContextInstance<"api", ApiClient, {}, {}>], {}, readonly [ApiCommandDefinition], never, {}>;
|
|
128
|
+
export type { ApiClient, ApiClientOptions, ApiCommandOptions, ApiRequest, OpenApiSpec };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { CrustError, defineCommand, defineContext, defineExtension, defineExtensionId } from "@crustjs/core";
|
|
2
|
+
// src/client.ts
|
|
3
|
+
/** `path` joined below `baseUrl`, keeping the base's own path; absolute URLs pass through. */
|
|
4
|
+
function resolveUrl(baseUrl, path) {
|
|
5
|
+
return URL.canParse(path) ? new URL(path) : new URL(`${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* `path` resolved like {@link ApiClient.fetch} does for a first request, which must stay on
|
|
9
|
+
* `baseUrl`'s origin, so credentials set for the API never go to a host the caller named;
|
|
10
|
+
* only redirects and next pages may leave it. Throws a `TypeError` for any other origin.
|
|
11
|
+
*/
|
|
12
|
+
function baseOriginUrl(baseUrl, path) {
|
|
13
|
+
const url = resolveUrl(baseUrl, path);
|
|
14
|
+
const { origin } = new URL(baseUrl);
|
|
15
|
+
if (url.origin !== origin) throw new TypeError(`Cannot request ${url.href}: only URLs on ${origin} are allowed`);
|
|
16
|
+
return url;
|
|
17
|
+
}
|
|
18
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
|
|
19
|
+
301,
|
|
20
|
+
302,
|
|
21
|
+
303,
|
|
22
|
+
307,
|
|
23
|
+
308
|
|
24
|
+
]);
|
|
25
|
+
const MAX_REDIRECTS = 20;
|
|
26
|
+
const BODY_HEADERS = [
|
|
27
|
+
"content-encoding",
|
|
28
|
+
"content-language",
|
|
29
|
+
"content-location",
|
|
30
|
+
"content-type"
|
|
31
|
+
];
|
|
32
|
+
/** `response` reporting `redirected`, as `fetch()` does after following a redirect, on its clones too. */
|
|
33
|
+
function markRedirected(response) {
|
|
34
|
+
const clone = response.clone.bind(response);
|
|
35
|
+
return Object.defineProperties(response, {
|
|
36
|
+
redirected: { value: true },
|
|
37
|
+
clone: { value: () => markRedirected(clone()) }
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const nextPageFetchers = /* @__PURE__ */ new WeakMap();
|
|
41
|
+
/**
|
|
42
|
+
* `client.fetch()` for a next page a response named: like a redirect target, it must be
|
|
43
|
+
* http(s) and gets no credential headers off the base URL's origin, even ones the request set.
|
|
44
|
+
*/
|
|
45
|
+
function fetchNextPage(client, path, init) {
|
|
46
|
+
const fetchNext = nextPageFetchers.get(client);
|
|
47
|
+
if (fetchNext === void 0) throw new TypeError("fetchNextPage: not an apiClient Context value");
|
|
48
|
+
return fetchNext(path, init);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The shared API client Context. {@link apiCommand} provides it, so app commands
|
|
52
|
+
* declare `.use(apiClient)` and call `(await ctx.api).fetch("/users")`.
|
|
53
|
+
*/
|
|
54
|
+
const apiClient = /* @__PURE__ */ defineContext("api").setup(async (input, options) => {
|
|
55
|
+
const origin = new URL(options.baseUrl).origin;
|
|
56
|
+
const credentialHeaders = [
|
|
57
|
+
"authorization",
|
|
58
|
+
"cookie",
|
|
59
|
+
"proxy-authorization"
|
|
60
|
+
];
|
|
61
|
+
let authHeaders;
|
|
62
|
+
async function loadAuthHeaders() {
|
|
63
|
+
const headers = new Headers(await options.auth?.(input));
|
|
64
|
+
credentialHeaders.push(...headers.keys());
|
|
65
|
+
return headers;
|
|
66
|
+
}
|
|
67
|
+
const firstHop = (url, { auth, ...init } = {}) => ({
|
|
68
|
+
url,
|
|
69
|
+
init: {
|
|
70
|
+
...init,
|
|
71
|
+
headers: new Headers(init.headers),
|
|
72
|
+
signal: init.signal ?? input.signal
|
|
73
|
+
},
|
|
74
|
+
auth: auth !== false,
|
|
75
|
+
redirects: 0
|
|
76
|
+
});
|
|
77
|
+
/**
|
|
78
|
+
* The request for `target` after `from`, for a redirect with `status` or, without one,
|
|
79
|
+
* a next page. Every move to a URL a response named goes through here, so the protocol,
|
|
80
|
+
* redirect limit, and credential rules cannot be bypassed.
|
|
81
|
+
*/
|
|
82
|
+
function step(from, target, status) {
|
|
83
|
+
from.init.signal?.throwIfAborted();
|
|
84
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") throw new TypeError(`Cannot request ${target.href}: only http: and https: URLs`);
|
|
85
|
+
const headers = new Headers(from.init.headers);
|
|
86
|
+
let { method = "GET", body } = from.init;
|
|
87
|
+
if (status !== void 0) {
|
|
88
|
+
if (from.redirects === MAX_REDIRECTS) throw new TypeError(`fetch ${from.url.href}: too many redirects`);
|
|
89
|
+
const upper = method.toUpperCase();
|
|
90
|
+
if (status === 303 ? upper !== "GET" && upper !== "HEAD" : status <= 302 && upper === "POST") {
|
|
91
|
+
method = "GET";
|
|
92
|
+
body = null;
|
|
93
|
+
for (const name of BODY_HEADERS) headers.delete(name);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (target.origin !== origin) for (const name of credentialHeaders) headers.delete(name);
|
|
97
|
+
const redirects = from.redirects + (status === void 0 ? 0 : 1);
|
|
98
|
+
return {
|
|
99
|
+
...from,
|
|
100
|
+
url: target,
|
|
101
|
+
init: {
|
|
102
|
+
...from.init,
|
|
103
|
+
method,
|
|
104
|
+
body,
|
|
105
|
+
headers
|
|
106
|
+
},
|
|
107
|
+
redirects
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
async function send(hop) {
|
|
111
|
+
const redirect = hop.init.redirect ?? "follow";
|
|
112
|
+
for (;;) {
|
|
113
|
+
const headers = new Headers(hop.init.headers);
|
|
114
|
+
if (hop.auth && hop.url.origin === origin) {
|
|
115
|
+
for (const [name, value] of await (authHeaders ??= loadAuthHeaders())) if (!headers.has(name)) headers.set(name, value);
|
|
116
|
+
}
|
|
117
|
+
const response = await fetch(hop.url, {
|
|
118
|
+
...hop.init,
|
|
119
|
+
headers,
|
|
120
|
+
redirect: redirect === "follow" ? "manual" : redirect
|
|
121
|
+
});
|
|
122
|
+
const location = response.headers.get("location");
|
|
123
|
+
if (redirect !== "follow" || !REDIRECT_STATUSES.has(response.status) || location === null) return hop.redirects > 0 ? markRedirected(response) : response;
|
|
124
|
+
await response.body?.cancel();
|
|
125
|
+
hop = step(hop, new URL(location, hop.url), response.status);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const client = {
|
|
129
|
+
baseUrl: options.baseUrl,
|
|
130
|
+
fetch: async (path, init) => send(firstHop(baseOriginUrl(options.baseUrl, path), init))
|
|
131
|
+
};
|
|
132
|
+
nextPageFetchers.set(client, (path, init) => {
|
|
133
|
+
const target = resolveUrl(options.baseUrl, path);
|
|
134
|
+
return send(step(firstHop(target, init), target));
|
|
135
|
+
});
|
|
136
|
+
return client;
|
|
137
|
+
});
|
|
138
|
+
// src/api-command.ts
|
|
139
|
+
const API = /* @__PURE__ */ defineExtensionId("crust:openapi");
|
|
140
|
+
/** A non-2xx response to `api <path>`; its body was already printed to stdout. */
|
|
141
|
+
var ApiResponseError = class extends Error {
|
|
142
|
+
name = "ApiResponseError";
|
|
143
|
+
/** The HTTP status code. */
|
|
144
|
+
status;
|
|
145
|
+
constructor(status, message) {
|
|
146
|
+
super(message);
|
|
147
|
+
this.status = status;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const METHODS = [
|
|
151
|
+
"get",
|
|
152
|
+
"put",
|
|
153
|
+
"post",
|
|
154
|
+
"delete",
|
|
155
|
+
"options",
|
|
156
|
+
"head",
|
|
157
|
+
"patch",
|
|
158
|
+
"trace"
|
|
159
|
+
];
|
|
160
|
+
/**
|
|
161
|
+
* The value a local `$ref`'s JSON Pointer fragment (`#/...`, URI-encoded or not) points to
|
|
162
|
+
* in `spec`, or `undefined`, also for other fragments such as `#anchor`.
|
|
163
|
+
*/
|
|
164
|
+
function refTarget(spec, ref) {
|
|
165
|
+
let pointer;
|
|
166
|
+
try {
|
|
167
|
+
pointer = decodeURIComponent(ref.slice(1));
|
|
168
|
+
} catch {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (pointer !== "" && !pointer.startsWith("/")) return void 0;
|
|
172
|
+
let target = spec;
|
|
173
|
+
for (const token of pointer.split("/").slice(1)) {
|
|
174
|
+
const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
175
|
+
target = typeof target === "object" && target !== null && Object.hasOwn(target, key) ? target[key] : void 0;
|
|
176
|
+
}
|
|
177
|
+
return target;
|
|
178
|
+
}
|
|
179
|
+
/** A Path Item with its own local `$ref`s (e.g. to `components.pathItems`) followed; sibling keys win. */
|
|
180
|
+
function pathItem(spec, item) {
|
|
181
|
+
let current = item;
|
|
182
|
+
const seen = /* @__PURE__ */ new Set();
|
|
183
|
+
for (let ref = current.$ref; typeof ref === "string" && ref.startsWith("#") && !seen.has(ref); ref = current.$ref) {
|
|
184
|
+
seen.add(ref);
|
|
185
|
+
const target = refTarget(spec, ref);
|
|
186
|
+
if (typeof target !== "object" || target === null || Array.isArray(target)) break;
|
|
187
|
+
const { $ref: _ref, ...siblings } = current;
|
|
188
|
+
current = {
|
|
189
|
+
...target,
|
|
190
|
+
...siblings
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return current;
|
|
194
|
+
}
|
|
195
|
+
function operations(spec) {
|
|
196
|
+
return Object.entries(spec.paths ?? {}).flatMap(([path, item]) => {
|
|
197
|
+
if (!path.startsWith("/") || typeof item !== "object" || item === null) return [];
|
|
198
|
+
const resolved = pathItem(spec, item);
|
|
199
|
+
return METHODS.flatMap((method) => {
|
|
200
|
+
const operation = resolved[method];
|
|
201
|
+
return typeof operation === "object" && operation !== null ? [{
|
|
202
|
+
method,
|
|
203
|
+
path,
|
|
204
|
+
operation
|
|
205
|
+
}] : [];
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** `spec`, or what its loader returns; a failure other than cancellation becomes a plain `Error`. */
|
|
210
|
+
async function loadSpec(spec, signal) {
|
|
211
|
+
if (typeof spec !== "function") return spec;
|
|
212
|
+
let loaded;
|
|
213
|
+
try {
|
|
214
|
+
loaded = await spec({ signal });
|
|
215
|
+
} catch (cause) {
|
|
216
|
+
if (signal.aborted && cause === signal.reason || cause instanceof Error && cause.name === "AbortError") throw cause;
|
|
217
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
218
|
+
throw new Error(`api: cannot load the OpenAPI document (${reason})`, { cause });
|
|
219
|
+
}
|
|
220
|
+
const tag = Object.prototype.toString.call(loaded).slice(8, -1);
|
|
221
|
+
const prototype = tag === "Object" ? Object.getPrototypeOf(loaded) : void 0;
|
|
222
|
+
if (tag !== "Object" || prototype !== Object.prototype && prototype !== null) {
|
|
223
|
+
const kind = tag === "Object" ? "class instance" : tag.toLowerCase();
|
|
224
|
+
throw new Error(`api: cannot load the OpenAPI document (expected a plain object, got ${kind})`);
|
|
225
|
+
}
|
|
226
|
+
return loaded;
|
|
227
|
+
}
|
|
228
|
+
/** One line per operation: `METHOD path operationId summary`, omitting what the spec lacks. */
|
|
229
|
+
function listOperations(spec) {
|
|
230
|
+
return operations(spec).map(({ method, path, operation }) => {
|
|
231
|
+
const { operationId, summary } = operation;
|
|
232
|
+
return [
|
|
233
|
+
method.toUpperCase(),
|
|
234
|
+
path,
|
|
235
|
+
operationId,
|
|
236
|
+
summary
|
|
237
|
+
].filter((part) => typeof part === "string" && part !== "").join(" ");
|
|
238
|
+
});
|
|
239
|
+
}
|
|
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]);
|
|
249
|
+
return typeof resolved === "object" && resolved !== null && !Array.isArray(resolved) ? {
|
|
250
|
+
...resolved,
|
|
251
|
+
...resolveRefs(siblings, spec, chain)
|
|
252
|
+
} : resolved;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveRefs(item, spec, chain)]));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The path item for a spec path, or the operation with that `operationId`, with
|
|
259
|
+
* local `$ref`s resolved.
|
|
260
|
+
*/
|
|
261
|
+
function specFor(spec, target) {
|
|
262
|
+
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, []);
|
|
266
|
+
}
|
|
267
|
+
const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
268
|
+
/** `key=value`; `true`, `false`, `null`, and JSON numbers become JSON values, like `gh api -F`. */
|
|
269
|
+
function parseField(field) {
|
|
270
|
+
const at = field.indexOf("=");
|
|
271
|
+
if (at <= 0) throw new CrustError("PARSE", "Invalid -F value: expected key=value.", { flag: "field" });
|
|
272
|
+
const value = field.slice(at + 1);
|
|
273
|
+
const literals = {
|
|
274
|
+
true: true,
|
|
275
|
+
false: false,
|
|
276
|
+
null: null
|
|
277
|
+
};
|
|
278
|
+
if (Object.hasOwn(literals, value)) return [field.slice(0, at), literals[value]];
|
|
279
|
+
if (!JSON_NUMBER.test(value)) return [field.slice(0, at), value];
|
|
280
|
+
const number = Number(value);
|
|
281
|
+
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
|
+
return [field.slice(0, at), number];
|
|
283
|
+
}
|
|
284
|
+
function parseHeader(header) {
|
|
285
|
+
const at = header.indexOf(":");
|
|
286
|
+
if (at <= 0) throw new CrustError("PARSE", "Invalid -H value: expected \"Name: value\".", { flag: "header" });
|
|
287
|
+
const entry = [header.slice(0, at).trim(), header.slice(at + 1).trim()];
|
|
288
|
+
try {
|
|
289
|
+
new Headers().append(...entry);
|
|
290
|
+
} catch {
|
|
291
|
+
throw new CrustError("PARSE", "Invalid -H value: the header name or value has characters HTTP does not allow.", { flag: "header" });
|
|
292
|
+
}
|
|
293
|
+
return entry;
|
|
294
|
+
}
|
|
295
|
+
/** Inline text, `@path` (relative to the working directory), or `-` (all of stdin). */
|
|
296
|
+
async function readBody(value, signal) {
|
|
297
|
+
if (value !== "-" && !value.startsWith("@")) return value;
|
|
298
|
+
try {
|
|
299
|
+
return value === "-" ? await (await import("@crustjs/utils/process")).readStdin(signal) : process.getBuiltinModule("node:fs").readFileSync(value.slice(1), "utf8");
|
|
300
|
+
} catch (cause) {
|
|
301
|
+
if (signal.aborted && cause === signal.reason) throw cause;
|
|
302
|
+
const name = value === "-" ? "stdin" : `"${value.slice(1)}"`;
|
|
303
|
+
const reason = cause instanceof Error ? ` (${cause.message})` : "";
|
|
304
|
+
throw new CrustError("PARSE", `--body: cannot read ${name}${reason}`, { flag: "body" }).withCause(cause);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const pathArg = {
|
|
308
|
+
name: "path",
|
|
309
|
+
type: "string",
|
|
310
|
+
description: "Request path below the API base URL, e.g. /users"
|
|
311
|
+
};
|
|
312
|
+
const apiFlags = {
|
|
313
|
+
list: {
|
|
314
|
+
type: "boolean",
|
|
315
|
+
noNegate: true,
|
|
316
|
+
description: "List the API's operations"
|
|
317
|
+
},
|
|
318
|
+
spec: {
|
|
319
|
+
type: "string",
|
|
320
|
+
description: "Print the OpenAPI definition of an operationId or path"
|
|
321
|
+
},
|
|
322
|
+
method: {
|
|
323
|
+
type: "string",
|
|
324
|
+
short: "X",
|
|
325
|
+
description: "HTTP method; default GET, or POST with fields or a body"
|
|
326
|
+
},
|
|
327
|
+
header: {
|
|
328
|
+
type: "string",
|
|
329
|
+
short: "H",
|
|
330
|
+
multiple: true,
|
|
331
|
+
description: "Request header, 'Key: Value'"
|
|
332
|
+
},
|
|
333
|
+
field: {
|
|
334
|
+
type: "string",
|
|
335
|
+
short: "F",
|
|
336
|
+
multiple: true,
|
|
337
|
+
description: "key=value; true, false, null, and numbers are sent as JSON"
|
|
338
|
+
},
|
|
339
|
+
body: {
|
|
340
|
+
type: "string",
|
|
341
|
+
description: "Request body: inline, @file, or - for stdin"
|
|
342
|
+
},
|
|
343
|
+
paginate: {
|
|
344
|
+
type: "boolean",
|
|
345
|
+
noNegate: true,
|
|
346
|
+
description: "Fetch every page, printing each response body as one JSON line"
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Provides the {@link apiClient} Context and contributes `api`: `api --list` and
|
|
351
|
+
* `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.
|
|
354
|
+
*/
|
|
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 }) => {
|
|
356
|
+
if (flags.list) {
|
|
357
|
+
for (const line of listOperations(await loadSpec(spec, signal))) stdout(line);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (flags.spec !== void 0) {
|
|
361
|
+
stdout(JSON.stringify(specFor(await loadSpec(spec, signal), flags.spec)));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (args.path === void 0) throw new CrustError("VALIDATION", "Missing required argument \"<path>\" (or pass --list or --spec)");
|
|
365
|
+
if (flags.paginate && paginate === void 0) throw new Error("--paginate: this CLI does not configure pagination");
|
|
366
|
+
let url;
|
|
367
|
+
try {
|
|
368
|
+
url = baseOriginUrl(client.baseUrl, args.path);
|
|
369
|
+
} catch (cause) {
|
|
370
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
371
|
+
throw new CrustError("PARSE", message, { argument: "path" }).withCause(cause);
|
|
372
|
+
}
|
|
373
|
+
const fields = (flags.field ?? []).map(parseField);
|
|
374
|
+
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();
|
|
376
|
+
const headers = new Headers((flags.header ?? []).map(parseHeader));
|
|
377
|
+
let path = args.path;
|
|
378
|
+
let requestBody = body;
|
|
379
|
+
if (fields.length > 0) {
|
|
380
|
+
if (body !== void 0 || method === "GET" || method === "HEAD") {
|
|
381
|
+
const query = new URLSearchParams(fields.map(([key, value]) => [key, String(value)])).toString();
|
|
382
|
+
url.search = url.search === "" ? query : `${url.search.slice(1)}&${query}`;
|
|
383
|
+
path = url.href;
|
|
384
|
+
} else requestBody = JSON.stringify(Object.fromEntries(fields));
|
|
385
|
+
}
|
|
386
|
+
if (requestBody !== void 0 && !headers.has("content-type")) headers.set("content-type", "application/json");
|
|
387
|
+
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
|
|
413
|
+
};
|
|
414
|
+
sentMethod = nextMethod;
|
|
415
|
+
response = await fetchNextPage(api, request.path, request.init);
|
|
416
|
+
}
|
|
417
|
+
}))));
|
|
418
|
+
/** A page body as one NDJSON line: JSON compacted, any other text (even empty) as a JSON string. */
|
|
419
|
+
function compactJson(text) {
|
|
420
|
+
try {
|
|
421
|
+
JSON.parse(text);
|
|
422
|
+
} catch {
|
|
423
|
+
return JSON.stringify(text);
|
|
424
|
+
}
|
|
425
|
+
return text.replace(/"(?:[^"\\]|\\.)*"|\s+/g, (token) => token[0] === "\"" ? token : "");
|
|
426
|
+
}
|
|
427
|
+
export { ApiResponseError, apiClient, apiCommand };
|
package/package.json
CHANGED
|
@@ -1,17 +1,66 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
2
|
+
"name": "@crustjs/openapi",
|
|
3
|
+
"version": "0.1.0",
|
|
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.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@crustjs/config": "0.0.0",
|
|
43
|
+
"@crustjs/core": "0.6.0",
|
|
44
|
+
"@crustjs/testing": "0.1.7",
|
|
45
|
+
"vite-plus": "1.4.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@crustjs/core": "^0.6.0",
|
|
49
|
+
"typescript": "^7.0.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"typescript": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"bun": ">=1.4.0",
|
|
58
|
+
"node": ">=24"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "vp run build:task",
|
|
62
|
+
"dev": "vp pack --watch",
|
|
63
|
+
"check:types": "vp run check:types:task",
|
|
64
|
+
"test": "vp run test:task"
|
|
65
|
+
}
|
|
66
|
+
}
|