specli 0.0.17 → 0.0.18
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/dist/cli/compile.js +8 -1
- package/package.json +2 -1
- package/src/ai/tools.test.ts +83 -0
- package/src/ai/tools.ts +211 -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 +109 -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 +255 -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 +59 -0
- package/src/cli/runtime/body-flags.test.ts +261 -0
- package/src/cli/runtime/body-flags.ts +176 -0
- package/src/cli/runtime/body.ts +24 -0
- package/src/cli/runtime/collect.ts +6 -0
- package/src/cli/runtime/compat.ts +89 -0
- package/src/cli/runtime/context.ts +62 -0
- package/src/cli/runtime/execute.ts +147 -0
- package/src/cli/runtime/generated.ts +242 -0
- package/src/cli/runtime/headers.ts +37 -0
- package/src/cli/runtime/index.ts +3 -0
- package/src/cli/runtime/profile/secrets.ts +83 -0
- package/src/cli/runtime/profile/store.ts +100 -0
- package/src/cli/runtime/request.test.ts +375 -0
- package/src/cli/runtime/request.ts +390 -0
- package/src/cli/runtime/server-url.ts +45 -0
- package/src/cli/runtime/template.ts +26 -0
- package/src/cli/runtime/validate/ajv.ts +13 -0
- package/src/cli/runtime/validate/coerce.test.ts +98 -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 +55 -0
- package/src/cli/server.ts +167 -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/cli.ts +94 -0
- package/src/compiled.ts +24 -0
- package/src/macros/env.ts +21 -0
- package/src/macros/spec.ts +17 -0
- package/src/macros/version.ts +14 -0
package/src/cli/exec.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { main } from "./main.js";
|
|
2
|
+
|
|
3
|
+
export type ExecOptions = {
|
|
4
|
+
server?: string;
|
|
5
|
+
serverVar?: string[];
|
|
6
|
+
auth?: string;
|
|
7
|
+
bearerToken?: string;
|
|
8
|
+
oauthToken?: string;
|
|
9
|
+
username?: string;
|
|
10
|
+
password?: string;
|
|
11
|
+
apiKey?: string;
|
|
12
|
+
profile?: string;
|
|
13
|
+
json?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export async function execCommand(
|
|
17
|
+
spec: string,
|
|
18
|
+
options: ExecOptions,
|
|
19
|
+
commandArgs: string[],
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
// commandArgs includes the spec as first element, filter it out
|
|
22
|
+
// to get the remaining args (resource, action, etc.)
|
|
23
|
+
const remainingArgs = commandArgs.slice(1);
|
|
24
|
+
|
|
25
|
+
// Reconstruct argv for main():
|
|
26
|
+
// [node, script, --spec, <spec>, ...options, ...remainingArgs]
|
|
27
|
+
const argv = [
|
|
28
|
+
process.argv[0] ?? "bun",
|
|
29
|
+
process.argv[1] ?? "specli",
|
|
30
|
+
"--spec",
|
|
31
|
+
spec,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// Add common options back as flags
|
|
35
|
+
if (options.server) {
|
|
36
|
+
argv.push("--server", options.server);
|
|
37
|
+
}
|
|
38
|
+
if (options.serverVar) {
|
|
39
|
+
for (const v of options.serverVar) {
|
|
40
|
+
argv.push("--server-var", v);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (options.auth) {
|
|
44
|
+
argv.push("--auth", options.auth);
|
|
45
|
+
}
|
|
46
|
+
if (options.bearerToken) {
|
|
47
|
+
argv.push("--bearer-token", options.bearerToken);
|
|
48
|
+
}
|
|
49
|
+
if (options.oauthToken) {
|
|
50
|
+
argv.push("--oauth-token", options.oauthToken);
|
|
51
|
+
}
|
|
52
|
+
if (options.username) {
|
|
53
|
+
argv.push("--username", options.username);
|
|
54
|
+
}
|
|
55
|
+
if (options.password) {
|
|
56
|
+
argv.push("--password", options.password);
|
|
57
|
+
}
|
|
58
|
+
if (options.apiKey) {
|
|
59
|
+
argv.push("--api-key", options.apiKey);
|
|
60
|
+
}
|
|
61
|
+
if (options.profile) {
|
|
62
|
+
argv.push("--profile", options.profile);
|
|
63
|
+
}
|
|
64
|
+
if (options.json) {
|
|
65
|
+
argv.push("--json");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Append remaining args (subcommand + its args)
|
|
69
|
+
argv.push(...remainingArgs);
|
|
70
|
+
|
|
71
|
+
await main(argv);
|
|
72
|
+
}
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
import { getArgValue, hasAnyArg } from "./runtime/argv.js";
|
|
7
|
+
import { collectRepeatable } from "./runtime/collect.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reads the version from package.json at runtime.
|
|
11
|
+
* Used when running in non-compiled mode.
|
|
12
|
+
*/
|
|
13
|
+
function getPackageVersion(): string {
|
|
14
|
+
try {
|
|
15
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const packageJsonPath = join(currentDir, "../../package.json");
|
|
17
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
18
|
+
return packageJson.version ?? "0.0.0";
|
|
19
|
+
} catch {
|
|
20
|
+
return "0.0.0";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
import { readStdinText } from "./runtime/compat.js";
|
|
25
|
+
import { buildRuntimeContext } from "./runtime/context.js";
|
|
26
|
+
import { addGeneratedCommands } from "./runtime/generated.js";
|
|
27
|
+
import { deleteToken, getToken, setToken } from "./runtime/profile/secrets.js";
|
|
28
|
+
import {
|
|
29
|
+
readProfiles,
|
|
30
|
+
upsertProfile,
|
|
31
|
+
writeProfiles,
|
|
32
|
+
} from "./runtime/profile/store.js";
|
|
33
|
+
import { toMinimalSchemaOutput } from "./schema.js";
|
|
34
|
+
import { stableStringify } from "./stable-json.js";
|
|
35
|
+
|
|
36
|
+
type MainOptions = {
|
|
37
|
+
embeddedSpecText?: string;
|
|
38
|
+
cliName?: string;
|
|
39
|
+
server?: string;
|
|
40
|
+
serverVars?: string[];
|
|
41
|
+
auth?: string;
|
|
42
|
+
version?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export async function main(argv: string[], options: MainOptions = {}) {
|
|
46
|
+
const program = new Command();
|
|
47
|
+
|
|
48
|
+
// Get version - use embedded version if available, otherwise read from package.json
|
|
49
|
+
const cliVersion = options.version ?? getPackageVersion();
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.name(options.cliName ?? "specli")
|
|
53
|
+
.description("Generate a CLI from an OpenAPI spec")
|
|
54
|
+
.version(cliVersion, "-v, --version", "Output the version number")
|
|
55
|
+
.option("--spec <urlOrPath>", "OpenAPI URL or file path")
|
|
56
|
+
.option("--server <url>", "Override server/base URL")
|
|
57
|
+
.option(
|
|
58
|
+
"--server-var <name=value>",
|
|
59
|
+
"Server URL template variable (repeatable)",
|
|
60
|
+
collectRepeatable,
|
|
61
|
+
)
|
|
62
|
+
.option("--auth <scheme>", "Select auth scheme by key")
|
|
63
|
+
.option("--bearer-token <token>", "Bearer token (Authorization: Bearer)")
|
|
64
|
+
.option("--oauth-token <token>", "OAuth token (alias of bearer)")
|
|
65
|
+
.option("--username <username>", "Basic auth username")
|
|
66
|
+
.option("--password <password>", "Basic auth password")
|
|
67
|
+
.option("--api-key <key>", "API key value")
|
|
68
|
+
.option("--json", "Machine-readable output")
|
|
69
|
+
.showHelpAfterError();
|
|
70
|
+
|
|
71
|
+
// If user asks for help and we have no embedded spec and no --spec, show minimal help.
|
|
72
|
+
const spec = getArgValue(argv, "--spec");
|
|
73
|
+
const wantsHelp = hasAnyArg(argv, ["-h", "--help"]);
|
|
74
|
+
if (!spec && !options.embeddedSpecText && wantsHelp) {
|
|
75
|
+
program.addHelpText(
|
|
76
|
+
"after",
|
|
77
|
+
"\nTo see generated commands, run with --spec <url|path>.\n",
|
|
78
|
+
);
|
|
79
|
+
program.parse(argv);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ctx = await buildRuntimeContext({
|
|
84
|
+
spec,
|
|
85
|
+
embeddedSpecText: options.embeddedSpecText,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Simple auth commands
|
|
89
|
+
const defaultProfileName = "default";
|
|
90
|
+
|
|
91
|
+
program
|
|
92
|
+
.command("login [token]")
|
|
93
|
+
.description("Store a bearer token for authentication")
|
|
94
|
+
.action(async (tokenArg: string | undefined, _opts, command) => {
|
|
95
|
+
const globals = command.optsWithGlobals() as { json?: boolean };
|
|
96
|
+
|
|
97
|
+
let token = tokenArg;
|
|
98
|
+
|
|
99
|
+
// If no token argument, try to read from stdin (for piping)
|
|
100
|
+
if (!token) {
|
|
101
|
+
const isTTY = process.stdin.isTTY;
|
|
102
|
+
if (isTTY) {
|
|
103
|
+
// Interactive mode - prompt user
|
|
104
|
+
process.stdout.write("Enter token: ");
|
|
105
|
+
const reader = process.stdin;
|
|
106
|
+
const chunks: Buffer[] = [];
|
|
107
|
+
for await (const chunk of reader) {
|
|
108
|
+
chunks.push(chunk);
|
|
109
|
+
// Read one line only
|
|
110
|
+
if (chunk.includes(10)) break; // newline
|
|
111
|
+
}
|
|
112
|
+
token = Buffer.concat(chunks).toString().trim();
|
|
113
|
+
} else {
|
|
114
|
+
// Piped input - use cross-runtime stdin reading
|
|
115
|
+
const text = await readStdinText();
|
|
116
|
+
token = text.trim();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!token) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
"No token provided. Usage: login <token> or echo $TOKEN | login",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Ensure default profile exists
|
|
127
|
+
const file = await readProfiles();
|
|
128
|
+
if (!file.profiles.find((p) => p.name === defaultProfileName)) {
|
|
129
|
+
const updated = upsertProfile(file, { name: defaultProfileName });
|
|
130
|
+
await writeProfiles({ ...updated, defaultProfile: defaultProfileName });
|
|
131
|
+
} else if (!file.defaultProfile) {
|
|
132
|
+
await writeProfiles({ ...file, defaultProfile: defaultProfileName });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await setToken(ctx.loaded.id, defaultProfileName, token);
|
|
136
|
+
|
|
137
|
+
if (globals.json) {
|
|
138
|
+
process.stdout.write(`${JSON.stringify({ ok: true })}\n`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
process.stdout.write("ok: logged in\n");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
program
|
|
145
|
+
.command("logout")
|
|
146
|
+
.description("Clear stored authentication token")
|
|
147
|
+
.action(async (_opts, command) => {
|
|
148
|
+
const globals = command.optsWithGlobals() as { json?: boolean };
|
|
149
|
+
|
|
150
|
+
const deleted = await deleteToken(ctx.loaded.id, defaultProfileName);
|
|
151
|
+
|
|
152
|
+
if (globals.json) {
|
|
153
|
+
process.stdout.write(`${JSON.stringify({ ok: deleted })}\n`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
process.stdout.write(
|
|
157
|
+
deleted ? "ok: logged out\n" : "ok: not logged in\n",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
program
|
|
162
|
+
.command("whoami")
|
|
163
|
+
.description("Show current authentication status")
|
|
164
|
+
.action(async (_opts, command) => {
|
|
165
|
+
const globals = command.optsWithGlobals() as { json?: boolean };
|
|
166
|
+
|
|
167
|
+
const token = await getToken(ctx.loaded.id, defaultProfileName);
|
|
168
|
+
const hasToken = Boolean(token);
|
|
169
|
+
|
|
170
|
+
// Mask the token for display (show first 8 and last 4 chars)
|
|
171
|
+
let maskedToken: string | null = null;
|
|
172
|
+
if (token && token.length > 16) {
|
|
173
|
+
maskedToken = `${token.slice(0, 8)}...${token.slice(-4)}`;
|
|
174
|
+
} else if (token) {
|
|
175
|
+
maskedToken = `${token.slice(0, 4)}...`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (globals.json) {
|
|
179
|
+
process.stdout.write(
|
|
180
|
+
`${JSON.stringify({ authenticated: hasToken, token: maskedToken })}\n`,
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (hasToken) {
|
|
186
|
+
process.stdout.write(`authenticated: yes\n`);
|
|
187
|
+
process.stdout.write(`token: ${maskedToken}\n`);
|
|
188
|
+
} else {
|
|
189
|
+
process.stdout.write(`authenticated: no\n`);
|
|
190
|
+
process.stdout.write(`Run 'login <token>' to authenticate.\n`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
program
|
|
195
|
+
.command("__schema")
|
|
196
|
+
.description("Print indexed operations (machine-readable when --json)")
|
|
197
|
+
.option("--pretty", "Pretty-print JSON when used with --json")
|
|
198
|
+
.option("--min", "Minimal JSON output (commands + metadata only)")
|
|
199
|
+
.action(async (_opts, command) => {
|
|
200
|
+
const flags = command.optsWithGlobals() as {
|
|
201
|
+
json?: boolean;
|
|
202
|
+
pretty?: boolean;
|
|
203
|
+
min?: boolean;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
if (flags.json) {
|
|
207
|
+
const pretty = Boolean(flags.pretty);
|
|
208
|
+
const payload = flags.min
|
|
209
|
+
? toMinimalSchemaOutput(ctx.schema)
|
|
210
|
+
: ctx.schema;
|
|
211
|
+
const text = stableStringify(payload, { space: pretty ? 2 : 0 });
|
|
212
|
+
process.stdout.write(`${text}\n`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
process.stdout.write(`${ctx.schema.openapi.title ?? "(untitled)"}\n`);
|
|
217
|
+
process.stdout.write(`OpenAPI: ${ctx.schema.openapi.version}\n`);
|
|
218
|
+
process.stdout.write(
|
|
219
|
+
`Spec: ${ctx.schema.spec.id} (${ctx.schema.spec.source})\n`,
|
|
220
|
+
);
|
|
221
|
+
process.stdout.write(`Fingerprint: ${ctx.schema.spec.fingerprint}\n`);
|
|
222
|
+
process.stdout.write(`Servers: ${ctx.schema.servers.length}\n`);
|
|
223
|
+
process.stdout.write(`Auth Schemes: ${ctx.schema.authSchemes.length}\n`);
|
|
224
|
+
process.stdout.write(`Operations: ${ctx.schema.operations.length}\n`);
|
|
225
|
+
|
|
226
|
+
for (const op of ctx.schema.operations) {
|
|
227
|
+
const id = op.operationId ? ` (${op.operationId})` : "";
|
|
228
|
+
process.stdout.write(`- ${op.method} ${op.path}${id}\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (ctx.schema.planned?.length) {
|
|
232
|
+
process.stdout.write("\nPlanned commands:\n");
|
|
233
|
+
for (const op of ctx.schema.planned) {
|
|
234
|
+
const args = op.pathArgs.length
|
|
235
|
+
? ` ${op.pathArgs.map((a) => `<${a}>`).join(" ")}`
|
|
236
|
+
: "";
|
|
237
|
+
process.stdout.write(`- specli ${op.resource} ${op.action}${args}\n`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
addGeneratedCommands(program, {
|
|
243
|
+
servers: ctx.servers,
|
|
244
|
+
authSchemes: ctx.authSchemes,
|
|
245
|
+
commands: ctx.commands,
|
|
246
|
+
specId: ctx.loaded.id,
|
|
247
|
+
embeddedDefaults: {
|
|
248
|
+
server: options.server,
|
|
249
|
+
serverVars: options.serverVars,
|
|
250
|
+
auth: options.auth,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await program.parseAsync(argv);
|
|
255
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { planOperation } from "./naming.js";
|
|
3
|
+
import type { NormalizedOperation } from "./types.js";
|
|
4
|
+
|
|
5
|
+
describe("planOperation", () => {
|
|
6
|
+
test("REST: GET /contacts -> contacts list", () => {
|
|
7
|
+
const op: NormalizedOperation = {
|
|
8
|
+
key: "GET /contacts",
|
|
9
|
+
method: "GET",
|
|
10
|
+
path: "/contacts",
|
|
11
|
+
operationId: "Contacts.List",
|
|
12
|
+
tags: ["Contacts"],
|
|
13
|
+
parameters: [],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const planned = planOperation(op);
|
|
17
|
+
expect(planned.style).toBe("rest");
|
|
18
|
+
expect(planned.resource).toBe("contacts");
|
|
19
|
+
expect(planned.action).toBe("list");
|
|
20
|
+
expect(planned.pathArgs).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("REST: singleton /ping stays ping and prefers operationId action", () => {
|
|
24
|
+
const op: NormalizedOperation = {
|
|
25
|
+
key: "GET /ping",
|
|
26
|
+
method: "GET",
|
|
27
|
+
path: "/ping",
|
|
28
|
+
operationId: "Ping.Get",
|
|
29
|
+
tags: [],
|
|
30
|
+
parameters: [],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const planned = planOperation(op);
|
|
34
|
+
expect(planned.style).toBe("rest");
|
|
35
|
+
expect(planned.resource).toBe("ping");
|
|
36
|
+
expect(planned.action).toBe("get");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("REST: singular path pluralizes to contacts", () => {
|
|
40
|
+
const op: NormalizedOperation = {
|
|
41
|
+
key: "GET /contact/{id}",
|
|
42
|
+
method: "GET",
|
|
43
|
+
path: "/contact/{id}",
|
|
44
|
+
tags: [],
|
|
45
|
+
parameters: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const planned = planOperation(op);
|
|
49
|
+
expect(planned.style).toBe("rest");
|
|
50
|
+
expect(planned.resource).toBe("contacts");
|
|
51
|
+
expect(planned.action).toBe("get");
|
|
52
|
+
expect(planned.pathArgs).toEqual(["id"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("RPC: POST /Contacts.List -> contacts list", () => {
|
|
56
|
+
const op: NormalizedOperation = {
|
|
57
|
+
key: "POST /Contacts.List",
|
|
58
|
+
method: "POST",
|
|
59
|
+
path: "/Contacts.List",
|
|
60
|
+
operationId: "Contacts.List",
|
|
61
|
+
tags: [],
|
|
62
|
+
parameters: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const planned = planOperation(op);
|
|
66
|
+
expect(planned.style).toBe("rpc");
|
|
67
|
+
expect(planned.resource).toBe("contacts");
|
|
68
|
+
expect(planned.action).toBe("list");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("RPC: Retrieve canonicalizes to get", () => {
|
|
72
|
+
const op: NormalizedOperation = {
|
|
73
|
+
key: "POST /Contacts.Retrieve",
|
|
74
|
+
method: "POST",
|
|
75
|
+
path: "/Contacts.Retrieve",
|
|
76
|
+
operationId: "Contacts.Retrieve",
|
|
77
|
+
tags: [],
|
|
78
|
+
parameters: [],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const planned = planOperation(op);
|
|
82
|
+
expect(planned.style).toBe("rpc");
|
|
83
|
+
expect(planned.resource).toBe("contacts");
|
|
84
|
+
expect(planned.action).toBe("get");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { pluralize } from "./pluralize.js";
|
|
2
|
+
import { kebabCase } from "./strings.js";
|
|
3
|
+
import type { NormalizedOperation } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type PlannedOperation = NormalizedOperation & {
|
|
6
|
+
resource: string;
|
|
7
|
+
action: string;
|
|
8
|
+
pathArgs: string[];
|
|
9
|
+
style: "rest" | "rpc";
|
|
10
|
+
canonicalAction: string;
|
|
11
|
+
aliasOf?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const GENERIC_TAGS = new Set(["default", "defaults", "api"]);
|
|
15
|
+
|
|
16
|
+
function getPathSegments(path: string): string[] {
|
|
17
|
+
return path
|
|
18
|
+
.split("/")
|
|
19
|
+
.map((s) => s.trim())
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getPathArgs(path: string): string[] {
|
|
24
|
+
const args: string[] = [];
|
|
25
|
+
const re = /\{([^}]+)\}/g;
|
|
26
|
+
|
|
27
|
+
while (true) {
|
|
28
|
+
const match = re.exec(path);
|
|
29
|
+
if (!match) break;
|
|
30
|
+
// biome-ignore lint/style/noNonNullAssertion: unknown
|
|
31
|
+
args.push(match[1]!);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return args;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pickResourceFromTags(tags: string[]): string | undefined {
|
|
38
|
+
if (!tags.length) return undefined;
|
|
39
|
+
const first = tags[0]?.trim();
|
|
40
|
+
if (!first) return undefined;
|
|
41
|
+
if (GENERIC_TAGS.has(first.toLowerCase())) return undefined;
|
|
42
|
+
return first;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function splitOperationId(operationId: string): {
|
|
46
|
+
prefix?: string;
|
|
47
|
+
suffix?: string;
|
|
48
|
+
} {
|
|
49
|
+
const trimmed = operationId.trim();
|
|
50
|
+
if (!trimmed) return {};
|
|
51
|
+
|
|
52
|
+
// Prefer dot-notation when present: Contacts.List
|
|
53
|
+
if (trimmed.includes(".")) {
|
|
54
|
+
const [prefix, ...rest] = trimmed.split(".");
|
|
55
|
+
return { prefix, suffix: rest.join(".") };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Try separators: Contacts_List, Contacts__List
|
|
59
|
+
if (trimmed.includes("__")) {
|
|
60
|
+
const [prefix, ...rest] = trimmed.split("__");
|
|
61
|
+
return { prefix, suffix: rest.join("__") };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (trimmed.includes("_")) {
|
|
65
|
+
const [prefix, ...rest] = trimmed.split("_");
|
|
66
|
+
return { prefix, suffix: rest.join("_") };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { suffix: trimmed };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function inferStyle(op: NormalizedOperation): "rest" | "rpc" {
|
|
73
|
+
// Path-based RPC convention (common in gRPC-ish HTTP gateways)
|
|
74
|
+
// - POST /Contacts.List
|
|
75
|
+
// - POST /Contacts/Service.List
|
|
76
|
+
if (op.path.includes(".")) return "rpc";
|
|
77
|
+
|
|
78
|
+
// operationId dot-notation alone is not enough to call it RPC; many REST APIs
|
|
79
|
+
// have dotted ids. We treat dotted operationId as a weak signal.
|
|
80
|
+
if (op.operationId?.includes(".") && op.method === "POST") return "rpc";
|
|
81
|
+
|
|
82
|
+
return "rest";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function inferResource(op: NormalizedOperation): string {
|
|
86
|
+
const tag = pickResourceFromTags(op.tags);
|
|
87
|
+
if (tag) return pluralize(kebabCase(tag));
|
|
88
|
+
|
|
89
|
+
if (op.operationId) {
|
|
90
|
+
const { prefix } = splitOperationId(op.operationId);
|
|
91
|
+
if (prefix) {
|
|
92
|
+
const fromId = kebabCase(prefix);
|
|
93
|
+
if (fromId === "ping") return "ping";
|
|
94
|
+
return pluralize(fromId);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const segments = getPathSegments(op.path);
|
|
99
|
+
let first = segments[0] ?? "api";
|
|
100
|
+
|
|
101
|
+
// If first segment is rpc-ish, like Contacts.List, split it.
|
|
102
|
+
// biome-ignore lint/style/noNonNullAssertion: split always returns at least one element
|
|
103
|
+
first = first.includes(".") ? first.split(".")[0]! : first;
|
|
104
|
+
|
|
105
|
+
// Singletons like /ping generally shouldn't become `pings`.
|
|
106
|
+
if (first.toLowerCase() === "ping") return "ping";
|
|
107
|
+
|
|
108
|
+
// Strip path params if they appear in first segment (rare)
|
|
109
|
+
const cleaned = first.replace(/^\{.+\}$/, "");
|
|
110
|
+
return pluralize(kebabCase(cleaned || "api"));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function canonicalizeAction(action: string): string {
|
|
114
|
+
const a = kebabCase(action);
|
|
115
|
+
|
|
116
|
+
// Common RPC verbs -> REST canonical verbs
|
|
117
|
+
if (a === "retrieve" || a === "read") return "get";
|
|
118
|
+
if (a === "list" || a === "search") return "list";
|
|
119
|
+
if (a === "create") return "create";
|
|
120
|
+
if (a === "update" || a === "patch") return "update";
|
|
121
|
+
if (a === "delete" || a === "remove") return "delete";
|
|
122
|
+
|
|
123
|
+
return a;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function inferRestAction(op: NormalizedOperation): string {
|
|
127
|
+
// If operationId is present and looks intentional, prefer it.
|
|
128
|
+
// This helps with singleton endpoints like GET /ping (Ping.Get) vs collections.
|
|
129
|
+
if (op.operationId) {
|
|
130
|
+
const { suffix } = splitOperationId(op.operationId);
|
|
131
|
+
if (suffix) {
|
|
132
|
+
const fromId = canonicalizeAction(suffix);
|
|
133
|
+
if (
|
|
134
|
+
fromId === "get" ||
|
|
135
|
+
fromId === "list" ||
|
|
136
|
+
fromId === "create" ||
|
|
137
|
+
fromId === "update" ||
|
|
138
|
+
fromId === "delete"
|
|
139
|
+
) {
|
|
140
|
+
return fromId;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const method = op.method.toUpperCase();
|
|
146
|
+
const args = getPathArgs(op.path);
|
|
147
|
+
const hasId = args.length > 0;
|
|
148
|
+
|
|
149
|
+
if (method === "GET" && !hasId) return "list";
|
|
150
|
+
if (method === "POST" && !hasId) return "create";
|
|
151
|
+
|
|
152
|
+
if (method === "GET" && hasId) return "get";
|
|
153
|
+
if ((method === "PUT" || method === "PATCH") && hasId) return "update";
|
|
154
|
+
if (method === "DELETE" && hasId) return "delete";
|
|
155
|
+
|
|
156
|
+
return kebabCase(method);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function inferRpcAction(op: NormalizedOperation): string {
|
|
160
|
+
// Prefer operationId suffix: Contacts.List -> list
|
|
161
|
+
if (op.operationId) {
|
|
162
|
+
const { suffix } = splitOperationId(op.operationId);
|
|
163
|
+
if (suffix) return canonicalizeAction(suffix);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Else take last segment and split by '.'
|
|
167
|
+
const segments = getPathSegments(op.path);
|
|
168
|
+
const last = segments[segments.length - 1] ?? "";
|
|
169
|
+
if (last.includes(".")) {
|
|
170
|
+
const part = last.split(".").pop() ?? last;
|
|
171
|
+
return canonicalizeAction(part);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return kebabCase(op.method);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function planOperation(op: NormalizedOperation): PlannedOperation {
|
|
178
|
+
const style = inferStyle(op);
|
|
179
|
+
const resource = inferResource(op);
|
|
180
|
+
const action = style === "rpc" ? inferRpcAction(op) : inferRestAction(op);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
...op,
|
|
184
|
+
key: op.key,
|
|
185
|
+
style,
|
|
186
|
+
resource,
|
|
187
|
+
action,
|
|
188
|
+
canonicalAction: action,
|
|
189
|
+
pathArgs: getPathArgs(op.path).map((a) => kebabCase(a)),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function planOperations(ops: NormalizedOperation[]): PlannedOperation[] {
|
|
194
|
+
const planned = ops.map(planOperation);
|
|
195
|
+
|
|
196
|
+
// Stable collision handling: if resource+action repeats, add a suffix.
|
|
197
|
+
const counts = new Map<string, number>();
|
|
198
|
+
for (const op of planned) {
|
|
199
|
+
const key = `${op.resource}:${op.action}`;
|
|
200
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const seen = new Map<string, number>();
|
|
204
|
+
return planned.map((op) => {
|
|
205
|
+
const key = `${op.resource}:${op.action}`;
|
|
206
|
+
const total = counts.get(key) ?? 0;
|
|
207
|
+
if (total <= 1) return op;
|
|
208
|
+
|
|
209
|
+
const idx = (seen.get(key) ?? 0) + 1;
|
|
210
|
+
seen.set(key, idx);
|
|
211
|
+
|
|
212
|
+
const suffix = op.operationId
|
|
213
|
+
? kebabCase(op.operationId)
|
|
214
|
+
: kebabCase(`${op.method}-${op.path}`);
|
|
215
|
+
|
|
216
|
+
const disambiguatedAction = `${op.action}-${suffix}-${idx}`;
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
...op,
|
|
220
|
+
action: disambiguatedAction,
|
|
221
|
+
aliasOf: `${op.resource} ${op.canonicalAction}`,
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { indexOperations } from "./operations.js";
|
|
4
|
+
import type { OpenApiDoc } from "./types.js";
|
|
5
|
+
|
|
6
|
+
describe("indexOperations", () => {
|
|
7
|
+
test("indexes basic operations", () => {
|
|
8
|
+
const doc: OpenApiDoc = {
|
|
9
|
+
openapi: "3.0.3",
|
|
10
|
+
paths: {
|
|
11
|
+
"/contacts": {
|
|
12
|
+
get: {
|
|
13
|
+
operationId: "Contacts.List",
|
|
14
|
+
tags: ["Contacts"],
|
|
15
|
+
parameters: [
|
|
16
|
+
{
|
|
17
|
+
in: "query",
|
|
18
|
+
name: "limit",
|
|
19
|
+
schema: { type: "integer" },
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
"/contacts/{id}": {
|
|
25
|
+
get: {
|
|
26
|
+
operationId: "Contacts.Get",
|
|
27
|
+
tags: ["Contacts"],
|
|
28
|
+
parameters: [
|
|
29
|
+
{
|
|
30
|
+
in: "path",
|
|
31
|
+
name: "id",
|
|
32
|
+
required: true,
|
|
33
|
+
schema: { type: "string" },
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const ops = indexOperations(doc);
|
|
42
|
+
expect(ops).toHaveLength(2);
|
|
43
|
+
|
|
44
|
+
expect(ops[0]?.key).toBe("GET /contacts");
|
|
45
|
+
expect(ops[0]?.path).toBe("/contacts");
|
|
46
|
+
expect(ops[0]?.method).toBe("GET");
|
|
47
|
+
expect(ops[0]?.parameters).toHaveLength(1);
|
|
48
|
+
expect(ops[0]?.parameters[0]?.in).toBe("query");
|
|
49
|
+
|
|
50
|
+
expect(ops[1]?.key).toBe("GET /contacts/{id}");
|
|
51
|
+
expect(ops[1]?.path).toBe("/contacts/{id}");
|
|
52
|
+
expect(ops[1]?.method).toBe("GET");
|
|
53
|
+
expect(ops[1]?.parameters).toHaveLength(1);
|
|
54
|
+
expect(ops[1]?.parameters[0]?.in).toBe("path");
|
|
55
|
+
expect(ops[1]?.parameters[0]?.required).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|