@faable/auth-sdk 2.2.1 → 2.3.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.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./FaableAuthApi.js";
2
+ export * from "./api/generated-client.js";
2
3
  export * from "./api/api-types.js";
3
4
  export * from "./api/types.js";
4
5
  export { authClientCredentials, authApikey } from "@faable/sdk-base";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./FaableAuthApi.js";
2
+ export * from "./api/generated-client.js";
2
3
  export * from "./api/api-types.js";
3
4
  export * from "./api/types.js";
4
5
  // Re-export the auth strategies (and their config/types) from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/auth-sdk",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "author": "Marc Pomar <marc@faable.com>",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -25,7 +25,7 @@
25
25
  "typescript": "^5.3.3"
26
26
  },
27
27
  "scripts": {
28
- "gentypes": "openapi-typescript ${AUTH_OPENAPI:-spec/openapi.json} -o src/api/types.ts",
28
+ "gentypes": "openapi-typescript ${AUTH_OPENAPI:-spec/openapi.json} -o src/api/types.ts && node scripts/gen-client.mjs",
29
29
  "build": "rimraf dist && tsc",
30
30
  "prebuild": "npm run gentypes",
31
31
  "prepublishOnly": "npm run build",
@@ -0,0 +1,205 @@
1
+ // Generates `src/api/generated-client.ts` from the OpenAPI spec.
2
+ //
3
+ // Why: the SDK's typed methods (userGet, teamList, …) used to be hand-written
4
+ // and always lagged the server. The TYPES are already auto-generated by
5
+ // openapi-typescript; this script does the same for the METHODS, so the SDK
6
+ // covers the whole management API for free and stays in sync on every build.
7
+ //
8
+ // Scope: only operations whose `security` includes `bearerAuth` (the management
9
+ // API). OAuth/OIDC/session/login browser flows carry no bearerAuth and are
10
+ // skipped — they are not SDK convenience calls. All included operationIds have
11
+ // the stable `resource/action` shape, so the camelCase names come out clean.
12
+ //
13
+ // Run via `npm run gentypes` (after openapi-typescript). Output is committed and
14
+ // reviewed in diff like `types.ts`. Do not edit the output by hand.
15
+
16
+ import { readFileSync, writeFileSync } from "node:fs";
17
+ import { fileURLToPath } from "node:url";
18
+ import { dirname, resolve } from "node:path";
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const ROOT = resolve(__dirname, "..");
22
+
23
+ const SPEC = process.env.AUTH_OPENAPI || resolve(ROOT, "spec/openapi.json");
24
+ const OUT = resolve(ROOT, "src/api/generated-client.ts");
25
+
26
+ const spec = JSON.parse(readFileSync(SPEC, "utf8"));
27
+
28
+ // operationId (`resource/action`, possibly snake/kebab in the action) → camelCase
29
+ // method name. `user/list`→userList, `client/rotateSecret`→clientRotateSecret,
30
+ // `role/assign_users`→roleAssignUsers.
31
+ const toMethodName = (operationId) =>
32
+ operationId
33
+ .split(/[/_-]/)
34
+ .filter(Boolean)
35
+ .map((seg, i) =>
36
+ i === 0
37
+ ? seg[0].toLowerCase() + seg.slice(1)
38
+ : seg[0].toUpperCase() + seg.slice(1),
39
+ )
40
+ .join("");
41
+
42
+ // `/team/{team_id}/member/check/{user_id}` → ["team_id", "user_id"], in order.
43
+ const pathParamNames = (path) =>
44
+ [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
45
+
46
+ // `/user/{user_id}` → "/user/${user_id}" (template-literal body).
47
+ const toUrlTemplate = (path) =>
48
+ path.replace(/\{([^}]+)\}/g, (_, name) => "${" + name + "}");
49
+
50
+ const hasBearerAuth = (op) =>
51
+ Array.isArray(op.security) &&
52
+ op.security.some((req) => req && Object.keys(req).includes("bearerAuth"));
53
+
54
+ const successSchema = (op) => {
55
+ const responses = op.responses || {};
56
+ const res = responses["200"] || responses["201"];
57
+ return res?.content?.["application/json"]?.schema;
58
+ };
59
+
60
+ // A paginated list response is `{ next, results }` (see buildPaginator in
61
+ // sdk-base). Detected structurally so it tracks the server, not the op name.
62
+ const isPaginated = (op) => {
63
+ const schema = successSchema(op);
64
+ const req = schema?.required;
65
+ return Array.isArray(req) && req.includes("next") && req.includes("results");
66
+ };
67
+
68
+ const escape = (s) => (s || "").replace(/\*\//g, "*\\/").replace(/\r?\n/g, " ");
69
+
70
+ // Collect + sort operations by operationId for a stable, reviewable diff.
71
+ const operations = [];
72
+ for (const [path, methods] of Object.entries(spec.paths || {})) {
73
+ for (const [httpMethod, op] of Object.entries(methods)) {
74
+ if (!op || typeof op !== "object" || !op.operationId) continue;
75
+ if (!["get", "post", "delete"].includes(httpMethod)) continue;
76
+ if (!hasBearerAuth(op)) continue;
77
+ operations.push({ path, httpMethod, op });
78
+ }
79
+ }
80
+ operations.sort((a, b) => a.op.operationId.localeCompare(b.op.operationId));
81
+
82
+ const emitMethod = ({ path, httpMethod, op }) => {
83
+ const id = op.operationId;
84
+ const name = toMethodName(id);
85
+ const pathParams = pathParamNames(path);
86
+ const queryParams = (op.parameters || []).filter((p) => p.in === "query");
87
+ const hasBody = !!op.requestBody;
88
+ const paginated = isPaginated(op);
89
+ const url = "`" + toUrlTemplate(path) + "`";
90
+
91
+ const hasQuery = queryParams.length > 0;
92
+ const args = pathParams.map((p) => `${p}: string`);
93
+ if (hasBody) args.push(`data: OpBody<"${id}">`);
94
+
95
+ // The only POST without a query in the spec; if that ever changes the
96
+ // generator must learn to pass query on a POST (fetcher.post takes a strict
97
+ // FetcherConfig). Fail loud rather than silently drop the query params.
98
+ if (httpMethod === "post" && hasQuery) {
99
+ throw new Error(
100
+ `gen-client: POST with query params not supported yet (${id}). ` +
101
+ "Extend emitMethod to thread query params through fetcher.post's config.",
102
+ );
103
+ }
104
+
105
+ let body;
106
+ if (paginated) {
107
+ args.push(`params?: Omit<OpQuery<"${id}">, "cursor" | "next">`);
108
+ // The paginator's request params are untyped (`any`), so array/number/enum
109
+ // query values pass straight through.
110
+ body = `return this.paginator<OpItem<"${id}">>({ url: ${url}, params });`;
111
+ } else if (httpMethod === "post") {
112
+ // fetcher.post rejects a falsy body ("empty body"), so bodyless POSTs (e.g.
113
+ // client/rotateSecret) send an empty object.
114
+ const data = hasBody ? "data" : "{}";
115
+ body = `return this.fetcher.post<OpResult<"${id}">>(${url}, ${data});`;
116
+ } else if (hasQuery) {
117
+ // GET/DELETE with query params route through fetcher.request: its `params`
118
+ // is untyped, sidestepping FetcherConfig's string-only constraint, and the
119
+ // GET path still goes through the ETag cache.
120
+ args.push(`params?: OpQuery<"${id}">`);
121
+ const method = httpMethod.toUpperCase();
122
+ body = `return this.fetcher.request<OpResult<"${id}">>({ method: "${method}", url: ${url}, params });`;
123
+ } else if (httpMethod === "delete") {
124
+ body = `return this.fetcher.delete<OpResult<"${id}">>(${url});`;
125
+ } else {
126
+ body = `return this.fetcher.get<OpResult<"${id}">>(${url});`;
127
+ }
128
+
129
+ const guards = pathParams.map((p) => ` requireId("${p}", ${p});`);
130
+ const doc = op.summary || op.description;
131
+ const jsdoc = [
132
+ " /**",
133
+ ` * \`${httpMethod.toUpperCase()} ${path}\` — operationId: \`${id}\``,
134
+ ...(doc ? [` *`, ` * ${escape(doc)}`] : []),
135
+ " */",
136
+ ].join("\n");
137
+
138
+ return [
139
+ jsdoc,
140
+ ` ${name}(${args.join(", ")}) {`,
141
+ ...guards,
142
+ ` ${body}`,
143
+ " }",
144
+ ].join("\n");
145
+ };
146
+
147
+ const header = `// AUTO-GENERATED by scripts/gen-client.mjs — do not edit by hand.
148
+ // Regenerated from the OpenAPI spec on every build (npm run gentypes).
149
+ // Source of truth: the auth server's operationIds + bearerAuth security.
150
+
151
+ import type { operations } from "./types.js";
152
+ import { FaableApi } from "@faable/sdk-base";
153
+ import { requireId } from "../helpers.js";
154
+
155
+ // JSON body of an operation's request (typed from the generated \`operations\`).
156
+ type OpBody<K extends keyof operations> = operations[K] extends {
157
+ requestBody: { content: { "application/json": infer B } };
158
+ }
159
+ ? B
160
+ : never;
161
+
162
+ // JSON body of an operation's 2xx response.
163
+ type Content2xx<R> = R extends {
164
+ 200: { content: { "application/json": infer T } };
165
+ }
166
+ ? T
167
+ : R extends { 201: { content: { "application/json": infer T } } }
168
+ ? T
169
+ : void;
170
+
171
+ type OpResult<K extends keyof operations> = operations[K] extends {
172
+ responses: infer R;
173
+ }
174
+ ? Content2xx<R>
175
+ : void;
176
+
177
+ // Item type of a paginated (\`{ next, results }\`) list response.
178
+ type OpItem<K extends keyof operations> =
179
+ OpResult<K> extends { results: (infer I)[] } ? I : never;
180
+
181
+ // Query parameters of an operation.
182
+ type OpQuery<K extends keyof operations> = operations[K] extends {
183
+ parameters: { query?: infer Q };
184
+ }
185
+ ? NonNullable<Q>
186
+ : Record<string, never>;
187
+ `;
188
+
189
+ const classBody = operations.map(emitMethod).join("\n\n");
190
+
191
+ const out = `${header}
192
+ /**
193
+ * Auto-generated management-API methods, one per \`bearerAuth\` operation in the
194
+ * OpenAPI spec. \`FaableAuthApi\` extends this and adds the constructor,
195
+ * custom-logic helpers, and deprecated aliases.
196
+ */
197
+ export abstract class GeneratedFaableAuthApi extends FaableApi {
198
+ ${classBody}
199
+ }
200
+ `;
201
+
202
+ writeFileSync(OUT, out);
203
+ console.warn(
204
+ `gen-client: wrote ${operations.length} methods → ${OUT.replace(ROOT + "/", "")}`,
205
+ );