@gusnips/sdkgen 0.1.0 → 0.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.
package/src/methods.ts ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * The methods an SDK exposes, written from the operation list the API already mounts, and the two
3
+ * files they run on.
4
+ *
5
+ * Five generators each wrote this loop, and what they shared is here: one method per operation,
6
+ * grouped by namespace, taking the params interface its input schema gives and returning the type
7
+ * the operation says `data` holds; a path whose slots carry the argument's own name, so the
8
+ * transport can fill them; and the checks that stop a wrong SDK at generation rather than in the
9
+ * hands of whoever installs it. What differs by product comes in through options: the doc
10
+ * comment's paragraphs, what the spec carries beyond the route, and members written by hand.
11
+ *
12
+ * It reads the operation list in-process, never the published OpenAPI document. The document has
13
+ * lost the type names, the argument renames and the typed query params, and a type JSON cannot
14
+ * carry reaches it as `{}`, where the schema writer here would have stopped.
15
+ */
16
+ import { readFileSync } from "node:fs";
17
+ import { createRequire } from "node:module";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import {
21
+ inputJsonSchema,
22
+ paramsInterface,
23
+ pascalCase,
24
+ typeNames,
25
+ wrapLines,
26
+ type JsonObject,
27
+ type SchemaSource,
28
+ } from "./types.ts";
29
+
30
+ /** Where the generated SDK puts an operation. */
31
+ export interface SdkPlacement {
32
+ /** `sendMessage`, or `numbers.pair` in a namespace. One dot at most; unique. */
33
+ method: string;
34
+ /** What `data` holds, as the SDK names it: `MessageDto`, `NumberDto[]`; `void` for a 204. */
35
+ returns: string;
36
+ /**
37
+ * Safe to run twice with no key: a read sent as a POST, or a write the server dedupes on its
38
+ * own. Default: a GET only.
39
+ */
40
+ repeatable?: boolean;
41
+ }
42
+
43
+ /**
44
+ * The part of an operation the generator reads. It is a structural subset of `@gusnips/server`'s
45
+ * `OpenApiOperation`, so the list an API hands `buildOpenApi` fits here unchanged.
46
+ */
47
+ export interface SdkOperation {
48
+ name: string;
49
+ method: "get" | "post" | "put" | "patch" | "delete";
50
+ /** The route as the router spells it: `/numbers/:id`. */
51
+ path: string;
52
+ summary: string;
53
+ description?: string;
54
+ /** Everything the operation reads. It becomes the method's params interface. */
55
+ input?: SchemaSource;
56
+ /** Input field → path slot, where the two names differ: `{ numberId: "id" }`. */
57
+ params?: Readonly<Record<string, string>>;
58
+ /** Fields the route fills in itself. The SDK leaves them out. */
59
+ fixed?: readonly string[];
60
+ /** Request headers the operation reads. An `Idempotency-Key` here makes the call keyed. */
61
+ headers?: readonly { readonly name: string }[];
62
+ /** The success status. A 204, 205 or 304 has no body, so it returns `void`. */
63
+ status?: number;
64
+ extensions?: Readonly<Record<`x-${string}`, unknown>>;
65
+ /** Where the generated SDK puts this operation. Absent: no method. */
66
+ sdk?: SdkPlacement;
67
+ }
68
+
69
+ /** A member written by hand, placed beside the generated ones and held to the same names. */
70
+ export interface InjectedMember {
71
+ /** `jobs.wait`, or `wait` at the top level. */
72
+ method: string;
73
+ /** Its parameters and return type: `(jobId: string, opts?: WaitOptions): Promise<JobDto>`. */
74
+ signature: string;
75
+ /** What it returns: `this.waitForJob(jobId, opts)`. */
76
+ call: string;
77
+ /** Its doc comment, one string per paragraph. */
78
+ doc?: readonly string[];
79
+ }
80
+
81
+ export interface SdkMethodsOptions {
82
+ /**
83
+ * The namespaces, in the order the client lists them. Given, a namespace not in it throws, so a
84
+ * typo cannot start a new one. Default: the order they first appear.
85
+ */
86
+ namespaces?: readonly string[];
87
+ /**
88
+ * A method's doc comment, one string per paragraph; `undefined` ones are skipped. Default: the
89
+ * summary, then the description.
90
+ */
91
+ doc?: (op: SdkOperation) => readonly (string | undefined)[];
92
+ /** Fields to add to the spec an operation hands `request`, such as its proxy block's fields. */
93
+ specExtra?: (op: SdkOperation) => Readonly<Record<string, unknown>> | undefined;
94
+ /** Members written by hand, each after the generated ones in its place. */
95
+ inject?: readonly InjectedMember[];
96
+ }
97
+
98
+ export interface SdkMethods {
99
+ /** An `export interface …Params` for each method that takes arguments: the body of `params.ts`. */
100
+ params: string;
101
+ /**
102
+ * The class members: top-level methods first, then one object per namespace. Each calls
103
+ * `this.request(spec, params, opts)`, which the class declares and the SDK implements.
104
+ */
105
+ members: string;
106
+ /** The params interfaces the members name, sorted, for their import line. */
107
+ paramTypes: string[];
108
+ /** The declared types the `returns` name, sorted, for their import from the contract. */
109
+ returnTypes: string[];
110
+ }
111
+
112
+ /** Success statuses with no body (RFC 9110), the same three the server's reference documents so. */
113
+ const NO_BODY = new Set([204, 205, 304]);
114
+
115
+ /** Class members a method must not be named: the seam every method calls, and the constructor. */
116
+ const RESERVED = new Set(["request", "constructor"]);
117
+
118
+ /** The spec fields the generator writes, which `specExtra` must not overwrite. */
119
+ const SPEC_FIELDS = new Set(["method", "path", "keyed", "repeatable"]);
120
+
121
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
122
+
123
+ interface Member {
124
+ /** Who asked for it, for an error message. */
125
+ where: string;
126
+ namespace: string | undefined;
127
+ name: string;
128
+ doc: readonly (string | undefined)[];
129
+ signature: string;
130
+ call: string;
131
+ }
132
+
133
+ /**
134
+ * The SDK's methods for every operation that names an `sdk` place, with the params interfaces they
135
+ * take.
136
+ *
137
+ * ```ts
138
+ * const { params, members, paramTypes, returnTypes } = sdkMethods(operations);
139
+ * const file = `export abstract class GeneratedOperations {
140
+ * protected abstract request<T>(spec: RequestSpec, params?: object, opts?: RequestOptions): Promise<T>;
141
+ * ${members}}\n`;
142
+ * ```
143
+ */
144
+ export function sdkMethods(
145
+ operations: readonly SdkOperation[],
146
+ options: SdkMethodsOptions = {},
147
+ ): SdkMethods {
148
+ const { doc = (op) => [op.summary, op.description], specExtra, inject = [] } = options;
149
+ const members: Member[] = [];
150
+ const params: string[] = [];
151
+ const paramTypes: string[] = [];
152
+ const returnTypes = new Set<string>();
153
+
154
+ for (const op of operations) {
155
+ const sdk = op.sdk;
156
+ if (sdk === undefined) continue;
157
+ const where = `${op.method.toUpperCase()} ${op.path} (${op.name})`;
158
+ if (op.status !== undefined && NO_BODY.has(op.status) && sdk.returns !== "void") {
159
+ throw new Error(
160
+ `${where} answers ${op.status}, which has no body, so \`sdk.returns\` must be "void".`,
161
+ );
162
+ }
163
+ const schema = op.input === undefined ? undefined : schemaOf(op.input, where);
164
+ const fixed = op.fixed ?? [];
165
+ const typeName = `${pascalCase(op.name)}Params`;
166
+ const iface = schema === undefined ? null : paramsInterface(typeName, schema, fixed);
167
+ if (iface !== null) {
168
+ params.push(`/** Arguments for \`${sdk.method}()\`. */\n${iface.source}`);
169
+ paramTypes.push(typeName);
170
+ }
171
+ for (const name of typeNames(sdk.returns)) returnTypes.add(name);
172
+
173
+ const listed = schema?.["required"];
174
+ const required = new Set(
175
+ Array.isArray(listed)
176
+ ? listed.filter((f): f is string => typeof f === "string" && !fixed.includes(f))
177
+ : [],
178
+ );
179
+ const fields = [
180
+ `method: "${op.method.toUpperCase()}"`,
181
+ `path: ${JSON.stringify(sdkPath(op, required, where))}`,
182
+ ];
183
+ if (op.headers?.some((h) => h.name.toLowerCase() === "idempotency-key"))
184
+ fields.push("keyed: true");
185
+ if (sdk.repeatable !== undefined) fields.push(`repeatable: ${sdk.repeatable}`);
186
+ for (const [key, value] of Object.entries(specExtra?.(op) ?? {})) {
187
+ if (SPEC_FIELDS.has(key)) {
188
+ throw new Error(
189
+ `${where}: \`specExtra\` sets "${key}", which the generator writes itself.`,
190
+ );
191
+ }
192
+ if (value !== undefined) fields.push(`${propertyKey(key)}: ${JSON.stringify(value)}`);
193
+ }
194
+
195
+ const argument = iface === null ? "" : `params${iface.optional ? "?" : ""}: ${typeName}, `;
196
+ members.push({
197
+ where,
198
+ ...place(sdk.method, where),
199
+ doc: doc(op),
200
+ signature: `(${argument}opts?: RequestOptions): Promise<${sdk.returns}>`,
201
+ call: `this.request({ ${fields.join(", ")} }, ${iface === null ? "undefined" : "params"}, opts)`,
202
+ });
203
+ }
204
+ for (const member of inject) {
205
+ const where = `The hand-written ${member.method}`;
206
+ members.push({
207
+ where,
208
+ ...place(member.method, where),
209
+ doc: member.doc ?? [],
210
+ signature: member.signature,
211
+ call: member.call,
212
+ });
213
+ }
214
+
215
+ return {
216
+ params: params.join("\n"),
217
+ members: writeMembers(members, options.namespaces),
218
+ paramTypes: paramTypes.sort(),
219
+ returnTypes: [...returnTypes].sort(),
220
+ };
221
+ }
222
+
223
+ /** `numbers.pair` → its namespace and name, each checked to be a name a class member can have. */
224
+ function place(method: string, where: string): { namespace: string | undefined; name: string } {
225
+ const parts = method.split(".");
226
+ if (parts.length > 2 || !parts.every((part) => IDENTIFIER.test(part))) {
227
+ throw new Error(
228
+ `${where}: \`${method}\` is not a method name. Write \`name\`, or \`namespace.name\` with one dot.`,
229
+ );
230
+ }
231
+ const [first = "", second] = parts;
232
+ return second === undefined
233
+ ? { namespace: undefined, name: first }
234
+ : { namespace: first, name: second };
235
+ }
236
+
237
+ /** Top-level methods first, then one `readonly namespace = { … }` per namespace. */
238
+ function writeMembers(members: readonly Member[], order: readonly string[] | undefined): string {
239
+ const seen = new Map<string, string>();
240
+ const topLevel = new Set<string>();
241
+ const namespaces: string[] = [];
242
+ for (const { where, namespace, name } of members) {
243
+ const full = namespace === undefined ? name : `${namespace}.${name}`;
244
+ const first = seen.get(full);
245
+ if (first !== undefined) {
246
+ throw new Error(`Two methods want to be \`${full}\`: ${first}, and ${where}. Rename one.`);
247
+ }
248
+ seen.set(full, where);
249
+ if (namespace === undefined) topLevel.add(name);
250
+ else if (!namespaces.includes(namespace)) namespaces.push(namespace);
251
+ }
252
+ for (const name of [...topLevel, ...namespaces]) {
253
+ if (RESERVED.has(name)) {
254
+ throw new Error(`\`${name}\` is a class member every SDK already has. Pick another name.`);
255
+ }
256
+ if (topLevel.has(name) && namespaces.includes(name)) {
257
+ throw new Error(`\`${name}\` is both a method and a namespace. Rename one.`);
258
+ }
259
+ }
260
+ if (order !== undefined) {
261
+ const unknown = namespaces.find((namespace) => !order.includes(namespace));
262
+ if (unknown !== undefined) {
263
+ throw new Error(
264
+ `The namespace \`${unknown}\` is not in \`namespaces\`. Add it there, or use one of: ${order.join(", ")}.`,
265
+ );
266
+ }
267
+ }
268
+
269
+ let out = "";
270
+ for (const member of members.filter((m) => m.namespace === undefined)) {
271
+ out += `\n${jsdoc(member.doc, " ")} ${member.name}${member.signature} {\n`;
272
+ out += ` return ${member.call};\n }\n`;
273
+ }
274
+ for (const namespace of (order ?? namespaces).filter((ns) => namespaces.includes(ns))) {
275
+ out += `\n readonly ${namespace} = {\n`;
276
+ for (const member of members.filter((m) => m.namespace === namespace)) {
277
+ out += `${jsdoc(member.doc, " ")} ${member.name}: ${member.signature} =>\n`;
278
+ out += ` ${member.call},\n`;
279
+ }
280
+ out += ` };\n`;
281
+ }
282
+ return out;
283
+ }
284
+
285
+ /**
286
+ * The route with each slot named for the argument that fills it: `/numbers/:id` with
287
+ * `{ numberId: "id" }` → `/numbers/:numberId`. A slot's argument must be required, or the SDK
288
+ * would let a caller leave out part of the address.
289
+ */
290
+ function sdkPath(op: SdkOperation, required: ReadonlySet<string>, where: string): string {
291
+ // A constraint such as `:id{[0-9]+}` is the router's business, and the SDK drops it.
292
+ const slot = /:(\w+)(\{[^}]*\})?(\?)?/g;
293
+ const slots = new Set([...op.path.matchAll(slot)].map((match) => match[1]));
294
+ const fieldIn = new Map<string, string>();
295
+ for (const [field, name] of Object.entries(op.params ?? {})) {
296
+ if (!slots.has(name)) {
297
+ throw new Error(
298
+ `${where}: \`params\` sends ${field} in ":${name}", which the path does not have.`,
299
+ );
300
+ }
301
+ fieldIn.set(name, field);
302
+ }
303
+ return op.path.replace(slot, (_match, name: string, _re, optional) => {
304
+ if (optional) {
305
+ throw new Error(
306
+ `${where} has an optional slot ":${name}?". List it as two operations, with and without it.`,
307
+ );
308
+ }
309
+ const field = fieldIn.get(name) ?? name;
310
+ if (!/^\w+$/.test(field)) {
311
+ throw new Error(
312
+ `${where}: the path's ":${name}" is filled from \`${field}\`, which a path cannot name. Rename the field.`,
313
+ );
314
+ }
315
+ if (!required.has(field)) {
316
+ throw new Error(
317
+ `${where}: the path's ":${name}" is filled from \`${field}\`, which the input does not require. ` +
318
+ "Make it required, or map the slot to another field with `params`.",
319
+ );
320
+ }
321
+ return `:${field}`;
322
+ });
323
+ }
324
+
325
+ /** The input as JSON Schema, with the operation named when it cannot be written. */
326
+ function schemaOf(input: SchemaSource, where: string): JsonObject {
327
+ try {
328
+ return inputJsonSchema(input);
329
+ } catch (err) {
330
+ throw new Error(`${where}: ${err instanceof Error ? err.message : String(err)}`, {
331
+ cause: err,
332
+ });
333
+ }
334
+ }
335
+
336
+ /** `/**` over paragraphs, a blank ` *` line between them, each wrapped for its indent. */
337
+ function jsdoc(paragraphs: readonly (string | undefined)[], indent: string): string {
338
+ const blocks = paragraphs
339
+ .map((paragraph) => (paragraph === undefined ? [] : wrapLines(paragraph, indent)))
340
+ .filter((lines) => lines.length > 0);
341
+ if (blocks.length === 0) return "";
342
+ const line = `\n${indent} * `;
343
+ return `${indent}/**${line}${blocks.map((lines) => lines.join(line)).join(`\n${indent} *${line}`)}\n${indent} */\n`;
344
+ }
345
+
346
+ /** A key that is not an identifier has to be quoted. */
347
+ const propertyKey = (key: string): string => (IDENTIFIER.test(key) ? key : JSON.stringify(key));
348
+
349
+ /**
350
+ * `@gusnips/http`'s retry rule, its `src/retry.ts` whole, for an SDK to carry as
351
+ * `generated/retry.ts`. It is read from this package's own dependency, never the adopter's, so
352
+ * every SDK built with one sdkgen carries one rule. When an sdkgen release moves the rule, each
353
+ * SDK's `--check` fails, and that is the signal to release the SDK.
354
+ */
355
+ export function retrySource(): string {
356
+ const entry = createRequire(import.meta.url).resolve("@gusnips/http/retry");
357
+ return readFileSync(join(dirname(entry), "../src/retry.ts"), "utf8");
358
+ }
359
+
360
+ /**
361
+ * The transport every generated method runs on, for an SDK to carry as `generated/transport.ts`
362
+ * beside `retry.ts`: this package's own `src/transport.ts`, importing the rule from `./retry.ts`.
363
+ */
364
+ export function transportSource(): string {
365
+ const path = fileURLToPath(new URL("../src/transport.ts", import.meta.url));
366
+ return readFileSync(path, "utf8").replace(`from "@gusnips/http/retry";`, `from "./retry.ts";`);
367
+ }
@@ -4,7 +4,15 @@
4
4
  */
5
5
  import { describe, expect, it } from "vitest";
6
6
  import { z } from "zod";
7
- import { camelCase, fieldsOf, inputJsonSchema, pascalCase, typeNames, typeOf } from "./index.ts";
7
+ import {
8
+ camelCase,
9
+ fieldsOf,
10
+ inputJsonSchema,
11
+ pascalCase,
12
+ sdkMethods,
13
+ typeNames,
14
+ typeOf,
15
+ } from "./index.ts";
8
16
 
9
17
  describe("README", () => {
10
18
  it("prints what the README says", () => {
@@ -20,5 +28,22 @@ describe("README", () => {
20
28
  expect(
21
29
  fieldsOf({ type: "object", properties: { "content-type": { type: "string" } } }, ""),
22
30
  ).toBe('"content-type"?: string;\n');
31
+ const { members } = sdkMethods([
32
+ {
33
+ name: "health",
34
+ method: "get",
35
+ path: "/health",
36
+ summary: "Check the API is up.",
37
+ sdk: { method: "health", returns: "HealthDto" },
38
+ },
39
+ ]);
40
+ expect(members).toBe(`
41
+ /**
42
+ * Check the API is up.
43
+ */
44
+ health(opts?: RequestOptions): Promise<HealthDto> {
45
+ return this.request({ method: "GET", path: "/health" }, undefined, opts);
46
+ }
47
+ `);
23
48
  });
24
49
  });