specli 0.0.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/CLAUDE.md +111 -0
- package/PLAN.md +274 -0
- package/README.md +474 -0
- package/biome.jsonc +1 -0
- package/bun.lock +98 -0
- package/cli.ts +74 -0
- package/fixtures/openapi-array-items.json +22 -0
- package/fixtures/openapi-auth.json +34 -0
- package/fixtures/openapi-body.json +41 -0
- package/fixtures/openapi-collision.json +21 -0
- package/fixtures/openapi-oauth.json +54 -0
- package/fixtures/openapi-servers.json +35 -0
- package/fixtures/openapi.json +87 -0
- package/index.ts +1 -0
- package/package.json +27 -0
- package/scripts/smoke-specs.ts +64 -0
- package/src/cli/auth-requirements.test.ts +27 -0
- package/src/cli/auth-requirements.ts +91 -0
- package/src/cli/auth-schemes.test.ts +66 -0
- package/src/cli/auth-schemes.ts +187 -0
- package/src/cli/capabilities.test.ts +94 -0
- package/src/cli/capabilities.ts +88 -0
- package/src/cli/command-id.test.ts +32 -0
- package/src/cli/command-id.ts +16 -0
- package/src/cli/command-index.ts +19 -0
- package/src/cli/command-model.test.ts +44 -0
- package/src/cli/command-model.ts +128 -0
- package/src/cli/compile.ts +119 -0
- package/src/cli/crypto.ts +9 -0
- package/src/cli/derive-name.ts +101 -0
- package/src/cli/exec.ts +72 -0
- package/src/cli/main.ts +336 -0
- package/src/cli/naming.test.ts +86 -0
- package/src/cli/naming.ts +224 -0
- package/src/cli/operations.test.ts +57 -0
- package/src/cli/operations.ts +152 -0
- package/src/cli/params.test.ts +70 -0
- package/src/cli/params.ts +71 -0
- package/src/cli/pluralize.ts +41 -0
- package/src/cli/positional.test.ts +65 -0
- package/src/cli/positional.ts +75 -0
- package/src/cli/request-body.test.ts +35 -0
- package/src/cli/request-body.ts +94 -0
- package/src/cli/runtime/argv.ts +14 -0
- package/src/cli/runtime/auth/resolve.ts +31 -0
- package/src/cli/runtime/body.ts +24 -0
- package/src/cli/runtime/collect.ts +6 -0
- package/src/cli/runtime/context.ts +62 -0
- package/src/cli/runtime/execute.ts +138 -0
- package/src/cli/runtime/generated.ts +200 -0
- package/src/cli/runtime/headers.ts +37 -0
- package/src/cli/runtime/index.ts +3 -0
- package/src/cli/runtime/profile/secrets.ts +42 -0
- package/src/cli/runtime/profile/store.ts +98 -0
- package/src/cli/runtime/request.test.ts +153 -0
- package/src/cli/runtime/request.ts +487 -0
- package/src/cli/runtime/server-url.ts +44 -0
- package/src/cli/runtime/template.ts +26 -0
- package/src/cli/runtime/validate/ajv.ts +13 -0
- package/src/cli/runtime/validate/coerce.ts +71 -0
- package/src/cli/runtime/validate/error.ts +29 -0
- package/src/cli/runtime/validate/index.ts +4 -0
- package/src/cli/runtime/validate/schema.ts +54 -0
- package/src/cli/schema-shape.ts +36 -0
- package/src/cli/schema.ts +76 -0
- package/src/cli/server.test.ts +35 -0
- package/src/cli/server.ts +88 -0
- package/src/cli/spec-id.ts +12 -0
- package/src/cli/spec-loader.ts +58 -0
- package/src/cli/stable-json.ts +35 -0
- package/src/cli/strings.ts +21 -0
- package/src/cli/types.ts +59 -0
- package/src/compiled.ts +23 -0
- package/src/macros/env.ts +25 -0
- package/src/macros/spec.ts +17 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import type { AuthScheme } from "../auth-schemes.ts";
|
|
2
|
+
import type { CommandAction } from "../command-model.ts";
|
|
3
|
+
|
|
4
|
+
import { resolveAuthScheme } from "./auth/resolve.ts";
|
|
5
|
+
import { loadBody, parseBodyAsJsonOrYaml } from "./body.ts";
|
|
6
|
+
import { parseHeaderInput } from "./headers.ts";
|
|
7
|
+
import { getToken } from "./profile/secrets.ts";
|
|
8
|
+
import { getProfile, readProfiles } from "./profile/store.ts";
|
|
9
|
+
import { resolveServerUrl } from "./server-url.ts";
|
|
10
|
+
import { applyTemplate } from "./template.ts";
|
|
11
|
+
import {
|
|
12
|
+
createAjv,
|
|
13
|
+
deriveValidationSchemas,
|
|
14
|
+
formatAjvErrors,
|
|
15
|
+
} from "./validate/index.ts";
|
|
16
|
+
|
|
17
|
+
export type RuntimeGlobals = {
|
|
18
|
+
spec?: string;
|
|
19
|
+
server?: string;
|
|
20
|
+
serverVar?: string[];
|
|
21
|
+
|
|
22
|
+
// Common runtime flags (may conflict with operation flags).
|
|
23
|
+
header?: string[];
|
|
24
|
+
accept?: string;
|
|
25
|
+
contentType?: string;
|
|
26
|
+
data?: string;
|
|
27
|
+
file?: string;
|
|
28
|
+
timeout?: string;
|
|
29
|
+
dryRun?: boolean;
|
|
30
|
+
curl?: boolean;
|
|
31
|
+
status?: boolean;
|
|
32
|
+
headers?: boolean;
|
|
33
|
+
|
|
34
|
+
// Namespaced variants for the runtime flags above.
|
|
35
|
+
ocHeader?: string[];
|
|
36
|
+
ocAccept?: string;
|
|
37
|
+
ocContentType?: string;
|
|
38
|
+
ocData?: string;
|
|
39
|
+
ocFile?: string;
|
|
40
|
+
ocTimeout?: string;
|
|
41
|
+
ocDryRun?: boolean;
|
|
42
|
+
ocCurl?: boolean;
|
|
43
|
+
ocStatus?: boolean;
|
|
44
|
+
ocHeaders?: boolean;
|
|
45
|
+
|
|
46
|
+
json?: boolean;
|
|
47
|
+
|
|
48
|
+
auth?: string;
|
|
49
|
+
bearerToken?: string;
|
|
50
|
+
oauthToken?: string;
|
|
51
|
+
username?: string;
|
|
52
|
+
password?: string;
|
|
53
|
+
apiKey?: string;
|
|
54
|
+
|
|
55
|
+
profile?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function parseKeyValuePairs(
|
|
59
|
+
pairs: string[] | undefined,
|
|
60
|
+
): Record<string, string> {
|
|
61
|
+
const out: Record<string, string> = {};
|
|
62
|
+
for (const pair of pairs ?? []) {
|
|
63
|
+
const idx = pair.indexOf("=");
|
|
64
|
+
if (idx === -1)
|
|
65
|
+
throw new Error(`Invalid pair '${pair}', expected name=value`);
|
|
66
|
+
const name = pair.slice(0, idx).trim();
|
|
67
|
+
const value = pair.slice(idx + 1).trim();
|
|
68
|
+
if (!name) throw new Error(`Invalid pair '${pair}', missing name`);
|
|
69
|
+
out[name] = value;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _parseTimeoutMs(value: string | undefined): number | undefined {
|
|
75
|
+
if (!value) return undefined;
|
|
76
|
+
const n = Number(value);
|
|
77
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
78
|
+
throw new Error("--timeout must be a positive number");
|
|
79
|
+
return n;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pickAuthSchemeKey(
|
|
83
|
+
action: CommandAction,
|
|
84
|
+
globals: RuntimeGlobals,
|
|
85
|
+
): string | undefined {
|
|
86
|
+
if (globals.auth) return globals.auth;
|
|
87
|
+
|
|
88
|
+
// If operation declares a single requirement set with a single scheme, default to it.
|
|
89
|
+
const req = action.auth.alternatives;
|
|
90
|
+
if (req.length === 1 && req[0]?.length === 1) {
|
|
91
|
+
return req[0][0]?.key;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function applyAuth(
|
|
98
|
+
headers: Headers,
|
|
99
|
+
url: URL,
|
|
100
|
+
action: CommandAction,
|
|
101
|
+
globals: RuntimeGlobals,
|
|
102
|
+
authSchemes: AuthScheme[],
|
|
103
|
+
): { headers: Headers; url: URL } {
|
|
104
|
+
const schemeKey = pickAuthSchemeKey(action, globals);
|
|
105
|
+
if (!schemeKey) return { headers, url };
|
|
106
|
+
|
|
107
|
+
const scheme = authSchemes.find((s) => s.key === schemeKey);
|
|
108
|
+
if (!scheme) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Unknown auth scheme '${schemeKey}'. Available: ${authSchemes
|
|
111
|
+
.map((s) => s.key)
|
|
112
|
+
.join(", ")}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (
|
|
117
|
+
scheme.kind === "http-bearer" ||
|
|
118
|
+
scheme.kind === "oauth2" ||
|
|
119
|
+
scheme.kind === "openIdConnect"
|
|
120
|
+
) {
|
|
121
|
+
const token = globals.bearerToken ?? globals.oauthToken;
|
|
122
|
+
if (!token)
|
|
123
|
+
throw new Error("Missing token. Provide --bearer-token <token>.");
|
|
124
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
125
|
+
return { headers, url };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (scheme.kind === "http-basic") {
|
|
129
|
+
if (!globals.username) throw new Error("Missing --username for basic auth");
|
|
130
|
+
if (!globals.password) throw new Error("Missing --password for basic auth");
|
|
131
|
+
const raw = `${globals.username}:${globals.password}`;
|
|
132
|
+
const encoded = Buffer.from(raw, "utf8").toString("base64");
|
|
133
|
+
headers.set("Authorization", `Basic ${encoded}`);
|
|
134
|
+
return { headers, url };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (scheme.kind === "api-key") {
|
|
138
|
+
if (!scheme.name)
|
|
139
|
+
throw new Error(`apiKey scheme '${scheme.key}' missing name`);
|
|
140
|
+
if (!scheme.in)
|
|
141
|
+
throw new Error(`apiKey scheme '${scheme.key}' missing location`);
|
|
142
|
+
if (!globals.apiKey) throw new Error("Missing --api-key for apiKey auth");
|
|
143
|
+
|
|
144
|
+
if (scheme.in === "header") {
|
|
145
|
+
headers.set(scheme.name, globals.apiKey);
|
|
146
|
+
}
|
|
147
|
+
if (scheme.in === "query") {
|
|
148
|
+
url.searchParams.set(scheme.name, globals.apiKey);
|
|
149
|
+
}
|
|
150
|
+
if (scheme.in === "cookie") {
|
|
151
|
+
const existing = headers.get("Cookie");
|
|
152
|
+
const part = `${scheme.name}=${globals.apiKey}`;
|
|
153
|
+
headers.set("Cookie", existing ? `${existing}; ${part}` : part);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { headers, url };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { headers, url };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type BuildRequestInput = {
|
|
163
|
+
specId: string;
|
|
164
|
+
action: CommandAction;
|
|
165
|
+
positionalValues: string[];
|
|
166
|
+
flagValues: Record<string, unknown>;
|
|
167
|
+
globals: RuntimeGlobals;
|
|
168
|
+
servers: import("../server.ts").ServerInfo[];
|
|
169
|
+
authSchemes: AuthScheme[];
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export async function buildRequest(
|
|
173
|
+
input: BuildRequestInput,
|
|
174
|
+
): Promise<{ request: Request; curl: string }> {
|
|
175
|
+
const profilesFile = await readProfiles();
|
|
176
|
+
const profile = getProfile(profilesFile, input.globals.profile);
|
|
177
|
+
|
|
178
|
+
const serverVars = parseKeyValuePairs(input.globals.serverVar);
|
|
179
|
+
const serverUrl = resolveServerUrl({
|
|
180
|
+
serverOverride: input.globals.server ?? profile?.server,
|
|
181
|
+
servers: input.servers,
|
|
182
|
+
serverVars,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Path params: action.positionals order matches templated params order.
|
|
186
|
+
const pathVars: Record<string, string> = {};
|
|
187
|
+
for (let i = 0; i < input.action.positionals.length; i++) {
|
|
188
|
+
const pos = input.action.positionals[i];
|
|
189
|
+
const raw = input.action.pathArgs[i];
|
|
190
|
+
const value = input.positionalValues[i];
|
|
191
|
+
if (typeof raw === "string" && typeof value === "string") {
|
|
192
|
+
pathVars[raw] = value;
|
|
193
|
+
}
|
|
194
|
+
// Use cli name too as fallback
|
|
195
|
+
if (pos?.name && typeof value === "string") {
|
|
196
|
+
pathVars[pos.name] = value;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const path = applyTemplate(input.action.path, pathVars, { encode: true });
|
|
201
|
+
|
|
202
|
+
const url = new URL(
|
|
203
|
+
path,
|
|
204
|
+
serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const headers = new Headers();
|
|
208
|
+
const accept = input.globals.accept ?? input.globals.ocAccept;
|
|
209
|
+
if (accept) headers.set("Accept", accept);
|
|
210
|
+
|
|
211
|
+
// Collect declared params for validation.
|
|
212
|
+
const queryValues: Record<string, unknown> = {};
|
|
213
|
+
const headerValues: Record<string, unknown> = {};
|
|
214
|
+
const cookieValues: Record<string, unknown> = {};
|
|
215
|
+
|
|
216
|
+
for (const p of input.action.params) {
|
|
217
|
+
if (p.kind !== "flag") continue;
|
|
218
|
+
const optValue = input.flagValues[optionKeyFromFlag(p.flag)];
|
|
219
|
+
if (typeof optValue === "undefined") continue;
|
|
220
|
+
|
|
221
|
+
if (p.in === "query") {
|
|
222
|
+
queryValues[p.name] = optValue;
|
|
223
|
+
}
|
|
224
|
+
if (p.in === "header") {
|
|
225
|
+
headerValues[p.name] = optValue;
|
|
226
|
+
}
|
|
227
|
+
if (p.in === "cookie") {
|
|
228
|
+
cookieValues[p.name] = optValue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Validate params (query/header/cookie) using Ajv.
|
|
233
|
+
const schemas = deriveValidationSchemas(input.action);
|
|
234
|
+
const ajv = createAjv();
|
|
235
|
+
|
|
236
|
+
if (schemas.querySchema) {
|
|
237
|
+
const validate = ajv.compile(schemas.querySchema);
|
|
238
|
+
if (!validate(queryValues)) {
|
|
239
|
+
throw new Error(formatAjvErrors(validate.errors));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (schemas.headerSchema) {
|
|
243
|
+
const validate = ajv.compile(schemas.headerSchema);
|
|
244
|
+
if (!validate(headerValues)) {
|
|
245
|
+
throw new Error(formatAjvErrors(validate.errors));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (schemas.cookieSchema) {
|
|
249
|
+
const validate = ajv.compile(schemas.cookieSchema);
|
|
250
|
+
if (!validate(cookieValues)) {
|
|
251
|
+
throw new Error(formatAjvErrors(validate.errors));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Apply params -> query/header/cookie
|
|
256
|
+
for (const [name, value] of Object.entries(queryValues)) {
|
|
257
|
+
if (Array.isArray(value)) {
|
|
258
|
+
for (const item of value) {
|
|
259
|
+
url.searchParams.append(name, String(item));
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
url.searchParams.set(name, String(value));
|
|
264
|
+
}
|
|
265
|
+
for (const [name, value] of Object.entries(headerValues)) {
|
|
266
|
+
headers.set(name, String(value));
|
|
267
|
+
}
|
|
268
|
+
for (const [name, value] of Object.entries(cookieValues)) {
|
|
269
|
+
const existing = headers.get("Cookie");
|
|
270
|
+
const part = `${name}=${String(value)}`;
|
|
271
|
+
headers.set("Cookie", existing ? `${existing}; ${part}` : part);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const extraHeaders = [
|
|
275
|
+
...(input.globals.header ?? []),
|
|
276
|
+
...(input.globals.ocHeader ?? []),
|
|
277
|
+
].map(parseHeaderInput);
|
|
278
|
+
for (const { name, value } of extraHeaders) {
|
|
279
|
+
headers.set(name, value);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let body: string | undefined;
|
|
283
|
+
if (input.action.requestBody) {
|
|
284
|
+
const data = input.globals.data ?? input.globals.ocData;
|
|
285
|
+
const file = input.globals.file ?? input.globals.ocFile;
|
|
286
|
+
|
|
287
|
+
const hasData = typeof data === "string";
|
|
288
|
+
const hasFile = typeof file === "string";
|
|
289
|
+
|
|
290
|
+
const rawBodyFlags =
|
|
291
|
+
typeof input.flagValues.__body === "object" && input.flagValues.__body
|
|
292
|
+
? (input.flagValues.__body as Record<string, unknown>)
|
|
293
|
+
: {};
|
|
294
|
+
|
|
295
|
+
const bodyFlags: Record<string, unknown> = Object.fromEntries(
|
|
296
|
+
Object.entries(rawBodyFlags).filter(
|
|
297
|
+
([, v]) => typeof v !== "undefined" && v !== false,
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const hasExpandedBody = Object.keys(bodyFlags).length > 0;
|
|
302
|
+
|
|
303
|
+
if (hasData && hasFile) throw new Error("Use only one of --data or --file");
|
|
304
|
+
if (hasExpandedBody && (hasData || hasFile)) {
|
|
305
|
+
throw new Error("Use either --data/--file or --body-* flags (not both)");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const contentType =
|
|
309
|
+
input.globals.contentType ??
|
|
310
|
+
input.globals.ocContentType ??
|
|
311
|
+
input.action.requestBody.preferredContentType;
|
|
312
|
+
if (contentType) headers.set("Content-Type", contentType);
|
|
313
|
+
|
|
314
|
+
const schema = input.action.requestBodySchema;
|
|
315
|
+
|
|
316
|
+
if (!hasExpandedBody && !hasData && !hasFile) {
|
|
317
|
+
if (input.action.requestBody.required) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
"Missing request body. Provide --data, --file, or --body-* flags.",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
} else if (hasExpandedBody) {
|
|
323
|
+
if (!contentType?.includes("json")) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
"Expanded body flags are only supported for JSON request bodies. Use --content-type application/json or --data/--file.",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Friendly "missing required field" errors for expanded flags.
|
|
330
|
+
if (schema?.required && Array.isArray(schema.required)) {
|
|
331
|
+
for (const name of schema.required) {
|
|
332
|
+
const n = String(name);
|
|
333
|
+
const key = `body${n[0]?.toUpperCase()}${n.slice(1)}`;
|
|
334
|
+
if (!(key in bodyFlags)) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`Missing required body field '${n}'. Provide --body-${n} or use --data/--file.`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const built: Record<string, unknown> = {};
|
|
343
|
+
for (const [k, v] of Object.entries(bodyFlags)) {
|
|
344
|
+
if (!k.startsWith("body")) continue;
|
|
345
|
+
const name = k.slice("body".length);
|
|
346
|
+
const propName = name.length
|
|
347
|
+
? name[0]?.toLowerCase() + name.slice(1)
|
|
348
|
+
: "";
|
|
349
|
+
if (!propName) continue;
|
|
350
|
+
|
|
351
|
+
const propSchema =
|
|
352
|
+
schema && schema.type === "object" && schema.properties
|
|
353
|
+
? (schema.properties as Record<string, unknown>)[propName]
|
|
354
|
+
: undefined;
|
|
355
|
+
const t =
|
|
356
|
+
propSchema && typeof propSchema === "object"
|
|
357
|
+
? (propSchema as { type?: unknown }).type
|
|
358
|
+
: "string";
|
|
359
|
+
|
|
360
|
+
if (t === "boolean") {
|
|
361
|
+
built[propName] = true;
|
|
362
|
+
} else if (t === "integer") {
|
|
363
|
+
built[propName] = Number.parseInt(String(v), 10);
|
|
364
|
+
} else if (t === "number") {
|
|
365
|
+
built[propName] = Number(String(v));
|
|
366
|
+
} else {
|
|
367
|
+
built[propName] = String(v);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (schema) {
|
|
372
|
+
const validate = ajv.compile(schema);
|
|
373
|
+
if (!validate(built)) {
|
|
374
|
+
throw new Error(formatAjvErrors(validate.errors));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
body = JSON.stringify(built);
|
|
379
|
+
} else if (hasData) {
|
|
380
|
+
if (contentType?.includes("json")) {
|
|
381
|
+
const parsed = parseBodyAsJsonOrYaml(data as string);
|
|
382
|
+
|
|
383
|
+
if (schema) {
|
|
384
|
+
const validate = ajv.compile(schema);
|
|
385
|
+
if (!validate(parsed)) {
|
|
386
|
+
throw new Error(formatAjvErrors(validate.errors));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
body = JSON.stringify(parsed);
|
|
391
|
+
} else {
|
|
392
|
+
body = data as string;
|
|
393
|
+
}
|
|
394
|
+
} else if (hasFile) {
|
|
395
|
+
const loaded = await loadBody({
|
|
396
|
+
kind: "file",
|
|
397
|
+
path: file as string,
|
|
398
|
+
});
|
|
399
|
+
if (contentType?.includes("json")) {
|
|
400
|
+
const parsed = parseBodyAsJsonOrYaml(loaded?.raw ?? "");
|
|
401
|
+
|
|
402
|
+
if (schema) {
|
|
403
|
+
const validate = ajv.compile(schema);
|
|
404
|
+
if (!validate(parsed)) {
|
|
405
|
+
throw new Error(formatAjvErrors(validate.errors));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
body = JSON.stringify(parsed);
|
|
410
|
+
} else {
|
|
411
|
+
body = loaded?.raw;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
if (
|
|
416
|
+
typeof (input.globals.data ?? input.globals.ocData) === "string" ||
|
|
417
|
+
typeof (input.globals.file ?? input.globals.ocFile) === "string"
|
|
418
|
+
) {
|
|
419
|
+
throw new Error("This operation does not accept a request body");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Profile-aware auth resolution (flags override profile).
|
|
424
|
+
const resolvedAuthScheme = resolveAuthScheme(
|
|
425
|
+
input.authSchemes,
|
|
426
|
+
input.action.auth,
|
|
427
|
+
{
|
|
428
|
+
flagAuthScheme: input.globals.auth,
|
|
429
|
+
profileAuthScheme: profile?.authScheme,
|
|
430
|
+
},
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const tokenFromProfile =
|
|
434
|
+
profile?.name && resolvedAuthScheme
|
|
435
|
+
? await getToken(input.specId, profile.name)
|
|
436
|
+
: null;
|
|
437
|
+
|
|
438
|
+
const globalsWithProfileAuth: RuntimeGlobals = {
|
|
439
|
+
...input.globals,
|
|
440
|
+
auth: resolvedAuthScheme,
|
|
441
|
+
bearerToken:
|
|
442
|
+
input.globals.bearerToken ??
|
|
443
|
+
input.globals.oauthToken ??
|
|
444
|
+
tokenFromProfile ??
|
|
445
|
+
undefined,
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const final = applyAuth(
|
|
449
|
+
headers,
|
|
450
|
+
url,
|
|
451
|
+
input.action,
|
|
452
|
+
globalsWithProfileAuth,
|
|
453
|
+
input.authSchemes,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const req = new Request(final.url.toString(), {
|
|
457
|
+
method: input.action.method,
|
|
458
|
+
headers: final.headers,
|
|
459
|
+
body,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const curl = buildCurl(req, body);
|
|
463
|
+
return { request: req, curl };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function buildCurl(req: Request, body: string | undefined): string {
|
|
467
|
+
const parts: string[] = ["curl", "-sS", "-X", req.method];
|
|
468
|
+
for (const [k, v] of req.headers.entries()) {
|
|
469
|
+
parts.push("-H", shellQuote(`${k}: ${v}`));
|
|
470
|
+
}
|
|
471
|
+
if (typeof body === "string") {
|
|
472
|
+
parts.push("--data", shellQuote(body));
|
|
473
|
+
}
|
|
474
|
+
parts.push(shellQuote(req.url));
|
|
475
|
+
return parts.join(" ");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function shellQuote(value: string): string {
|
|
479
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function optionKeyFromFlag(flag: string): string {
|
|
483
|
+
// Commander uses camelCase property names derived from long flag.
|
|
484
|
+
// Example: --x-request-id -> xRequestId
|
|
485
|
+
const name = flag.replace(/^--/, "");
|
|
486
|
+
return name.replace(/-([a-z])/g, (_, c) => String(c).toUpperCase());
|
|
487
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ServerInfo } from "../server.ts";
|
|
2
|
+
|
|
3
|
+
import { applyTemplate, extractTemplateVars } from "./template.ts";
|
|
4
|
+
|
|
5
|
+
export type ResolveServerInput = {
|
|
6
|
+
serverOverride?: string;
|
|
7
|
+
servers: ServerInfo[];
|
|
8
|
+
serverVars: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function resolveServerUrl(input: ResolveServerInput): string {
|
|
12
|
+
const base = input.serverOverride ?? input.servers[0]?.url;
|
|
13
|
+
if (!base) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"No server URL found. Provide --server <url> or define servers in the OpenAPI spec.",
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const names = extractTemplateVars(base);
|
|
20
|
+
if (!names.length) return base;
|
|
21
|
+
|
|
22
|
+
const vars: Record<string, string> = {};
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
const provided = input.serverVars[name];
|
|
25
|
+
if (typeof provided === "string") {
|
|
26
|
+
vars[name] = provided;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// If spec has default for this var, use it.
|
|
31
|
+
const match = input.servers.find((s) => s.url === base);
|
|
32
|
+
const v = match?.variables.find((x) => x.name === name);
|
|
33
|
+
if (typeof v?.default === "string") {
|
|
34
|
+
vars[name] = v.default;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Missing server variable '${name}'. Provide --server-var ${name}=...`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return applyTemplate(base, vars);
|
|
44
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function extractTemplateVars(template: string): string[] {
|
|
2
|
+
const out: string[] = [];
|
|
3
|
+
const re = /\{([^}]+)\}/g;
|
|
4
|
+
while (true) {
|
|
5
|
+
const match = re.exec(template);
|
|
6
|
+
if (!match) break;
|
|
7
|
+
out.push((match[1] ?? "").trim());
|
|
8
|
+
}
|
|
9
|
+
return out.filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function applyTemplate(
|
|
13
|
+
template: string,
|
|
14
|
+
vars: Record<string, string>,
|
|
15
|
+
options?: { encode?: boolean },
|
|
16
|
+
): string {
|
|
17
|
+
const encode = options?.encode ?? false;
|
|
18
|
+
return template.replace(/\{([^}]+)\}/g, (_, rawName) => {
|
|
19
|
+
const name = String(rawName).trim();
|
|
20
|
+
const value = vars[name];
|
|
21
|
+
if (typeof value !== "string") {
|
|
22
|
+
throw new Error(`Missing template variable: ${name}`);
|
|
23
|
+
}
|
|
24
|
+
return encode ? encodeURIComponent(value) : value;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { InvalidArgumentError } from "commander";
|
|
2
|
+
|
|
3
|
+
import type { ParamType } from "../../schema-shape.ts";
|
|
4
|
+
|
|
5
|
+
export function coerceValue(raw: string, type: ParamType): unknown {
|
|
6
|
+
if (type === "string" || type === "unknown") return raw;
|
|
7
|
+
|
|
8
|
+
if (type === "boolean") {
|
|
9
|
+
// Commander boolean options are handled without a value; keep for completeness.
|
|
10
|
+
if (raw === "true") return true;
|
|
11
|
+
if (raw === "false") return false;
|
|
12
|
+
throw new InvalidArgumentError(`Expected boolean, got '${raw}'`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (type === "integer") {
|
|
16
|
+
const n = Number.parseInt(raw, 10);
|
|
17
|
+
if (!Number.isFinite(n))
|
|
18
|
+
throw new InvalidArgumentError(`Expected integer, got '${raw}'`);
|
|
19
|
+
return n;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (type === "number") {
|
|
23
|
+
const n = Number(raw);
|
|
24
|
+
if (!Number.isFinite(n))
|
|
25
|
+
throw new InvalidArgumentError(`Expected number, got '${raw}'`);
|
|
26
|
+
return n;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// For now, accept objects as JSON strings.
|
|
30
|
+
if (type === "object") {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new InvalidArgumentError(
|
|
35
|
+
`Expected JSON object, got '${raw}'. Use --data/--file for complex bodies.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Arrays should usually be passed as repeatable flags or comma-separated,
|
|
41
|
+
// but allow JSON arrays too.
|
|
42
|
+
if (type === "array") {
|
|
43
|
+
return coerceArrayInput(raw, "string");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return raw;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function coerceArrayInput(raw: string, itemType: ParamType): unknown[] {
|
|
50
|
+
const trimmed = raw.trim();
|
|
51
|
+
if (!trimmed) return [];
|
|
52
|
+
|
|
53
|
+
if (trimmed.startsWith("[")) {
|
|
54
|
+
let parsed: unknown;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(trimmed);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new InvalidArgumentError(`Expected JSON array, got '${raw}'`);
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(parsed)) {
|
|
61
|
+
throw new InvalidArgumentError(`Expected JSON array, got '${raw}'`);
|
|
62
|
+
}
|
|
63
|
+
return parsed.map((v) => coerceValue(String(v), itemType));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return trimmed
|
|
67
|
+
.split(",")
|
|
68
|
+
.map((s) => s.trim())
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.map((s) => coerceValue(s, itemType));
|
|
71
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ErrorObject } from "ajv";
|
|
2
|
+
|
|
3
|
+
export function formatAjvErrors(
|
|
4
|
+
errors: ErrorObject[] | null | undefined,
|
|
5
|
+
): string {
|
|
6
|
+
if (!errors?.length) return "Invalid input";
|
|
7
|
+
|
|
8
|
+
return errors
|
|
9
|
+
.map((e) => {
|
|
10
|
+
const path = e.instancePath || e.schemaPath || "";
|
|
11
|
+
|
|
12
|
+
if (
|
|
13
|
+
e.keyword === "required" &&
|
|
14
|
+
e.params &&
|
|
15
|
+
typeof e.params === "object" &&
|
|
16
|
+
"missingProperty" in e.params
|
|
17
|
+
) {
|
|
18
|
+
const missing = String(
|
|
19
|
+
(e.params as { missingProperty?: unknown }).missingProperty,
|
|
20
|
+
);
|
|
21
|
+
const where = e.instancePath || "/";
|
|
22
|
+
return `${where} missing required property '${missing}'`.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const msg = e.message || "invalid";
|
|
26
|
+
return `${path} ${msg}`.trim();
|
|
27
|
+
})
|
|
28
|
+
.join("\n");
|
|
29
|
+
}
|