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,86 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { planOperation } from "./naming.ts";
|
|
3
|
+
import type { NormalizedOperation } from "./types.ts";
|
|
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.ts";
|
|
2
|
+
import { kebabCase } from "./strings.ts";
|
|
3
|
+
import type { NormalizedOperation } from "./types.ts";
|
|
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.ts";
|
|
4
|
+
import type { OpenApiDoc } from "./types.ts";
|
|
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
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NormalizedOperation,
|
|
3
|
+
NormalizedParameter,
|
|
4
|
+
NormalizedRequestBody,
|
|
5
|
+
OpenApiDoc,
|
|
6
|
+
} from "./types.ts";
|
|
7
|
+
|
|
8
|
+
function operationKey(method: string, path: string): string {
|
|
9
|
+
return `${method.toUpperCase()} ${path}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const HTTP_METHODS = [
|
|
13
|
+
"get",
|
|
14
|
+
"post",
|
|
15
|
+
"put",
|
|
16
|
+
"patch",
|
|
17
|
+
"delete",
|
|
18
|
+
"options",
|
|
19
|
+
"head",
|
|
20
|
+
"trace",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
type RawParameter = {
|
|
24
|
+
in?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
required?: boolean;
|
|
27
|
+
description?: string;
|
|
28
|
+
schema?: unknown;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type RawRequestBody = {
|
|
32
|
+
required?: boolean;
|
|
33
|
+
content?: Record<string, { schema?: unknown } | undefined>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type RawOperation = {
|
|
37
|
+
operationId?: string;
|
|
38
|
+
tags?: string[];
|
|
39
|
+
summary?: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
deprecated?: boolean;
|
|
42
|
+
security?: OpenApiDoc["security"];
|
|
43
|
+
parameters?: RawParameter[];
|
|
44
|
+
requestBody?: RawRequestBody;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type RawPathItem = {
|
|
48
|
+
parameters?: RawParameter[];
|
|
49
|
+
} & Partial<Record<(typeof HTTP_METHODS)[number], RawOperation>>;
|
|
50
|
+
|
|
51
|
+
function normalizeParam(p: RawParameter): NormalizedParameter | undefined {
|
|
52
|
+
if (!p || typeof p !== "object") return undefined;
|
|
53
|
+
const loc = p.in;
|
|
54
|
+
const name = p.name;
|
|
55
|
+
if (
|
|
56
|
+
loc !== "path" &&
|
|
57
|
+
loc !== "query" &&
|
|
58
|
+
loc !== "header" &&
|
|
59
|
+
loc !== "cookie"
|
|
60
|
+
) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
if (!name) return undefined;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
in: loc,
|
|
67
|
+
name,
|
|
68
|
+
required: Boolean(p.required || loc === "path"),
|
|
69
|
+
description: p.description,
|
|
70
|
+
schema: p.schema,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mergeParameters(
|
|
75
|
+
pathParams: RawParameter[] | undefined,
|
|
76
|
+
opParams: RawParameter[] | undefined,
|
|
77
|
+
): NormalizedParameter[] {
|
|
78
|
+
const merged = new Map<string, NormalizedParameter>();
|
|
79
|
+
|
|
80
|
+
for (const p of pathParams ?? []) {
|
|
81
|
+
const normalized = normalizeParam(p);
|
|
82
|
+
if (!normalized) continue;
|
|
83
|
+
merged.set(`${normalized.in}:${normalized.name}`, normalized);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const p of opParams ?? []) {
|
|
87
|
+
const normalized = normalizeParam(p);
|
|
88
|
+
if (!normalized) continue;
|
|
89
|
+
merged.set(`${normalized.in}:${normalized.name}`, normalized);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [...merged.values()];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeRequestBody(
|
|
96
|
+
rb: RawRequestBody | undefined,
|
|
97
|
+
): NormalizedRequestBody | undefined {
|
|
98
|
+
if (!rb) return undefined;
|
|
99
|
+
|
|
100
|
+
const content = rb.content ?? {};
|
|
101
|
+
const contentTypes = Object.keys(content);
|
|
102
|
+
const schemasByContentType: Record<string, unknown | undefined> = {};
|
|
103
|
+
|
|
104
|
+
for (const contentType of contentTypes) {
|
|
105
|
+
schemasByContentType[contentType] = content[contentType]?.schema;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
required: Boolean(rb.required),
|
|
110
|
+
contentTypes,
|
|
111
|
+
schemasByContentType,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function indexOperations(doc: OpenApiDoc): NormalizedOperation[] {
|
|
116
|
+
const out: NormalizedOperation[] = [];
|
|
117
|
+
const paths = doc.paths ?? {};
|
|
118
|
+
|
|
119
|
+
for (const [path, rawPathItem] of Object.entries(paths)) {
|
|
120
|
+
if (!rawPathItem || typeof rawPathItem !== "object") continue;
|
|
121
|
+
|
|
122
|
+
const pathItem = rawPathItem as RawPathItem;
|
|
123
|
+
|
|
124
|
+
for (const method of HTTP_METHODS) {
|
|
125
|
+
const op = pathItem[method];
|
|
126
|
+
if (!op) continue;
|
|
127
|
+
|
|
128
|
+
const parameters = mergeParameters(pathItem.parameters, op.parameters);
|
|
129
|
+
const normalizedMethod = method.toUpperCase();
|
|
130
|
+
out.push({
|
|
131
|
+
key: operationKey(normalizedMethod, path),
|
|
132
|
+
method: normalizedMethod,
|
|
133
|
+
path,
|
|
134
|
+
operationId: op.operationId,
|
|
135
|
+
tags: op.tags ?? [],
|
|
136
|
+
summary: op.summary,
|
|
137
|
+
description: op.description,
|
|
138
|
+
deprecated: op.deprecated,
|
|
139
|
+
security: (op.security ?? doc.security) as OpenApiDoc["security"],
|
|
140
|
+
parameters,
|
|
141
|
+
requestBody: normalizeRequestBody(op.requestBody),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
out.sort((a, b) => {
|
|
147
|
+
if (a.path !== b.path) return a.path.localeCompare(b.path);
|
|
148
|
+
return a.method.localeCompare(b.method);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { deriveParamSpecs } from "./params.ts";
|
|
4
|
+
import type { NormalizedOperation } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
describe("deriveParamSpecs", () => {
|
|
7
|
+
test("derives basic types + flags", () => {
|
|
8
|
+
const op: NormalizedOperation = {
|
|
9
|
+
key: "GET /contacts",
|
|
10
|
+
method: "GET",
|
|
11
|
+
path: "/contacts",
|
|
12
|
+
tags: [],
|
|
13
|
+
parameters: [
|
|
14
|
+
{
|
|
15
|
+
in: "query",
|
|
16
|
+
name: "limit",
|
|
17
|
+
required: false,
|
|
18
|
+
schema: {
|
|
19
|
+
type: "integer",
|
|
20
|
+
format: "int32",
|
|
21
|
+
enum: ["1", "2"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
in: "header",
|
|
26
|
+
name: "X-Request-Id",
|
|
27
|
+
required: false,
|
|
28
|
+
schema: { type: "string" },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const specs = deriveParamSpecs(op);
|
|
34
|
+
expect(specs).toHaveLength(2);
|
|
35
|
+
|
|
36
|
+
const limit = specs.find((p) => p.name === "limit");
|
|
37
|
+
expect(limit?.kind).toBe("flag");
|
|
38
|
+
expect(limit?.flag).toBe("--limit");
|
|
39
|
+
expect(limit?.type).toBe("integer");
|
|
40
|
+
expect(limit?.format).toBe("int32");
|
|
41
|
+
expect(limit?.enum).toEqual(["1", "2"]);
|
|
42
|
+
|
|
43
|
+
const reqId = specs.find((p) => p.name === "X-Request-Id");
|
|
44
|
+
expect(reqId?.kind).toBe("flag");
|
|
45
|
+
expect(reqId?.flag).toBe("--x-request-id");
|
|
46
|
+
expect(reqId?.type).toBe("string");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("derives array item types", () => {
|
|
50
|
+
const op: NormalizedOperation = {
|
|
51
|
+
key: "GET /things",
|
|
52
|
+
method: "GET",
|
|
53
|
+
path: "/things",
|
|
54
|
+
tags: [],
|
|
55
|
+
parameters: [
|
|
56
|
+
{
|
|
57
|
+
in: "query",
|
|
58
|
+
name: "ids",
|
|
59
|
+
required: false,
|
|
60
|
+
schema: { type: "array", items: { type: "integer" } },
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const specs = deriveParamSpecs(op);
|
|
66
|
+
expect(specs).toHaveLength(1);
|
|
67
|
+
expect(specs[0]?.type).toBe("array");
|
|
68
|
+
expect(specs[0]?.itemType).toBe("integer");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSchemaEnumStrings,
|
|
3
|
+
getSchemaFormat,
|
|
4
|
+
getSchemaType,
|
|
5
|
+
} from "./schema-shape.ts";
|
|
6
|
+
import { kebabCase } from "./strings.ts";
|
|
7
|
+
import type { NormalizedOperation, NormalizedParameter } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export type ParamType = import("./schema-shape.ts").ParamType;
|
|
10
|
+
|
|
11
|
+
export type ParamSpec = {
|
|
12
|
+
kind: "positional" | "flag";
|
|
13
|
+
in: NormalizedParameter["in"];
|
|
14
|
+
name: string;
|
|
15
|
+
flag: string;
|
|
16
|
+
required: boolean;
|
|
17
|
+
description?: string;
|
|
18
|
+
type: ParamType;
|
|
19
|
+
format?: string;
|
|
20
|
+
enum?: string[];
|
|
21
|
+
|
|
22
|
+
// Arrays
|
|
23
|
+
itemType?: ParamType;
|
|
24
|
+
itemFormat?: string;
|
|
25
|
+
itemEnum?: string[];
|
|
26
|
+
|
|
27
|
+
// Original schema for Ajv validation and future advanced flag expansion.
|
|
28
|
+
schema?: import("./types.ts").JsonSchema;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function deriveParamSpecs(op: NormalizedOperation): ParamSpec[] {
|
|
32
|
+
const out: ParamSpec[] = [];
|
|
33
|
+
|
|
34
|
+
for (const p of op.parameters) {
|
|
35
|
+
const flag = `--${kebabCase(p.name)}`;
|
|
36
|
+
const type = getSchemaType(p.schema);
|
|
37
|
+
const schemaObj =
|
|
38
|
+
p.schema && typeof p.schema === "object"
|
|
39
|
+
? (p.schema as import("./types.ts").JsonSchema)
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
42
|
+
const itemsSchema =
|
|
43
|
+
schemaObj && type === "array" && typeof schemaObj.items === "object"
|
|
44
|
+
? (schemaObj.items as unknown)
|
|
45
|
+
: undefined;
|
|
46
|
+
|
|
47
|
+
out.push({
|
|
48
|
+
kind: p.in === "path" ? "positional" : "flag",
|
|
49
|
+
in: p.in,
|
|
50
|
+
name: p.name,
|
|
51
|
+
flag,
|
|
52
|
+
required: p.required,
|
|
53
|
+
description: p.description,
|
|
54
|
+
type,
|
|
55
|
+
format: getSchemaFormat(p.schema),
|
|
56
|
+
enum: getSchemaEnumStrings(p.schema),
|
|
57
|
+
itemType: type === "array" ? getSchemaType(itemsSchema) : undefined,
|
|
58
|
+
itemFormat: type === "array" ? getSchemaFormat(itemsSchema) : undefined,
|
|
59
|
+
itemEnum:
|
|
60
|
+
type === "array" ? getSchemaEnumStrings(itemsSchema) : undefined,
|
|
61
|
+
schema: schemaObj,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
out.sort((a, b) => {
|
|
66
|
+
if (a.in !== b.in) return a.in.localeCompare(b.in);
|
|
67
|
+
return a.name.localeCompare(b.name);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const IRREGULAR: Record<string, string> = {
|
|
2
|
+
person: "people",
|
|
3
|
+
man: "men",
|
|
4
|
+
woman: "women",
|
|
5
|
+
child: "children",
|
|
6
|
+
tooth: "teeth",
|
|
7
|
+
foot: "feet",
|
|
8
|
+
mouse: "mice",
|
|
9
|
+
goose: "geese",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const UNCOUNTABLE = new Set([
|
|
13
|
+
"metadata",
|
|
14
|
+
"information",
|
|
15
|
+
"equipment",
|
|
16
|
+
"money",
|
|
17
|
+
"series",
|
|
18
|
+
"species",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function pluralize(word: string): string {
|
|
22
|
+
const w = word.trim();
|
|
23
|
+
if (!w) return w;
|
|
24
|
+
|
|
25
|
+
const lower = w.toLowerCase();
|
|
26
|
+
if (UNCOUNTABLE.has(lower)) return lower;
|
|
27
|
+
if (IRREGULAR[lower]) return IRREGULAR[lower];
|
|
28
|
+
|
|
29
|
+
// already plural-ish
|
|
30
|
+
if (lower.endsWith("s")) return lower;
|
|
31
|
+
|
|
32
|
+
if (/[bcdfghjklmnpqrstvwxyz]y$/.test(lower)) {
|
|
33
|
+
return lower.replace(/y$/, "ies");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (/(ch|sh|x|z)$/.test(lower)) {
|
|
37
|
+
return `${lower}es`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `${lower}s`;
|
|
41
|
+
}
|