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,187 @@
|
|
|
1
|
+
import { kebabCase } from "./strings.ts";
|
|
2
|
+
import type { OpenApiDoc } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export type AuthSchemeKind =
|
|
5
|
+
| "http-bearer"
|
|
6
|
+
| "http-basic"
|
|
7
|
+
| "api-key"
|
|
8
|
+
| "oauth2"
|
|
9
|
+
| "openIdConnect"
|
|
10
|
+
| "unknown";
|
|
11
|
+
|
|
12
|
+
export type AuthScheme = {
|
|
13
|
+
key: string;
|
|
14
|
+
kind: AuthSchemeKind;
|
|
15
|
+
name?: string;
|
|
16
|
+
in?: "header" | "query" | "cookie";
|
|
17
|
+
scheme?: string;
|
|
18
|
+
bearerFormat?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
|
|
21
|
+
// oauth2/openid only (subset of spec, enough to derive flags + docs)
|
|
22
|
+
oauthFlows?: OAuthFlows;
|
|
23
|
+
openIdConnectUrl?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type OAuthFlow = {
|
|
27
|
+
authorizationUrl?: string;
|
|
28
|
+
tokenUrl?: string;
|
|
29
|
+
refreshUrl?: string;
|
|
30
|
+
scopes: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type OAuthFlows = Partial<
|
|
34
|
+
Record<
|
|
35
|
+
"implicit" | "password" | "clientCredentials" | "authorizationCode",
|
|
36
|
+
OAuthFlow
|
|
37
|
+
>
|
|
38
|
+
>;
|
|
39
|
+
|
|
40
|
+
type RawOAuthFlow = {
|
|
41
|
+
authorizationUrl?: unknown;
|
|
42
|
+
tokenUrl?: unknown;
|
|
43
|
+
refreshUrl?: unknown;
|
|
44
|
+
scopes?: unknown;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type RawOAuthFlows = {
|
|
48
|
+
implicit?: RawOAuthFlow;
|
|
49
|
+
password?: RawOAuthFlow;
|
|
50
|
+
clientCredentials?: RawOAuthFlow;
|
|
51
|
+
authorizationCode?: RawOAuthFlow;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type RawSecurityScheme = {
|
|
55
|
+
type?: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
name?: string;
|
|
58
|
+
in?: string;
|
|
59
|
+
scheme?: string;
|
|
60
|
+
bearerFormat?: string;
|
|
61
|
+
flows?: RawOAuthFlows;
|
|
62
|
+
openIdConnectUrl?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function parseOAuthFlow(flow: RawOAuthFlow | undefined): OAuthFlow | undefined {
|
|
66
|
+
if (!flow) return undefined;
|
|
67
|
+
const scopesObj = flow.scopes;
|
|
68
|
+
const scopes =
|
|
69
|
+
scopesObj && typeof scopesObj === "object" && !Array.isArray(scopesObj)
|
|
70
|
+
? Object.keys(scopesObj as Record<string, unknown>)
|
|
71
|
+
: [];
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
authorizationUrl:
|
|
75
|
+
typeof flow.authorizationUrl === "string"
|
|
76
|
+
? flow.authorizationUrl
|
|
77
|
+
: undefined,
|
|
78
|
+
tokenUrl: typeof flow.tokenUrl === "string" ? flow.tokenUrl : undefined,
|
|
79
|
+
refreshUrl:
|
|
80
|
+
typeof flow.refreshUrl === "string" ? flow.refreshUrl : undefined,
|
|
81
|
+
scopes: scopes.sort(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseOAuthFlows(
|
|
86
|
+
flows: RawOAuthFlows | undefined,
|
|
87
|
+
): OAuthFlows | undefined {
|
|
88
|
+
if (!flows) return undefined;
|
|
89
|
+
const out: OAuthFlows = {};
|
|
90
|
+
|
|
91
|
+
const implicit = parseOAuthFlow(flows.implicit);
|
|
92
|
+
if (implicit) out.implicit = implicit;
|
|
93
|
+
|
|
94
|
+
const password = parseOAuthFlow(flows.password);
|
|
95
|
+
if (password) out.password = password;
|
|
96
|
+
|
|
97
|
+
const clientCredentials = parseOAuthFlow(flows.clientCredentials);
|
|
98
|
+
if (clientCredentials) out.clientCredentials = clientCredentials;
|
|
99
|
+
|
|
100
|
+
const authorizationCode = parseOAuthFlow(flows.authorizationCode);
|
|
101
|
+
if (authorizationCode) out.authorizationCode = authorizationCode;
|
|
102
|
+
|
|
103
|
+
return Object.keys(out).length ? out : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function listAuthSchemes(doc: OpenApiDoc): AuthScheme[] {
|
|
107
|
+
const schemes = doc.components?.securitySchemes;
|
|
108
|
+
if (!schemes || typeof schemes !== "object") return [];
|
|
109
|
+
|
|
110
|
+
const out: AuthScheme[] = [];
|
|
111
|
+
|
|
112
|
+
for (const [key, raw] of Object.entries(schemes)) {
|
|
113
|
+
if (!raw || typeof raw !== "object") continue;
|
|
114
|
+
const s = raw as RawSecurityScheme;
|
|
115
|
+
|
|
116
|
+
const type = s.type;
|
|
117
|
+
if (type === "http") {
|
|
118
|
+
const scheme = (s.scheme ?? "").toLowerCase();
|
|
119
|
+
if (scheme === "bearer") {
|
|
120
|
+
out.push({
|
|
121
|
+
key,
|
|
122
|
+
kind: "http-bearer",
|
|
123
|
+
scheme: scheme,
|
|
124
|
+
bearerFormat: s.bearerFormat,
|
|
125
|
+
description: s.description,
|
|
126
|
+
});
|
|
127
|
+
} else if (scheme === "basic") {
|
|
128
|
+
out.push({
|
|
129
|
+
key,
|
|
130
|
+
kind: "http-basic",
|
|
131
|
+
scheme: scheme,
|
|
132
|
+
description: s.description,
|
|
133
|
+
});
|
|
134
|
+
} else {
|
|
135
|
+
out.push({
|
|
136
|
+
key,
|
|
137
|
+
kind: "unknown",
|
|
138
|
+
scheme: s.scheme,
|
|
139
|
+
description: s.description,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (type === "apiKey") {
|
|
146
|
+
const where = s.in;
|
|
147
|
+
const loc =
|
|
148
|
+
where === "header" || where === "query" || where === "cookie"
|
|
149
|
+
? where
|
|
150
|
+
: undefined;
|
|
151
|
+
out.push({
|
|
152
|
+
key,
|
|
153
|
+
kind: "api-key",
|
|
154
|
+
name: s.name,
|
|
155
|
+
in: loc,
|
|
156
|
+
description: s.description,
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (type === "oauth2") {
|
|
162
|
+
out.push({
|
|
163
|
+
key,
|
|
164
|
+
kind: "oauth2",
|
|
165
|
+
description: s.description,
|
|
166
|
+
oauthFlows: parseOAuthFlows(s.flows),
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (type === "openIdConnect") {
|
|
172
|
+
out.push({
|
|
173
|
+
key,
|
|
174
|
+
kind: "openIdConnect",
|
|
175
|
+
description: s.description,
|
|
176
|
+
openIdConnectUrl: s.openIdConnectUrl,
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
out.push({ key, kind: "unknown", description: s.description });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Stable order.
|
|
185
|
+
out.sort((a, b) => kebabCase(a.key).localeCompare(kebabCase(b.key)));
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AuthScheme } from "./auth-schemes.ts";
|
|
3
|
+
import { deriveCapabilities } from "./capabilities.ts";
|
|
4
|
+
import type { CommandModel } from "./command-model.ts";
|
|
5
|
+
import type { ServerInfo } from "./server.ts";
|
|
6
|
+
import type { NormalizedOperation, OpenApiDoc } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
describe("deriveCapabilities", () => {
|
|
9
|
+
test("reports requestBody + server vars", () => {
|
|
10
|
+
const doc: OpenApiDoc = {
|
|
11
|
+
openapi: "3.0.3",
|
|
12
|
+
security: [{ bearerAuth: [] }],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const servers: ServerInfo[] = [
|
|
16
|
+
{
|
|
17
|
+
url: "https://{region}.api.example.com",
|
|
18
|
+
variables: [],
|
|
19
|
+
variableNames: ["region"],
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const authSchemes: AuthScheme[] = [
|
|
24
|
+
{ key: "bearerAuth", kind: "http-bearer" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const operations: NormalizedOperation[] = [
|
|
28
|
+
{
|
|
29
|
+
key: "POST /contacts",
|
|
30
|
+
method: "POST",
|
|
31
|
+
path: "/contacts",
|
|
32
|
+
tags: [],
|
|
33
|
+
parameters: [],
|
|
34
|
+
requestBody: {
|
|
35
|
+
required: true,
|
|
36
|
+
contentTypes: ["application/json"],
|
|
37
|
+
schemasByContentType: { "application/json": { type: "object" } },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const commands: CommandModel = {
|
|
43
|
+
resources: [
|
|
44
|
+
{
|
|
45
|
+
resource: "contacts",
|
|
46
|
+
actions: [
|
|
47
|
+
{
|
|
48
|
+
id: "x",
|
|
49
|
+
key: "POST /contacts",
|
|
50
|
+
action: "create",
|
|
51
|
+
pathArgs: [],
|
|
52
|
+
method: "POST",
|
|
53
|
+
path: "/contacts",
|
|
54
|
+
tags: [],
|
|
55
|
+
style: "rest",
|
|
56
|
+
positionals: [],
|
|
57
|
+
flags: [],
|
|
58
|
+
params: [],
|
|
59
|
+
auth: { alternatives: [] },
|
|
60
|
+
requestBody: {
|
|
61
|
+
required: true,
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
contentType: "application/json",
|
|
65
|
+
required: true,
|
|
66
|
+
schemaType: "object",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
hasJson: true,
|
|
70
|
+
hasFormUrlEncoded: false,
|
|
71
|
+
hasMultipart: false,
|
|
72
|
+
bodyFlags: ["--data", "--file"],
|
|
73
|
+
preferredContentType: "application/json",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const caps = deriveCapabilities({
|
|
82
|
+
doc,
|
|
83
|
+
servers,
|
|
84
|
+
authSchemes,
|
|
85
|
+
operations,
|
|
86
|
+
commands,
|
|
87
|
+
});
|
|
88
|
+
expect(caps.servers.hasVariables).toBe(true);
|
|
89
|
+
expect(caps.operations.hasRequestBodies).toBe(true);
|
|
90
|
+
expect(caps.commands.hasRequestBodies).toBe(true);
|
|
91
|
+
expect(caps.auth.hasSecurityRequirements).toBe(true);
|
|
92
|
+
expect(caps.auth.kinds).toEqual(["http-bearer"]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { AuthScheme, AuthSchemeKind } from "./auth-schemes.ts";
|
|
2
|
+
import type { CommandModel } from "./command-model.ts";
|
|
3
|
+
import type { ServerInfo } from "./server.ts";
|
|
4
|
+
import type {
|
|
5
|
+
NormalizedOperation,
|
|
6
|
+
OpenApiDoc,
|
|
7
|
+
SecurityRequirement,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export type Capabilities = {
|
|
11
|
+
servers: {
|
|
12
|
+
count: number;
|
|
13
|
+
hasVariables: boolean;
|
|
14
|
+
};
|
|
15
|
+
auth: {
|
|
16
|
+
count: number;
|
|
17
|
+
kinds: AuthSchemeKind[];
|
|
18
|
+
hasSecurityRequirements: boolean;
|
|
19
|
+
};
|
|
20
|
+
operations: {
|
|
21
|
+
count: number;
|
|
22
|
+
hasRequestBodies: boolean;
|
|
23
|
+
};
|
|
24
|
+
commands: {
|
|
25
|
+
countResources: number;
|
|
26
|
+
countActions: number;
|
|
27
|
+
hasRequestBodies: boolean;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function uniqueSorted<T>(items: T[], compare: (a: T, b: T) => number): T[] {
|
|
32
|
+
const out = [...items];
|
|
33
|
+
out.sort(compare);
|
|
34
|
+
return out.filter((v, i) => i === 0 || compare(out[i - 1] as T, v) !== 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasSecurity(requirements: SecurityRequirement[] | undefined): boolean {
|
|
38
|
+
if (!requirements?.length) return false;
|
|
39
|
+
|
|
40
|
+
// Treat any non-empty array as "auth exists", even if it contains `{}` to mean optional.
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function deriveCapabilities(input: {
|
|
45
|
+
doc: OpenApiDoc;
|
|
46
|
+
servers: ServerInfo[];
|
|
47
|
+
authSchemes: AuthScheme[];
|
|
48
|
+
operations: NormalizedOperation[];
|
|
49
|
+
commands?: CommandModel;
|
|
50
|
+
}): Capabilities {
|
|
51
|
+
const serverHasVars = input.servers.some((s) => s.variableNames.length > 0);
|
|
52
|
+
|
|
53
|
+
const authKinds = uniqueSorted(
|
|
54
|
+
input.authSchemes.map((s) => s.kind),
|
|
55
|
+
(a, b) => a.localeCompare(b),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const hasSecurityRequirements =
|
|
59
|
+
hasSecurity(input.doc.security) ||
|
|
60
|
+
input.operations.some((op) => hasSecurity(op.security));
|
|
61
|
+
|
|
62
|
+
const opHasBodies = input.operations.some((op) => Boolean(op.requestBody));
|
|
63
|
+
|
|
64
|
+
const cmdResources = input.commands?.resources ?? [];
|
|
65
|
+
const cmdActions = cmdResources.flatMap((r) => r.actions);
|
|
66
|
+
const cmdHasBodies = cmdActions.some((a) => Boolean(a.requestBody));
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
servers: {
|
|
70
|
+
count: input.servers.length,
|
|
71
|
+
hasVariables: serverHasVars,
|
|
72
|
+
},
|
|
73
|
+
auth: {
|
|
74
|
+
count: input.authSchemes.length,
|
|
75
|
+
kinds: authKinds,
|
|
76
|
+
hasSecurityRequirements,
|
|
77
|
+
},
|
|
78
|
+
operations: {
|
|
79
|
+
count: input.operations.length,
|
|
80
|
+
hasRequestBodies: opHasBodies,
|
|
81
|
+
},
|
|
82
|
+
commands: {
|
|
83
|
+
countResources: cmdResources.length,
|
|
84
|
+
countActions: cmdActions.length,
|
|
85
|
+
hasRequestBodies: cmdHasBodies,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { buildCommandId } from "./command-id.ts";
|
|
4
|
+
|
|
5
|
+
describe("buildCommandId", () => {
|
|
6
|
+
test("includes spec/resource/action/op", () => {
|
|
7
|
+
expect(
|
|
8
|
+
buildCommandId({
|
|
9
|
+
specId: "contacts-api",
|
|
10
|
+
resource: "contacts",
|
|
11
|
+
action: "get",
|
|
12
|
+
operationKey: "GET /contacts/{id}",
|
|
13
|
+
}),
|
|
14
|
+
).toBe("contacts-api:contacts:get:get-contacts-id");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("disambiguates by operationKey", () => {
|
|
18
|
+
const a = buildCommandId({
|
|
19
|
+
specId: "x",
|
|
20
|
+
resource: "contacts",
|
|
21
|
+
action: "list",
|
|
22
|
+
operationKey: "GET /contacts",
|
|
23
|
+
});
|
|
24
|
+
const b = buildCommandId({
|
|
25
|
+
specId: "x",
|
|
26
|
+
resource: "contacts",
|
|
27
|
+
action: "list",
|
|
28
|
+
operationKey: "GET /contacts/search",
|
|
29
|
+
});
|
|
30
|
+
expect(a).not.toBe(b);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { kebabCase } from "./strings.ts";
|
|
2
|
+
|
|
3
|
+
export type CommandIdParts = {
|
|
4
|
+
specId: string;
|
|
5
|
+
resource: string;
|
|
6
|
+
action: string;
|
|
7
|
+
operationKey: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function buildCommandId(parts: CommandIdParts): string {
|
|
11
|
+
// operationKey is the ultimate disambiguator, but we keep the id readable.
|
|
12
|
+
// Example:
|
|
13
|
+
// contacts-api:contacts:get:GET-/contacts/{id}
|
|
14
|
+
const op = kebabCase(parts.operationKey.replace(/\s+/g, "-"));
|
|
15
|
+
return `${parts.specId}:${kebabCase(parts.resource)}:${kebabCase(parts.action)}:${op}`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CommandAction, CommandModel } from "./command-model.ts";
|
|
2
|
+
|
|
3
|
+
export type CommandsIndex = {
|
|
4
|
+
byId: Record<string, CommandAction>;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function buildCommandsIndex(
|
|
8
|
+
commands: CommandModel | undefined,
|
|
9
|
+
): CommandsIndex {
|
|
10
|
+
const byId: Record<string, CommandAction> = {};
|
|
11
|
+
|
|
12
|
+
for (const resource of commands?.resources ?? []) {
|
|
13
|
+
for (const action of resource.actions) {
|
|
14
|
+
byId[action.id] = action;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { byId };
|
|
19
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { buildCommandModel } from "./command-model.ts";
|
|
4
|
+
import type { PlannedOperation } from "./naming.ts";
|
|
5
|
+
|
|
6
|
+
describe("buildCommandModel", () => {
|
|
7
|
+
test("groups operations by resource", () => {
|
|
8
|
+
const planned: PlannedOperation[] = [
|
|
9
|
+
{
|
|
10
|
+
key: "GET /contacts",
|
|
11
|
+
method: "GET",
|
|
12
|
+
path: "/contacts",
|
|
13
|
+
tags: ["Contacts"],
|
|
14
|
+
parameters: [],
|
|
15
|
+
resource: "contacts",
|
|
16
|
+
action: "list",
|
|
17
|
+
canonicalAction: "list",
|
|
18
|
+
pathArgs: [],
|
|
19
|
+
style: "rest",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
key: "GET /contacts/{id}",
|
|
23
|
+
method: "GET",
|
|
24
|
+
path: "/contacts/{id}",
|
|
25
|
+
tags: ["Contacts"],
|
|
26
|
+
parameters: [],
|
|
27
|
+
resource: "contacts",
|
|
28
|
+
action: "get",
|
|
29
|
+
canonicalAction: "get",
|
|
30
|
+
pathArgs: ["id"],
|
|
31
|
+
style: "rest",
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const model = buildCommandModel(planned, { specId: "contacts-api" });
|
|
36
|
+
expect(model.resources).toHaveLength(1);
|
|
37
|
+
expect(model.resources[0]?.resource).toBe("contacts");
|
|
38
|
+
expect(model.resources[0]?.actions).toHaveLength(2);
|
|
39
|
+
expect(model.resources[0]?.actions.map((a) => a.action)).toEqual([
|
|
40
|
+
"get",
|
|
41
|
+
"list",
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AuthSummary } from "./auth-requirements.ts";
|
|
2
|
+
import { summarizeAuth } from "./auth-requirements.ts";
|
|
3
|
+
import type { AuthScheme } from "./auth-schemes.ts";
|
|
4
|
+
import { buildCommandId } from "./command-id.ts";
|
|
5
|
+
import type { PlannedOperation } from "./naming.ts";
|
|
6
|
+
import type { ParamSpec } from "./params.ts";
|
|
7
|
+
import { deriveParamSpecs } from "./params.ts";
|
|
8
|
+
import { deriveFlags, derivePositionals } from "./positional.ts";
|
|
9
|
+
import type { RequestBodyInfo } from "./request-body.ts";
|
|
10
|
+
import { deriveRequestBodyInfo } from "./request-body.ts";
|
|
11
|
+
import type { SecurityRequirement } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export type CommandAction = {
|
|
14
|
+
id: string;
|
|
15
|
+
key: string;
|
|
16
|
+
action: string;
|
|
17
|
+
// Derived path arguments. These become positionals later.
|
|
18
|
+
pathArgs: string[];
|
|
19
|
+
method: string;
|
|
20
|
+
path: string;
|
|
21
|
+
operationId?: string;
|
|
22
|
+
tags: string[];
|
|
23
|
+
summary?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
deprecated?: boolean;
|
|
26
|
+
style: PlannedOperation["style"];
|
|
27
|
+
|
|
28
|
+
// Derived CLI shape (Phase 1 output; Phase 2 will wire these into commander)
|
|
29
|
+
positionals: Array<import("./positional.ts").PositionalArg>;
|
|
30
|
+
flags: Array<
|
|
31
|
+
Pick<
|
|
32
|
+
import("./params.ts").ParamSpec,
|
|
33
|
+
| "in"
|
|
34
|
+
| "name"
|
|
35
|
+
| "flag"
|
|
36
|
+
| "required"
|
|
37
|
+
| "description"
|
|
38
|
+
| "type"
|
|
39
|
+
| "format"
|
|
40
|
+
| "enum"
|
|
41
|
+
| "itemType"
|
|
42
|
+
| "itemFormat"
|
|
43
|
+
| "itemEnum"
|
|
44
|
+
>
|
|
45
|
+
>;
|
|
46
|
+
|
|
47
|
+
// Full raw params list (useful for debugging and future features)
|
|
48
|
+
params: ParamSpec[];
|
|
49
|
+
|
|
50
|
+
auth: AuthSummary;
|
|
51
|
+
requestBody?: RequestBodyInfo;
|
|
52
|
+
requestBodySchema?: import("./types.ts").JsonSchema;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type CommandResource = {
|
|
56
|
+
resource: string;
|
|
57
|
+
actions: CommandAction[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type CommandModel = {
|
|
61
|
+
resources: CommandResource[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type BuildCommandModelOptions = {
|
|
65
|
+
specId: string;
|
|
66
|
+
globalSecurity?: SecurityRequirement[];
|
|
67
|
+
authSchemes?: AuthScheme[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function buildCommandModel(
|
|
71
|
+
planned: PlannedOperation[],
|
|
72
|
+
options: BuildCommandModelOptions,
|
|
73
|
+
): CommandModel {
|
|
74
|
+
const byResource = new Map<string, CommandAction[]>();
|
|
75
|
+
|
|
76
|
+
for (const op of planned) {
|
|
77
|
+
const list = byResource.get(op.resource) ?? [];
|
|
78
|
+
const params = deriveParamSpecs(op);
|
|
79
|
+
const positionals = derivePositionals({ pathArgs: op.pathArgs, params });
|
|
80
|
+
const flags = deriveFlags({ pathArgs: op.pathArgs, params });
|
|
81
|
+
|
|
82
|
+
list.push({
|
|
83
|
+
id: buildCommandId({
|
|
84
|
+
specId: options.specId,
|
|
85
|
+
resource: op.resource,
|
|
86
|
+
action: op.action,
|
|
87
|
+
operationKey: op.key,
|
|
88
|
+
}),
|
|
89
|
+
key: op.key,
|
|
90
|
+
action: op.action,
|
|
91
|
+
pathArgs: op.pathArgs,
|
|
92
|
+
method: op.method,
|
|
93
|
+
path: op.path,
|
|
94
|
+
operationId: op.operationId,
|
|
95
|
+
tags: op.tags,
|
|
96
|
+
summary: op.summary,
|
|
97
|
+
description: op.description,
|
|
98
|
+
deprecated: op.deprecated,
|
|
99
|
+
style: op.style,
|
|
100
|
+
params,
|
|
101
|
+
positionals,
|
|
102
|
+
flags: flags.flags,
|
|
103
|
+
auth: summarizeAuth(
|
|
104
|
+
op.security,
|
|
105
|
+
options.globalSecurity,
|
|
106
|
+
options.authSchemes ?? [],
|
|
107
|
+
),
|
|
108
|
+
requestBody: deriveRequestBodyInfo(op),
|
|
109
|
+
requestBodySchema: deriveRequestBodyInfo(op)?.preferredSchema,
|
|
110
|
+
});
|
|
111
|
+
byResource.set(op.resource, list);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const resources: CommandResource[] = [];
|
|
115
|
+
|
|
116
|
+
for (const [resource, actions] of byResource.entries()) {
|
|
117
|
+
actions.sort((a, b) => {
|
|
118
|
+
if (a.action !== b.action) return a.action.localeCompare(b.action);
|
|
119
|
+
if (a.path !== b.path) return a.path.localeCompare(b.path);
|
|
120
|
+
return a.method.localeCompare(b.method);
|
|
121
|
+
});
|
|
122
|
+
resources.push({ resource, actions });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
resources.sort((a, b) => a.resource.localeCompare(b.resource));
|
|
126
|
+
|
|
127
|
+
return { resources };
|
|
128
|
+
}
|