@gusnips/sdkgen 0.1.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/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/contract.d.ts +40 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +213 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +222 -0
- package/dist/types.js.map +1 -0
- package/dist/write.d.ts +29 -0
- package/dist/write.d.ts.map +1 -0
- package/dist/write.js +52 -0
- package/dist/write.js.map +1 -0
- package/package.json +66 -0
- package/src/contract.test.ts +201 -0
- package/src/contract.ts +249 -0
- package/src/index.ts +21 -0
- package/src/readme.test.ts +24 -0
- package/src/types.test.ts +144 -0
- package/src/types.ts +250 -0
- package/src/write.test.ts +67 -0
- package/src/write.ts +71 -0
package/src/contract.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying the API's own type declarations into its SDK, with their comments.
|
|
3
|
+
*
|
|
4
|
+
* A published SDK cannot depend on the API's workspace, so the types it answers with have to
|
|
5
|
+
* travel inside it. A `.d.ts` emit would carry the types and drop the prose, and the prose is most
|
|
6
|
+
* of what makes an SDK pleasant to hold, so this reads each declaration as it is written at home
|
|
7
|
+
* and copies it whole. Five generators did this with one hand-written reader, and each copy had
|
|
8
|
+
* fixed something the others had not:
|
|
9
|
+
* - a property KEY is not a reference (`VALIDATION_ERROR: 400` names no type);
|
|
10
|
+
* - an `export function` is a braced block, like an interface;
|
|
11
|
+
* - `(typeof X)[number]` can be written as its literal union, where the array would be dead.
|
|
12
|
+
* And all five broke what the reader rests on, that the blanked text matches the original line for
|
|
13
|
+
* line: an escaped newline in a string moved every later line, a backslash at the end of a `//`
|
|
14
|
+
* comment blanked the next line, a `/*` followed by `/` closed on its own star, and a one-line
|
|
15
|
+
* `{}` block ran on into the next declaration.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { BUILTIN } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
export interface LiftOptions {
|
|
22
|
+
/** The directory `sources` are relative to, usually the repo root. */
|
|
23
|
+
root: string;
|
|
24
|
+
/** The files a public type may come from. They are written out in this order. */
|
|
25
|
+
sources: readonly string[];
|
|
26
|
+
/** The names the SDK needs. Everything they mention comes along. */
|
|
27
|
+
roots: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Write `(typeof X)[number]` as its literal union, and leave the array out. Off by default: an SDK
|
|
30
|
+
* that re-exports its contract publishes that array, so dropping it is a breaking change, and
|
|
31
|
+
* each member's doc comment goes with it.
|
|
32
|
+
*/
|
|
33
|
+
inlineTuples?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Keep `export` on the roots only. What they mention is still written, without it, so the SDK's
|
|
36
|
+
* public names are exactly the ones listed. Off by default: everything written is exported.
|
|
37
|
+
*/
|
|
38
|
+
exportOnlyRoots?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Block {
|
|
42
|
+
name: string;
|
|
43
|
+
/** The declaration with its doc comment, as it reads at home. */
|
|
44
|
+
source: string;
|
|
45
|
+
/** The names it mentions, followed so nothing lands half-defined. */
|
|
46
|
+
refs: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The source with every comment and string body blanked, one character for one character and a
|
|
51
|
+
* newline for a newline, so structure can be read off it while the ORIGINAL lines are what gets
|
|
52
|
+
* copied. Without it a `;` inside a sentence ends a declaration early, and every capitalized word
|
|
53
|
+
* in the prose looks like a type. `keepStrings` blanks the comments only, for reading the strings
|
|
54
|
+
* themselves: an apostrophe in a comment is not a quote.
|
|
55
|
+
*
|
|
56
|
+
* ponytail: a regex literal is read as code, so a quote inside one (`/"/`) opens a string that
|
|
57
|
+
* runs to the next quote. Contract files are declarations, where that does not come up; one that
|
|
58
|
+
* needs it would need a real tokenizer here.
|
|
59
|
+
*/
|
|
60
|
+
export function blankCommentsAndStrings(source: string, { keepStrings = false } = {}): string {
|
|
61
|
+
let out = "";
|
|
62
|
+
let state: "code" | "line" | "block" | '"' | "'" | "`" = "code";
|
|
63
|
+
const blank = (c: string | undefined) => (c === "\n" ? "\n" : c === undefined ? "" : " ");
|
|
64
|
+
const inString = (c: string | undefined) => (keepStrings ? (c ?? "") : blank(c));
|
|
65
|
+
for (let i = 0; i < source.length; i++) {
|
|
66
|
+
const c = source[i] ?? "";
|
|
67
|
+
const next = source[i + 1];
|
|
68
|
+
if (state === "code") {
|
|
69
|
+
if (c === "/" && (next === "/" || next === "*")) {
|
|
70
|
+
// Both characters of the opener, so the `*` of `/*/` cannot also close it.
|
|
71
|
+
state = next === "/" ? "line" : "block";
|
|
72
|
+
out += " ";
|
|
73
|
+
i++;
|
|
74
|
+
} else if (c === '"' || c === "'" || c === "`") {
|
|
75
|
+
state = c;
|
|
76
|
+
out += inString(c);
|
|
77
|
+
} else out += c;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (state === "line" || state === "block") {
|
|
81
|
+
if (state === "block" && c === "*" && next === "/") {
|
|
82
|
+
state = "code";
|
|
83
|
+
out += " ";
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (c === "\n" && state === "line") state = "code";
|
|
88
|
+
out += blank(c);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (c === "\\") {
|
|
92
|
+
// An escape never closes a string, and the character it escapes may be a newline.
|
|
93
|
+
out += inString(c) + inString(next);
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (c === state) state = "code";
|
|
98
|
+
out += inString(c);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Split one file into its exported top-level declarations. */
|
|
104
|
+
function readBlocks(text: string): Block[] {
|
|
105
|
+
const lines = text.split("\n");
|
|
106
|
+
const clean = blankCommentsAndStrings(text).split("\n");
|
|
107
|
+
const blocks: Block[] = [];
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < lines.length; i++) {
|
|
110
|
+
const match = /^export (interface|type|const|function) (\w+)/.exec(clean[i] ?? "");
|
|
111
|
+
if (!match?.[2]) continue;
|
|
112
|
+
const name = match[2];
|
|
113
|
+
|
|
114
|
+
// Back over the doc comment sitting on top of the declaration.
|
|
115
|
+
let start = i;
|
|
116
|
+
while (start > 0) {
|
|
117
|
+
const above = (lines[start - 1] ?? "").trim();
|
|
118
|
+
if (!above || (!above.startsWith("*") && !above.startsWith("/*"))) break;
|
|
119
|
+
start--;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Forward to its end: the first `;` outside every bracket, since a mapped type carries one on
|
|
123
|
+
// an inner line, or for an interface or a function, the line that closes its brackets. That
|
|
124
|
+
// can be its first (`export interface Empty {}`); waiting for a lone `}`, as every copy did,
|
|
125
|
+
// swallowed the declaration after it.
|
|
126
|
+
const braced = match[1] === "interface" || match[1] === "function";
|
|
127
|
+
let end = i;
|
|
128
|
+
let depth = 0;
|
|
129
|
+
outer: for (; end < lines.length; end++) {
|
|
130
|
+
for (const char of clean[end] ?? "") {
|
|
131
|
+
if ("{[(".includes(char)) depth++;
|
|
132
|
+
else if ("}])".includes(char)) depth--;
|
|
133
|
+
else if (char === ";" && depth === 0) break outer;
|
|
134
|
+
}
|
|
135
|
+
if (braced && depth === 0) break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const body = clean
|
|
139
|
+
.slice(i, end + 1)
|
|
140
|
+
.join("\n")
|
|
141
|
+
// A property KEY is not a reference: `VALIDATION_ERROR: 400` names a member, not a type.
|
|
142
|
+
.replace(/\b[A-Z][A-Za-z0-9_]*(?=\s*\??:)/g, " ");
|
|
143
|
+
const refs = [...new Set(body.match(/\b[A-Z][A-Za-z0-9_]*\b/g) ?? [])].filter(
|
|
144
|
+
// ponytail: a short name is taken for a type parameter (`T`, `K`). A wrong guess is loud,
|
|
145
|
+
// not silent: a skipped name that WAS needed leaves the SDK missing a type, and it stops
|
|
146
|
+
// compiling.
|
|
147
|
+
(ref) => ref !== name && ref.length > 2 && !BUILTIN.has(ref),
|
|
148
|
+
);
|
|
149
|
+
blocks.push({ name, source: lines.slice(start, end + 1).join("\n"), refs });
|
|
150
|
+
i = end;
|
|
151
|
+
}
|
|
152
|
+
return blocks;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* With `inlineTuples`, `export type Plan = (typeof PLANS)[number];` becomes
|
|
157
|
+
* `export type Plan = "free" | "pro";`, and the array is left out. Only a tuple of plain string
|
|
158
|
+
* literals qualifies; anything else throws, because guessing a union into a published type is
|
|
159
|
+
* worse than stopping.
|
|
160
|
+
*/
|
|
161
|
+
function inlineTupleUnions(index: Map<string, Block>): void {
|
|
162
|
+
for (const block of index.values()) {
|
|
163
|
+
const alias = /export type (\w+) = \(?typeof (\w+)\)?\[number\];/.exec(block.source);
|
|
164
|
+
if (!alias?.[2]) continue;
|
|
165
|
+
const tuple = index.get(alias[2]);
|
|
166
|
+
if (tuple === undefined) continue; // reported as missing below
|
|
167
|
+
// Comments out first, strings kept: a member's doc comment can hold an apostrophe.
|
|
168
|
+
const code = blankCommentsAndStrings(tuple.source, { keepStrings: true });
|
|
169
|
+
const literal = /=\s*\[([\s\S]*?)\]\s*as const\s*;/.exec(code);
|
|
170
|
+
const members = literal?.[1]?.match(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g) ?? [];
|
|
171
|
+
const rest = literal?.[1]?.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, "");
|
|
172
|
+
if (literal === null || rest === undefined || /[^\s,]/.test(rest)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`${alias[2]} must be a tuple of string literals \`as const\` for ${block.name} to be written as a union.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const union = members.map((member) => {
|
|
178
|
+
let unreadable = false;
|
|
179
|
+
const text = member.slice(1, -1).replace(/\\([\s\S])/g, (_escape, char: string) => {
|
|
180
|
+
unreadable ||= !`"'\\`.includes(char);
|
|
181
|
+
return char;
|
|
182
|
+
});
|
|
183
|
+
// ponytail: only a quote or a backslash is unescaped. Any other escape (`\n`, `\u00e9`)
|
|
184
|
+
// needs the language's whole table, so it stops rather than write a wrong member.
|
|
185
|
+
if (unreadable) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`${alias[2]} has a member with an escape this cannot read: ${member}. Write it without one, or leave inlineTuples off.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return JSON.stringify(text);
|
|
191
|
+
});
|
|
192
|
+
block.source = block.source.replace(
|
|
193
|
+
alias[0],
|
|
194
|
+
`export type ${block.name} = ${union.join(" | ") || "never"};`,
|
|
195
|
+
);
|
|
196
|
+
block.refs = [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Every declaration the roots need, in source order, as one file's text. A name nobody declares
|
|
202
|
+
* throws with the list of files searched: a contract with a dangling reference does not compile,
|
|
203
|
+
* and finding out here beats finding out at publish.
|
|
204
|
+
*/
|
|
205
|
+
export function liftContract({
|
|
206
|
+
root,
|
|
207
|
+
sources,
|
|
208
|
+
roots,
|
|
209
|
+
inlineTuples = false,
|
|
210
|
+
exportOnlyRoots = false,
|
|
211
|
+
}: LiftOptions): string {
|
|
212
|
+
const files = sources.map((file) => ({
|
|
213
|
+
file,
|
|
214
|
+
blocks: readBlocks(readFileSync(join(root, file), "utf8")),
|
|
215
|
+
}));
|
|
216
|
+
const index = new Map<string, Block>();
|
|
217
|
+
for (const { blocks } of files) for (const block of blocks) index.set(block.name, block);
|
|
218
|
+
if (inlineTuples) inlineTupleUnions(index);
|
|
219
|
+
|
|
220
|
+
const needed = new Set<string>();
|
|
221
|
+
const missing = new Set<string>();
|
|
222
|
+
const visit = (name: string): void => {
|
|
223
|
+
if (needed.has(name)) return;
|
|
224
|
+
const block = index.get(name);
|
|
225
|
+
if (block === undefined) {
|
|
226
|
+
missing.add(name);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
needed.add(name);
|
|
230
|
+
block.refs.forEach(visit);
|
|
231
|
+
};
|
|
232
|
+
roots.forEach(visit);
|
|
233
|
+
if (missing.size > 0) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`No declaration found for ${[...missing].join(", ")}. Export it from one of:\n ${sources.join("\n ")}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const chunks: string[] = [];
|
|
240
|
+
for (const { file, blocks } of files) {
|
|
241
|
+
const wanted = blocks.filter((b) => needed.has(b.name));
|
|
242
|
+
if (wanted.length === 0) continue;
|
|
243
|
+
chunks.push(`// ── from ${file} ${"─".repeat(Math.max(0, 60 - file.length))}\n`);
|
|
244
|
+
const text = (b: Block) =>
|
|
245
|
+
exportOnlyRoots && !roots.includes(b.name) ? b.source.replace(/^export /m, "") : b.source;
|
|
246
|
+
chunks.push(wanted.map(text).join("\n\n"));
|
|
247
|
+
}
|
|
248
|
+
return chunks.join("\n");
|
|
249
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { liftContract, type LiftOptions } from "./contract.ts";
|
|
2
|
+
export {
|
|
3
|
+
camelCase,
|
|
4
|
+
docComment,
|
|
5
|
+
fieldsOf,
|
|
6
|
+
inputJsonSchema,
|
|
7
|
+
paramsInterface,
|
|
8
|
+
pascalCase,
|
|
9
|
+
typeNames,
|
|
10
|
+
typeOf,
|
|
11
|
+
wrapLines,
|
|
12
|
+
type JsonObject,
|
|
13
|
+
type SchemaSource,
|
|
14
|
+
type StandardJsonSchema,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
export {
|
|
17
|
+
writeGenerated,
|
|
18
|
+
type GeneratedFiles,
|
|
19
|
+
type WriteOptions,
|
|
20
|
+
type WriteResult,
|
|
21
|
+
} from "./write.ts";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The README's examples, run. Every literal below is what the README prints beside the call, and a
|
|
3
|
+
* snippet nobody executes rots quietly.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { camelCase, fieldsOf, inputJsonSchema, pascalCase, typeNames, typeOf } from "./index.ts";
|
|
8
|
+
|
|
9
|
+
describe("README", () => {
|
|
10
|
+
it("prints what the README says", () => {
|
|
11
|
+
expect(typeOf({ type: ["string", "null"] })).toBe("string | null");
|
|
12
|
+
expect(fieldsOf(inputJsonSchema(z.object({ to: z.string().describe("Who gets it.") })))).toBe(
|
|
13
|
+
" /** Who gets it. */\n to: string;\n",
|
|
14
|
+
);
|
|
15
|
+
expect(typeNames("Page<Job>[]")).toEqual(["Page", "Job"]);
|
|
16
|
+
expect([camelCase("send_message"), pascalCase("send_message")]).toEqual([
|
|
17
|
+
"sendMessage",
|
|
18
|
+
"SendMessage",
|
|
19
|
+
]);
|
|
20
|
+
expect(
|
|
21
|
+
fieldsOf({ type: "object", properties: { "content-type": { type: "string" } } }, ""),
|
|
22
|
+
).toBe('"content-type"?: string;\n');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
camelCase,
|
|
5
|
+
docComment,
|
|
6
|
+
fieldsOf,
|
|
7
|
+
inputJsonSchema,
|
|
8
|
+
paramsInterface,
|
|
9
|
+
pascalCase,
|
|
10
|
+
typeNames,
|
|
11
|
+
typeOf,
|
|
12
|
+
wrapLines,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
describe("typeOf", () => {
|
|
16
|
+
it.each([
|
|
17
|
+
[{ type: "string" }, "string"],
|
|
18
|
+
[{ type: "integer" }, "number"],
|
|
19
|
+
[{ type: "boolean" }, "boolean"],
|
|
20
|
+
[{ enum: ["qr", "code", null] }, '"qr" | "code" | null'],
|
|
21
|
+
[{ const: 5 }, "5"],
|
|
22
|
+
[{ type: ["number", "null"] }, "number | null"],
|
|
23
|
+
[{ anyOf: [{ type: "string" }, { type: "null" }] }, "string | null"],
|
|
24
|
+
[{ type: "array", items: { enum: ["a", "b"] } }, '("a" | "b")[]'],
|
|
25
|
+
[{ type: "array", items: { type: "string" } }, "string[]"],
|
|
26
|
+
[{ type: "array" }, "unknown[]"],
|
|
27
|
+
[{ type: "array", prefixItems: [{ type: "number" }, { type: "string" }] }, "[number, string]"],
|
|
28
|
+
[
|
|
29
|
+
{ type: "array", prefixItems: [{ type: "number" }], items: { type: "boolean" } },
|
|
30
|
+
"[number, ...boolean[]]",
|
|
31
|
+
],
|
|
32
|
+
[{ type: "object", additionalProperties: { type: "string" } }, "Record<string, string>"],
|
|
33
|
+
[{ type: "object" }, "Record<string, unknown>"],
|
|
34
|
+
[{}, "unknown"],
|
|
35
|
+
[{ description: "Anything at all." }, "unknown"],
|
|
36
|
+
])("writes %j as %s", (schema, type) => {
|
|
37
|
+
expect(typeOf(schema)).toBe(type);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("reads a boolean schema", () => {
|
|
41
|
+
expect(typeOf(true)).toBe("unknown");
|
|
42
|
+
expect(typeOf(false)).toBe("never");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("writes an inline object one level in", () => {
|
|
46
|
+
expect(
|
|
47
|
+
typeOf({ type: "object", properties: { a: { type: "string" } }, required: ["a"] }, " "),
|
|
48
|
+
).toBe("{\n a: string;\n }");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("refuses a schema it would have to guess at", () => {
|
|
52
|
+
expect(() => typeOf({ allOf: [{ type: "string" }] })).toThrow(/Cannot write this JSON Schema/);
|
|
53
|
+
expect(() => typeOf({ $ref: "#/$defs/X" })).toThrow(/Cannot write this JSON Schema/);
|
|
54
|
+
expect(() => typeOf({ type: "array", items: 5 })).toThrow(/Not a JSON Schema: 5/);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("fieldsOf", () => {
|
|
59
|
+
it("keeps each description, marks the optional ones, and quotes a name that needs it", () => {
|
|
60
|
+
const schema = inputJsonSchema(
|
|
61
|
+
z.object({
|
|
62
|
+
to: z.string().describe("Who gets it."),
|
|
63
|
+
"reply-to": z.string().optional(),
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
expect(fieldsOf(schema)).toBe(
|
|
67
|
+
' /** Who gets it. */\n to: string;\n "reply-to"?: string;\n',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("leaves out the fields it is told to", () => {
|
|
72
|
+
const schema = { type: "object", properties: { a: { type: "string" }, b: { type: "string" } } };
|
|
73
|
+
expect(fieldsOf(schema, " ", ["a"])).toBe(" b?: string;\n");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("paramsInterface", () => {
|
|
78
|
+
const schema = inputJsonSchema(
|
|
79
|
+
z.object({ id: z.string(), type: z.enum(["image"]), caption: z.string().optional() }),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
it("is optional when every field left is optional", () => {
|
|
83
|
+
expect(paramsInterface("SendParams", schema, ["id", "type"])).toEqual({
|
|
84
|
+
source: "export interface SendParams {\n caption?: string;\n}\n",
|
|
85
|
+
optional: true,
|
|
86
|
+
});
|
|
87
|
+
expect(paramsInterface("SendParams", schema)?.optional).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("is nothing when no field is left", () => {
|
|
91
|
+
expect(paramsInterface("NoParams", { type: "object", properties: {} })).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("docComment", () => {
|
|
96
|
+
it("wraps under the opener, the way the generated files already read", () => {
|
|
97
|
+
const text = Array.from({ length: 30 }, (_, i) => `word${i}`).join(" ");
|
|
98
|
+
const out = docComment(text, " ");
|
|
99
|
+
expect(out.startsWith(" /** word0 ")).toBe(true);
|
|
100
|
+
expect(out).toContain("\n * word");
|
|
101
|
+
expect(out.endsWith(" */\n")).toBe(true);
|
|
102
|
+
expect(out.split("\n").every((line) => line.length <= 100)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("gives the bare lines, for a comment laid out another way", () => {
|
|
106
|
+
expect(wrapLines("a */ b", "")).toEqual(["a *\\/ b"]);
|
|
107
|
+
expect(wrapLines("one two three", " ".repeat(86))).toEqual(["one two", "three"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("cannot be closed early by the text", () => {
|
|
111
|
+
expect(docComment("Matches /files/*/ only.", "")).toBe("/** Matches /files/*\\/ only. */\n");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("typeNames", () => {
|
|
116
|
+
it("finds the names inside a generic, once, without TypeScript's own", () => {
|
|
117
|
+
expect(typeNames("V1ListPage<JobDto>[] | Record<string, JobDto> | null")).toEqual([
|
|
118
|
+
"V1ListPage",
|
|
119
|
+
"JobDto",
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("names", () => {
|
|
125
|
+
it("turns a tool name into a method name and a type name", () => {
|
|
126
|
+
expect(camelCase("send_media_image")).toBe("sendMediaImage");
|
|
127
|
+
expect(pascalCase("send_media")).toBe("SendMedia");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("inputJsonSchema", () => {
|
|
132
|
+
it("says what to upgrade when a schema cannot describe itself", () => {
|
|
133
|
+
expect(() => inputJsonSchema({ "~standard": { vendor: "zod" } })).toThrow(/zod does from 4\.4/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("stops on a zod type JSON Schema cannot hold, rather than writing it as any value", () => {
|
|
137
|
+
expect(() => inputJsonSchema(z.object({ at: z.date() }))).toThrow(/Date cannot be represented/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("takes JSON Schema as it is", () => {
|
|
141
|
+
const schema = { type: "object" };
|
|
142
|
+
expect(inputJsonSchema(schema)).toBe(schema);
|
|
143
|
+
});
|
|
144
|
+
});
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing a JSON Schema as a TypeScript type, with each field's description kept as its doc
|
|
3
|
+
* comment.
|
|
4
|
+
*
|
|
5
|
+
* Five generators each wrote this, and the fixes were spread across them: `type: [..., "null"]`
|
|
6
|
+
* in three, `{}` as `unknown` and a record as `Record<string, T>` in two, a boolean schema and a
|
|
7
|
+
* tuple in one. The rule all five kept is the one that matters most: **never guess a type into a
|
|
8
|
+
* published SDK.** A schema node this does not understand throws; it never becomes `unknown`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type JsonObject = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
/** The part of the Standard JSON Schema interface this module calls: zod 4.4 and later. */
|
|
14
|
+
export interface StandardJsonSchema {
|
|
15
|
+
"~standard": {
|
|
16
|
+
vendor: string;
|
|
17
|
+
jsonSchema?: {
|
|
18
|
+
input(options: { target: "draft-2020-12"; libraryOptions?: JsonObject }): JsonObject;
|
|
19
|
+
output(options: { target: "draft-2020-12"; libraryOptions?: JsonObject }): JsonObject;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A schema: a Standard JSON Schema such as a zod object, or JSON Schema itself. */
|
|
25
|
+
export type SchemaSource = StandardJsonSchema | JsonObject;
|
|
26
|
+
|
|
27
|
+
/** The schema as JSON Schema, describing what a caller SENDS. */
|
|
28
|
+
export function inputJsonSchema(source: SchemaSource): JsonObject {
|
|
29
|
+
if (!isStandard(source)) return source;
|
|
30
|
+
const standard = source["~standard"];
|
|
31
|
+
if (standard.jsonSchema === undefined) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`This ${standard.vendor} schema cannot describe itself as JSON Schema. zod does from 4.4.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return standard.jsonSchema.input({ target: "draft-2020-12" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isStandard(source: SchemaSource): source is StandardJsonSchema {
|
|
40
|
+
return "~standard" in source;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Keywords that describe a value without constraining it, so `{ description }` is still `{}`. */
|
|
44
|
+
const ANNOTATIONS = new Set([
|
|
45
|
+
"$schema",
|
|
46
|
+
"$comment",
|
|
47
|
+
"title",
|
|
48
|
+
"description",
|
|
49
|
+
"default",
|
|
50
|
+
"examples",
|
|
51
|
+
"deprecated",
|
|
52
|
+
"readOnly",
|
|
53
|
+
"writeOnly",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** Names TypeScript itself provides, so a mention of one is not a declaration to find. */
|
|
57
|
+
export const BUILTIN = new Set([
|
|
58
|
+
"Array",
|
|
59
|
+
"ArrayBuffer",
|
|
60
|
+
"Awaited",
|
|
61
|
+
"Blob",
|
|
62
|
+
"Capitalize",
|
|
63
|
+
"Date",
|
|
64
|
+
"Error",
|
|
65
|
+
"Exclude",
|
|
66
|
+
"Extract",
|
|
67
|
+
"Lowercase",
|
|
68
|
+
"Map",
|
|
69
|
+
"NonNullable",
|
|
70
|
+
"Omit",
|
|
71
|
+
"Parameters",
|
|
72
|
+
"Partial",
|
|
73
|
+
"Pick",
|
|
74
|
+
"Promise",
|
|
75
|
+
"Readonly",
|
|
76
|
+
"ReadonlyArray",
|
|
77
|
+
"ReadonlyMap",
|
|
78
|
+
"ReadonlySet",
|
|
79
|
+
"Record",
|
|
80
|
+
"RegExp",
|
|
81
|
+
"Required",
|
|
82
|
+
"ReturnType",
|
|
83
|
+
"Set",
|
|
84
|
+
"URL",
|
|
85
|
+
"Uint8Array",
|
|
86
|
+
"Uncapitalize",
|
|
87
|
+
"Uppercase",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
function isObject(value: unknown): value is JsonObject {
|
|
91
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A schema that is `true`, `false` or an object; anything else is not a schema. */
|
|
95
|
+
function asSchema(value: unknown): JsonObject | boolean {
|
|
96
|
+
if (typeof value === "boolean" || isObject(value)) return value;
|
|
97
|
+
throw new Error(`Not a JSON Schema: ${JSON.stringify(value)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The TypeScript type for one schema. `indent` is where the enclosing line starts, so an inline
|
|
102
|
+
* object's fields land one level in.
|
|
103
|
+
*/
|
|
104
|
+
export function typeOf(schema: JsonObject | boolean, indent = ""): string {
|
|
105
|
+
if (typeof schema === "boolean") return schema ? "unknown" : "never";
|
|
106
|
+
const { enum: members, const: constant, type } = schema;
|
|
107
|
+
if (Array.isArray(members)) return members.map((v) => JSON.stringify(v)).join(" | ");
|
|
108
|
+
if (constant !== undefined) return JSON.stringify(constant);
|
|
109
|
+
const union = schema["anyOf"] ?? schema["oneOf"];
|
|
110
|
+
if (Array.isArray(union)) return union.map((s) => typeOf(asSchema(s), indent)).join(" | ");
|
|
111
|
+
// `type: ["number", "null"]`: each member carries the rest of the node, such as its items.
|
|
112
|
+
if (Array.isArray(type))
|
|
113
|
+
return type.map((t) => typeOf({ ...schema, type: t }, indent)).join(" | ");
|
|
114
|
+
// The EMPTY schema, `{}`, is "any value": what zod writes for `z.unknown()`. There is no
|
|
115
|
+
// narrower type to give it, so this is a shape understood, not a guess.
|
|
116
|
+
if (Object.keys(schema).every((key) => ANNOTATIONS.has(key))) return "unknown";
|
|
117
|
+
|
|
118
|
+
switch (type) {
|
|
119
|
+
case "string":
|
|
120
|
+
return "string";
|
|
121
|
+
case "number":
|
|
122
|
+
case "integer":
|
|
123
|
+
return "number";
|
|
124
|
+
case "boolean":
|
|
125
|
+
return "boolean";
|
|
126
|
+
case "null":
|
|
127
|
+
return "null";
|
|
128
|
+
case "array": {
|
|
129
|
+
const items = schema["items"];
|
|
130
|
+
// Fixed positions, each with its own type: `[number, number]`, never `number[]`, which
|
|
131
|
+
// would accept one.
|
|
132
|
+
const fixed = schema["prefixItems"] ?? (Array.isArray(items) ? items : undefined);
|
|
133
|
+
if (Array.isArray(fixed)) {
|
|
134
|
+
const rest =
|
|
135
|
+
Array.isArray(items) || items === undefined || items === false
|
|
136
|
+
? ""
|
|
137
|
+
: `, ...${typeOf(asSchema(items), indent)}[]`;
|
|
138
|
+
return `[${fixed.map((m) => typeOf(asSchema(m), indent)).join(", ")}${rest}]`;
|
|
139
|
+
}
|
|
140
|
+
const item = items === undefined ? "unknown" : typeOf(asSchema(items), indent);
|
|
141
|
+
// `("a" | "b")[]`: without the parens the union swallows the array, and only the last
|
|
142
|
+
// member becomes one.
|
|
143
|
+
return item.includes(" | ") ? `(${item})[]` : `${item}[]`;
|
|
144
|
+
}
|
|
145
|
+
case "object": {
|
|
146
|
+
const fields = fieldsOf(schema, `${indent} `);
|
|
147
|
+
if (fields) return `{\n${fields}${indent}}`;
|
|
148
|
+
const values = schema["additionalProperties"];
|
|
149
|
+
// A record, such as headers: no named fields, one type for every value.
|
|
150
|
+
return isObject(values)
|
|
151
|
+
? `Record<string, ${typeOf(values, indent)}>`
|
|
152
|
+
: "Record<string, unknown>";
|
|
153
|
+
}
|
|
154
|
+
default:
|
|
155
|
+
throw new Error(`Cannot write this JSON Schema as a type: ${JSON.stringify(schema)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* An object schema's fields, one per line, each description kept as a doc comment. `skip` leaves
|
|
161
|
+
* fields out, such as the ones a route fills in itself.
|
|
162
|
+
*/
|
|
163
|
+
export function fieldsOf(
|
|
164
|
+
schema: JsonObject,
|
|
165
|
+
indent = " ",
|
|
166
|
+
skip: readonly string[] = [],
|
|
167
|
+
): string {
|
|
168
|
+
const properties = isObject(schema["properties"]) ? schema["properties"] : {};
|
|
169
|
+
const required = new Set(Array.isArray(schema["required"]) ? schema["required"] : []);
|
|
170
|
+
let out = "";
|
|
171
|
+
for (const [name, raw] of Object.entries(properties)) {
|
|
172
|
+
if (skip.includes(name)) continue;
|
|
173
|
+
const field = asSchema(raw);
|
|
174
|
+
const description = typeof field === "object" ? field["description"] : undefined;
|
|
175
|
+
if (typeof description === "string") out += docComment(description, indent);
|
|
176
|
+
// A name that is not an identifier, such as `content-type`, has to be quoted.
|
|
177
|
+
const key = /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
|
|
178
|
+
out += `${indent}${key}${required.has(name) ? "" : "?"}: ${typeOf(field, indent)};\n`;
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `export interface SendMessageParams { … }`, or `null` when the schema has no fields left. The
|
|
185
|
+
* interface is `optional` when no remaining field is required, so the method's argument can be.
|
|
186
|
+
*/
|
|
187
|
+
export function paramsInterface(
|
|
188
|
+
name: string,
|
|
189
|
+
schema: JsonObject,
|
|
190
|
+
skip: readonly string[] = [],
|
|
191
|
+
): { source: string; optional: boolean } | null {
|
|
192
|
+
const fields = fieldsOf(schema, " ", skip);
|
|
193
|
+
if (!fields) return null;
|
|
194
|
+
const required = Array.isArray(schema["required"]) ? schema["required"] : [];
|
|
195
|
+
return {
|
|
196
|
+
source: `export interface ${name} {\n${fields}}\n`,
|
|
197
|
+
optional: !required.some((field) => typeof field === "string" && !skip.includes(field)),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Prose broken into lines of at most `96 - indent.length` characters, to sit inside a comment at
|
|
203
|
+
* that indent. Every caller puts them in one, so a star-slash is broken up here: it would end the
|
|
204
|
+
* comment early and turn the rest of the sentence into code.
|
|
205
|
+
*
|
|
206
|
+
* ```ts
|
|
207
|
+
* wrapLines(op.description, " ").join("\n * ");
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
export function wrapLines(text: string, indent = " "): string[] {
|
|
211
|
+
const width = 96 - indent.length;
|
|
212
|
+
const lines: string[] = [];
|
|
213
|
+
let line = "";
|
|
214
|
+
for (const word of text.replace(/\*\//g, "*\\/").split(/\s+/).filter(Boolean)) {
|
|
215
|
+
if (line && line.length + word.length + 1 > width) {
|
|
216
|
+
lines.push(line);
|
|
217
|
+
line = word;
|
|
218
|
+
} else line = line ? `${line} ${word}` : word;
|
|
219
|
+
}
|
|
220
|
+
if (line) lines.push(line);
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* `/** text *\/`, with continuation lines under the opener: the format all five generators wrote
|
|
226
|
+
* for a field, byte for byte.
|
|
227
|
+
*/
|
|
228
|
+
export function docComment(text: string, indent = " "): string {
|
|
229
|
+
return `${indent}/** ${wrapLines(text, indent).join(`\n${indent} * `)} */\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The declared names a type expression mentions: `"V1ListPage<JobDto>[] | null"` needs
|
|
234
|
+
* `V1ListPage` and `JobDto`. Splitting on `|` alone, as one generator did, misses what sits
|
|
235
|
+
* inside the generic.
|
|
236
|
+
*/
|
|
237
|
+
export function typeNames(expression: string): string[] {
|
|
238
|
+
const names = expression.match(/\b[A-Z][A-Za-z0-9_]*\b/g) ?? [];
|
|
239
|
+
return [...new Set(names)].filter((name) => !BUILTIN.has(name));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** `send_message` → `sendMessage`. */
|
|
243
|
+
export const camelCase = (name: string): string =>
|
|
244
|
+
name.replace(/[_-](\w)/g, (_m, c: string) => c.toUpperCase());
|
|
245
|
+
|
|
246
|
+
/** `send_message` → `SendMessage`. */
|
|
247
|
+
export const pascalCase = (name: string): string => {
|
|
248
|
+
const camel = camelCase(name);
|
|
249
|
+
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
250
|
+
};
|