@faable/deploy-sdk 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ // Generates `src/api/generated-client.ts` + `src/api/api-types.ts` from the
2
+ // OpenAPI spec. Ported from auth-sdk's generator (kept in sync by hand).
3
+ //
4
+ // Why: the SDK's typed methods (appGet, deploymentList, …) used to be
5
+ // hand-written and always lagged the API. The TYPES are already generated by
6
+ // openapi-typescript; this does the same for the METHODS, so the SDK covers the
7
+ // whole deploy API for free and stays in sync on every build.
8
+ //
9
+ // Scope: every secured operation (all of them carry the same
10
+ // apikey/faable_cli/faable_machine/faable_user scheme), MINUS internal
11
+ // resources in EXCLUDE_RESOURCES and the unsecured github/webhook/oidc
12
+ // endpoints. Reads the spec from a LOCAL file (downloaded by fetch-spec.mjs) so
13
+ // types.ts and this client are generated from the exact same snapshot.
14
+ //
15
+ // Run via `npm run gentypes`. Output is committed and reviewed in diff like
16
+ // `types.ts`. Do not edit the output by hand.
17
+
18
+ import { readFileSync, writeFileSync } from "node:fs";
19
+ import { fileURLToPath } from "node:url";
20
+ import { dirname, resolve } from "node:path";
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const ROOT = resolve(__dirname, "..");
24
+
25
+ const SPEC = process.env.DEPLOY_OPENAPI || resolve(ROOT, "spec/openapi.json");
26
+ const OUT = resolve(ROOT, "src/api/generated-client.ts");
27
+ const OUT_TYPES = resolve(ROOT, "src/api/api-types.ts");
28
+
29
+ const spec = JSON.parse(readFileSync(SPEC, "utf8"));
30
+
31
+ // operationId (`resource/action`, possibly snake/kebab in the action) →
32
+ // camelCase. `app/list`→appList, `app/get_deploy_workflow`→appGetDeployWorkflow,
33
+ // `secrets/list_app`→secretsListApp.
34
+ const toMethodName = (operationId) =>
35
+ operationId
36
+ .split(/[/_-]/)
37
+ .filter(Boolean)
38
+ .map((seg, i) =>
39
+ i === 0
40
+ ? seg[0].toLowerCase() + seg.slice(1)
41
+ : seg[0].toUpperCase() + seg.slice(1),
42
+ )
43
+ .join("");
44
+
45
+ // `/app/{id}/traffic` → ["id"], in order of appearance.
46
+ const pathParamNames = (path) =>
47
+ [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
48
+
49
+ // `/app/{id}` → "/app/${id}" (template-literal body).
50
+ const toUrlTemplate = (path) =>
51
+ path.replace(/\{([^}]+)\}/g, (_, name) => "${" + name + "}");
52
+
53
+ // Include every operation that requires auth (all deploy ops share one scheme)
54
+ // and isn't marked internal. Two exclusion layers:
55
+ // 1. no `security` → infra (github setup/webhook, oidc token exchange)
56
+ // 2. `x-internal: true` → explicitly marked private in the API
57
+ const isIncluded = (op) => {
58
+ if (!Array.isArray(op.security) || op.security.length === 0) return false;
59
+ return op["x-internal"] !== true;
60
+ };
61
+
62
+ const successSchema = (op) => {
63
+ const responses = op.responses || {};
64
+ const res = responses["200"] || responses["201"];
65
+ return res?.content?.["application/json"]?.schema;
66
+ };
67
+
68
+ // A paginated list response is `{ next, results }` (see buildPaginator in
69
+ // sdk-base). Detected structurally so it tracks the server, not the op name.
70
+ const isPaginated = (op) => {
71
+ const schema = successSchema(op);
72
+ const req = schema?.required;
73
+ return Array.isArray(req) && req.includes("next") && req.includes("results");
74
+ };
75
+
76
+ const escape = (s) => (s || "").replace(/\*\//g, "*\\/").replace(/\r?\n/g, " ");
77
+
78
+ // Collect + sort operations by operationId for a stable, reviewable diff.
79
+ const operations = [];
80
+ for (const [path, methods] of Object.entries(spec.paths || {})) {
81
+ for (const [httpMethod, op] of Object.entries(methods)) {
82
+ if (!op || typeof op !== "object" || !op.operationId) continue;
83
+ if (!["get", "post", "delete"].includes(httpMethod)) continue;
84
+ if (!isIncluded(op)) continue;
85
+ operations.push({ path, httpMethod, op });
86
+ }
87
+ }
88
+ operations.sort((a, b) => a.op.operationId.localeCompare(b.op.operationId));
89
+
90
+ const emitMethod = ({ path, httpMethod, op }) => {
91
+ const id = op.operationId;
92
+ const name = toMethodName(id);
93
+ const pathParams = pathParamNames(path);
94
+ const queryParams = (op.parameters || []).filter((p) => p.in === "query");
95
+ const hasBody = !!op.requestBody;
96
+ const paginated = isPaginated(op);
97
+ const url = "`" + toUrlTemplate(path) + "`";
98
+
99
+ const hasQuery = queryParams.length > 0;
100
+ const args = pathParams.map((p) => `${p}: string`);
101
+ if (hasBody) args.push(`data: OpBody<"${id}">`);
102
+
103
+ // fetcher.post takes a strict FetcherConfig (string-only params), so a POST
104
+ // carrying query params needs extra handling. None exist today; fail loud if
105
+ // that changes rather than silently dropping the query.
106
+ if (httpMethod === "post" && hasQuery) {
107
+ throw new Error(
108
+ `gen-client: POST with query params not supported yet (${id}). ` +
109
+ "Extend emitMethod to thread query params through fetcher.post's config.",
110
+ );
111
+ }
112
+
113
+ let body;
114
+ if (paginated) {
115
+ args.push(`params?: Omit<OpQuery<"${id}">, "cursor" | "next">`);
116
+ // The paginator's request params are untyped (`any`), so array/number/enum
117
+ // query values pass straight through.
118
+ body = `return this.paginator<OpItem<"${id}">>({ url: ${url}, params });`;
119
+ } else if (httpMethod === "post") {
120
+ // fetcher.post rejects a falsy body ("empty body"), so bodyless POSTs send
121
+ // an empty object.
122
+ const data = hasBody ? "data" : "{}";
123
+ body = `return this.fetcher.post<OpResult<"${id}">>(${url}, ${data});`;
124
+ } else if (hasQuery) {
125
+ // GET/DELETE with query params route through fetcher.request: its `params`
126
+ // is untyped, sidestepping FetcherConfig's string-only constraint, and the
127
+ // GET path still goes through the ETag cache.
128
+ args.push(`params?: OpQuery<"${id}">`);
129
+ const method = httpMethod.toUpperCase();
130
+ body = `return this.fetcher.request<OpResult<"${id}">>({ method: "${method}", url: ${url}, params });`;
131
+ } else if (httpMethod === "delete") {
132
+ body = `return this.fetcher.delete<OpResult<"${id}">>(${url});`;
133
+ } else {
134
+ body = `return this.fetcher.get<OpResult<"${id}">>(${url});`;
135
+ }
136
+
137
+ const guards = pathParams.map((p) => ` requireId("${p}", ${p});`);
138
+ const doc = op.summary || op.description;
139
+ const jsdoc = [
140
+ " /**",
141
+ ` * \`${httpMethod.toUpperCase()} ${path}\` — operationId: \`${id}\``,
142
+ ...(doc ? [` *`, ` * ${escape(doc)}`] : []),
143
+ " */",
144
+ ].join("\n");
145
+
146
+ return [
147
+ jsdoc,
148
+ ` ${name}(${args.join(", ")}) {`,
149
+ ...guards,
150
+ ` ${body}`,
151
+ " }",
152
+ ].join("\n");
153
+ };
154
+
155
+ const header = `// AUTO-GENERATED by scripts/gen-client.mjs — do not edit by hand.
156
+ // Regenerated from the OpenAPI spec on every build (npm run gentypes).
157
+ // Source of truth: the deploy API's operationIds + security.
158
+
159
+ import type { operations } from "./types.js";
160
+ import { FaableApi } from "@faable/sdk-base";
161
+ import { requireId } from "../helpers.js";
162
+
163
+ // JSON body of an operation's request (typed from the generated \`operations\`).
164
+ type OpBody<K extends keyof operations> = operations[K] extends {
165
+ requestBody: { content: { "application/json": infer B } };
166
+ }
167
+ ? B
168
+ : never;
169
+
170
+ // JSON body of an operation's 2xx response.
171
+ type Content2xx<R> = R extends {
172
+ 200: { content: { "application/json": infer T } };
173
+ }
174
+ ? T
175
+ : R extends { 201: { content: { "application/json": infer T } } }
176
+ ? T
177
+ : void;
178
+
179
+ type OpResult<K extends keyof operations> = operations[K] extends {
180
+ responses: infer R;
181
+ }
182
+ ? Content2xx<R>
183
+ : void;
184
+
185
+ // Item type of a paginated (\`{ next, results }\`) list response.
186
+ type OpItem<K extends keyof operations> =
187
+ OpResult<K> extends { results: (infer I)[] } ? I : never;
188
+
189
+ // Query parameters of an operation.
190
+ type OpQuery<K extends keyof operations> = operations[K] extends {
191
+ parameters: { query?: infer Q };
192
+ }
193
+ ? NonNullable<Q>
194
+ : Record<string, never>;
195
+ `;
196
+
197
+ const classBody = operations.map(emitMethod).join("\n\n");
198
+
199
+ const out = `${header}
200
+ /**
201
+ * Auto-generated deploy-API methods, one per secured operation in the OpenAPI
202
+ * spec. \`DeployApi\` extends this and adds the constructor, custom-logic
203
+ * helpers, and deprecated aliases.
204
+ */
205
+ export abstract class GeneratedFaableDeployApi extends FaableApi {
206
+ ${classBody}
207
+ }
208
+ `;
209
+
210
+ writeFileSync(OUT, out);
211
+ console.warn(
212
+ `gen-client: wrote ${operations.length} methods → ${OUT.replace(ROOT + "/", "")}`,
213
+ );
214
+
215
+ // Simple-named aliases for every component schema (App, Deployment, Secret, …),
216
+ // so consumers import `App` instead of `components["schemas"]["App"]`. One alias
217
+ // per schema, sorted for a stable diff. Hand-maintained extras (friendlier event
218
+ // names, the AppTraffic interface) live in custom-types.ts, which re-exports
219
+ // this file.
220
+ const schemaNames = Object.keys(spec.components?.schemas || {})
221
+ .filter((name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name))
222
+ .sort();
223
+
224
+ const typeAliases = schemaNames
225
+ .map((name) => `export type ${name} = components["schemas"]["${name}"];`)
226
+ .join("\n");
227
+
228
+ const typesOut = `// AUTO-GENERATED by scripts/gen-client.mjs — do not edit by hand.
229
+ // Regenerated from the OpenAPI spec on every build (npm run gentypes).
230
+ // One simple-named alias per schema in \`components["schemas"]\`.
231
+ // Hand-maintained extras live in custom-types.ts.
232
+
233
+ import type { components } from "./types.js";
234
+
235
+ ${typeAliases}
236
+ `;
237
+
238
+ writeFileSync(OUT_TYPES, typesOut);
239
+ console.warn(
240
+ `gen-client: wrote ${schemaNames.length} type aliases → ${OUT_TYPES.replace(ROOT + "/", "")}`,
241
+ );