@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/dist/types.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
/** The schema as JSON Schema, describing what a caller SENDS. */
|
|
11
|
+
export function inputJsonSchema(source) {
|
|
12
|
+
if (!isStandard(source))
|
|
13
|
+
return source;
|
|
14
|
+
const standard = source["~standard"];
|
|
15
|
+
if (standard.jsonSchema === undefined) {
|
|
16
|
+
throw new Error(`This ${standard.vendor} schema cannot describe itself as JSON Schema. zod does from 4.4.`);
|
|
17
|
+
}
|
|
18
|
+
return standard.jsonSchema.input({ target: "draft-2020-12" });
|
|
19
|
+
}
|
|
20
|
+
function isStandard(source) {
|
|
21
|
+
return "~standard" in source;
|
|
22
|
+
}
|
|
23
|
+
/** Keywords that describe a value without constraining it, so `{ description }` is still `{}`. */
|
|
24
|
+
const ANNOTATIONS = new Set([
|
|
25
|
+
"$schema",
|
|
26
|
+
"$comment",
|
|
27
|
+
"title",
|
|
28
|
+
"description",
|
|
29
|
+
"default",
|
|
30
|
+
"examples",
|
|
31
|
+
"deprecated",
|
|
32
|
+
"readOnly",
|
|
33
|
+
"writeOnly",
|
|
34
|
+
]);
|
|
35
|
+
/** Names TypeScript itself provides, so a mention of one is not a declaration to find. */
|
|
36
|
+
export const BUILTIN = new Set([
|
|
37
|
+
"Array",
|
|
38
|
+
"ArrayBuffer",
|
|
39
|
+
"Awaited",
|
|
40
|
+
"Blob",
|
|
41
|
+
"Capitalize",
|
|
42
|
+
"Date",
|
|
43
|
+
"Error",
|
|
44
|
+
"Exclude",
|
|
45
|
+
"Extract",
|
|
46
|
+
"Lowercase",
|
|
47
|
+
"Map",
|
|
48
|
+
"NonNullable",
|
|
49
|
+
"Omit",
|
|
50
|
+
"Parameters",
|
|
51
|
+
"Partial",
|
|
52
|
+
"Pick",
|
|
53
|
+
"Promise",
|
|
54
|
+
"Readonly",
|
|
55
|
+
"ReadonlyArray",
|
|
56
|
+
"ReadonlyMap",
|
|
57
|
+
"ReadonlySet",
|
|
58
|
+
"Record",
|
|
59
|
+
"RegExp",
|
|
60
|
+
"Required",
|
|
61
|
+
"ReturnType",
|
|
62
|
+
"Set",
|
|
63
|
+
"URL",
|
|
64
|
+
"Uint8Array",
|
|
65
|
+
"Uncapitalize",
|
|
66
|
+
"Uppercase",
|
|
67
|
+
]);
|
|
68
|
+
function isObject(value) {
|
|
69
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
/** A schema that is `true`, `false` or an object; anything else is not a schema. */
|
|
72
|
+
function asSchema(value) {
|
|
73
|
+
if (typeof value === "boolean" || isObject(value))
|
|
74
|
+
return value;
|
|
75
|
+
throw new Error(`Not a JSON Schema: ${JSON.stringify(value)}`);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The TypeScript type for one schema. `indent` is where the enclosing line starts, so an inline
|
|
79
|
+
* object's fields land one level in.
|
|
80
|
+
*/
|
|
81
|
+
export function typeOf(schema, indent = "") {
|
|
82
|
+
if (typeof schema === "boolean")
|
|
83
|
+
return schema ? "unknown" : "never";
|
|
84
|
+
const { enum: members, const: constant, type } = schema;
|
|
85
|
+
if (Array.isArray(members))
|
|
86
|
+
return members.map((v) => JSON.stringify(v)).join(" | ");
|
|
87
|
+
if (constant !== undefined)
|
|
88
|
+
return JSON.stringify(constant);
|
|
89
|
+
const union = schema["anyOf"] ?? schema["oneOf"];
|
|
90
|
+
if (Array.isArray(union))
|
|
91
|
+
return union.map((s) => typeOf(asSchema(s), indent)).join(" | ");
|
|
92
|
+
// `type: ["number", "null"]`: each member carries the rest of the node, such as its items.
|
|
93
|
+
if (Array.isArray(type))
|
|
94
|
+
return type.map((t) => typeOf({ ...schema, type: t }, indent)).join(" | ");
|
|
95
|
+
// The EMPTY schema, `{}`, is "any value": what zod writes for `z.unknown()`. There is no
|
|
96
|
+
// narrower type to give it, so this is a shape understood, not a guess.
|
|
97
|
+
if (Object.keys(schema).every((key) => ANNOTATIONS.has(key)))
|
|
98
|
+
return "unknown";
|
|
99
|
+
switch (type) {
|
|
100
|
+
case "string":
|
|
101
|
+
return "string";
|
|
102
|
+
case "number":
|
|
103
|
+
case "integer":
|
|
104
|
+
return "number";
|
|
105
|
+
case "boolean":
|
|
106
|
+
return "boolean";
|
|
107
|
+
case "null":
|
|
108
|
+
return "null";
|
|
109
|
+
case "array": {
|
|
110
|
+
const items = schema["items"];
|
|
111
|
+
// Fixed positions, each with its own type: `[number, number]`, never `number[]`, which
|
|
112
|
+
// would accept one.
|
|
113
|
+
const fixed = schema["prefixItems"] ?? (Array.isArray(items) ? items : undefined);
|
|
114
|
+
if (Array.isArray(fixed)) {
|
|
115
|
+
const rest = Array.isArray(items) || items === undefined || items === false
|
|
116
|
+
? ""
|
|
117
|
+
: `, ...${typeOf(asSchema(items), indent)}[]`;
|
|
118
|
+
return `[${fixed.map((m) => typeOf(asSchema(m), indent)).join(", ")}${rest}]`;
|
|
119
|
+
}
|
|
120
|
+
const item = items === undefined ? "unknown" : typeOf(asSchema(items), indent);
|
|
121
|
+
// `("a" | "b")[]`: without the parens the union swallows the array, and only the last
|
|
122
|
+
// member becomes one.
|
|
123
|
+
return item.includes(" | ") ? `(${item})[]` : `${item}[]`;
|
|
124
|
+
}
|
|
125
|
+
case "object": {
|
|
126
|
+
const fields = fieldsOf(schema, `${indent} `);
|
|
127
|
+
if (fields)
|
|
128
|
+
return `{\n${fields}${indent}}`;
|
|
129
|
+
const values = schema["additionalProperties"];
|
|
130
|
+
// A record, such as headers: no named fields, one type for every value.
|
|
131
|
+
return isObject(values)
|
|
132
|
+
? `Record<string, ${typeOf(values, indent)}>`
|
|
133
|
+
: "Record<string, unknown>";
|
|
134
|
+
}
|
|
135
|
+
default:
|
|
136
|
+
throw new Error(`Cannot write this JSON Schema as a type: ${JSON.stringify(schema)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* An object schema's fields, one per line, each description kept as a doc comment. `skip` leaves
|
|
141
|
+
* fields out, such as the ones a route fills in itself.
|
|
142
|
+
*/
|
|
143
|
+
export function fieldsOf(schema, indent = " ", skip = []) {
|
|
144
|
+
const properties = isObject(schema["properties"]) ? schema["properties"] : {};
|
|
145
|
+
const required = new Set(Array.isArray(schema["required"]) ? schema["required"] : []);
|
|
146
|
+
let out = "";
|
|
147
|
+
for (const [name, raw] of Object.entries(properties)) {
|
|
148
|
+
if (skip.includes(name))
|
|
149
|
+
continue;
|
|
150
|
+
const field = asSchema(raw);
|
|
151
|
+
const description = typeof field === "object" ? field["description"] : undefined;
|
|
152
|
+
if (typeof description === "string")
|
|
153
|
+
out += docComment(description, indent);
|
|
154
|
+
// A name that is not an identifier, such as `content-type`, has to be quoted.
|
|
155
|
+
const key = /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
|
|
156
|
+
out += `${indent}${key}${required.has(name) ? "" : "?"}: ${typeOf(field, indent)};\n`;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* `export interface SendMessageParams { … }`, or `null` when the schema has no fields left. The
|
|
162
|
+
* interface is `optional` when no remaining field is required, so the method's argument can be.
|
|
163
|
+
*/
|
|
164
|
+
export function paramsInterface(name, schema, skip = []) {
|
|
165
|
+
const fields = fieldsOf(schema, " ", skip);
|
|
166
|
+
if (!fields)
|
|
167
|
+
return null;
|
|
168
|
+
const required = Array.isArray(schema["required"]) ? schema["required"] : [];
|
|
169
|
+
return {
|
|
170
|
+
source: `export interface ${name} {\n${fields}}\n`,
|
|
171
|
+
optional: !required.some((field) => typeof field === "string" && !skip.includes(field)),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Prose broken into lines of at most `96 - indent.length` characters, to sit inside a comment at
|
|
176
|
+
* that indent. Every caller puts them in one, so a star-slash is broken up here: it would end the
|
|
177
|
+
* comment early and turn the rest of the sentence into code.
|
|
178
|
+
*
|
|
179
|
+
* ```ts
|
|
180
|
+
* wrapLines(op.description, " ").join("\n * ");
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
export function wrapLines(text, indent = " ") {
|
|
184
|
+
const width = 96 - indent.length;
|
|
185
|
+
const lines = [];
|
|
186
|
+
let line = "";
|
|
187
|
+
for (const word of text.replace(/\*\//g, "*\\/").split(/\s+/).filter(Boolean)) {
|
|
188
|
+
if (line && line.length + word.length + 1 > width) {
|
|
189
|
+
lines.push(line);
|
|
190
|
+
line = word;
|
|
191
|
+
}
|
|
192
|
+
else
|
|
193
|
+
line = line ? `${line} ${word}` : word;
|
|
194
|
+
}
|
|
195
|
+
if (line)
|
|
196
|
+
lines.push(line);
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* `/** text *\/`, with continuation lines under the opener: the format all five generators wrote
|
|
201
|
+
* for a field, byte for byte.
|
|
202
|
+
*/
|
|
203
|
+
export function docComment(text, indent = " ") {
|
|
204
|
+
return `${indent}/** ${wrapLines(text, indent).join(`\n${indent} * `)} */\n`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The declared names a type expression mentions: `"V1ListPage<JobDto>[] | null"` needs
|
|
208
|
+
* `V1ListPage` and `JobDto`. Splitting on `|` alone, as one generator did, misses what sits
|
|
209
|
+
* inside the generic.
|
|
210
|
+
*/
|
|
211
|
+
export function typeNames(expression) {
|
|
212
|
+
const names = expression.match(/\b[A-Z][A-Za-z0-9_]*\b/g) ?? [];
|
|
213
|
+
return [...new Set(names)].filter((name) => !BUILTIN.has(name));
|
|
214
|
+
}
|
|
215
|
+
/** `send_message` → `sendMessage`. */
|
|
216
|
+
export const camelCase = (name) => name.replace(/[_-](\w)/g, (_m, c) => c.toUpperCase());
|
|
217
|
+
/** `send_message` → `SendMessage`. */
|
|
218
|
+
export const pascalCase = (name) => {
|
|
219
|
+
const camel = camelCase(name);
|
|
220
|
+
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
221
|
+
};
|
|
222
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/write.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The generated text for each file, by path: absolute, or relative to `root`. */
|
|
2
|
+
export type GeneratedFiles = Readonly<Record<string, string>>;
|
|
3
|
+
export interface WriteOptions {
|
|
4
|
+
/** What a relative path in `files` starts from, usually the repo root. Default: the working directory. */
|
|
5
|
+
root?: string;
|
|
6
|
+
/** Write nothing, and list the files that would change. */
|
|
7
|
+
check?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Paths as `files` spells them, so a message can print them as they are. */
|
|
10
|
+
export interface WriteResult {
|
|
11
|
+
/** Files that were missing or different and were written. Always empty when checking. */
|
|
12
|
+
written: string[];
|
|
13
|
+
/** Files that are missing or differ from what the API says today. Always empty when writing. */
|
|
14
|
+
stale: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Formats each file with the prettier config that applies at its path, then writes it — or, with
|
|
18
|
+
* `check`, writes nothing and lists the files that would change.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const { stale } = await writeGenerated(files, { root, check: process.argv.includes("--check") });
|
|
22
|
+
* if (stale.length > 0) {
|
|
23
|
+
* console.error(`Out of date:\n ${stale.join("\n ")}\nRun \`bun run sdk:gen\` and commit.`);
|
|
24
|
+
* process.exitCode = 1;
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function writeGenerated(files: GeneratedFiles, { root, check }?: WriteOptions): Promise<WriteResult>;
|
|
29
|
+
//# sourceMappingURL=write.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../src/write.ts"],"names":[],"mappings":"AAWA,kFAAkF;AAClF,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAE9D,MAAM,WAAW,YAAY;IAC3B,0GAA0G;IAC1G,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2DAA2D;IAC3D,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,6EAA6E;AAC7E,MAAM,WAAW,WAAW;IAC1B,yFAAyF;IACzF,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,gGAAgG;IAChG,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,cAAc,EACrB,EAAE,IAAU,EAAE,KAAa,EAAE,GAAE,YAAiB,GAC/C,OAAO,CAAC,WAAW,CAAC,CAetB"}
|
package/dist/write.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing generated files, or proving the ones on disk are current.
|
|
3
|
+
*
|
|
4
|
+
* Every file goes through the repo's own prettier config first. The output is checked in and read
|
|
5
|
+
* by whoever opens the SDK, and formatting it here is also what keeps `format:check` and the
|
|
6
|
+
* generator's own check from ever disagreeing about one file.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import { format, resolveConfig } from "prettier";
|
|
11
|
+
/**
|
|
12
|
+
* Formats each file with the prettier config that applies at its path, then writes it — or, with
|
|
13
|
+
* `check`, writes nothing and lists the files that would change.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* const { stale } = await writeGenerated(files, { root, check: process.argv.includes("--check") });
|
|
17
|
+
* if (stale.length > 0) {
|
|
18
|
+
* console.error(`Out of date:\n ${stale.join("\n ")}\nRun \`bun run sdk:gen\` and commit.`);
|
|
19
|
+
* process.exitCode = 1;
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export async function writeGenerated(files, { root = ".", check = false } = {}) {
|
|
24
|
+
const result = { written: [], stale: [] };
|
|
25
|
+
for (const [name, text] of Object.entries(files)) {
|
|
26
|
+
const path = resolve(root, name);
|
|
27
|
+
const formatted = await format(text, { ...(await resolveConfig(path)), filepath: path });
|
|
28
|
+
if (readOrNull(path) === formatted)
|
|
29
|
+
continue;
|
|
30
|
+
if (check) {
|
|
31
|
+
result.stale.push(name);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
35
|
+
writeFileSync(path, formatted);
|
|
36
|
+
result.written.push(name);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
function readOrNull(path) {
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(path, "utf8");
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
// Missing is stale. Anything else, such as a permission error, is not an answer to "is it
|
|
46
|
+
// current", so it throws.
|
|
47
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT")
|
|
48
|
+
return null;
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=write.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"write.js","sourceRoot":"","sources":["../src/write.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACjE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAoBjD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,KAAqB,EACrB,EAAE,IAAI,GAAG,GAAG,EAAE,KAAK,GAAG,KAAK,KAAmB,EAAE;IAEhD,MAAM,MAAM,GAAgB,EAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;IACvD,KAAK,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACjD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACjC,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,EAAE,GAAG,CAAC,MAAM,aAAa,CAAC,IAAI,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QACzF,IAAI,UAAU,CAAC,IAAI,CAAC,KAAK,SAAS;YAAE,SAAS;QAC7C,IAAI,KAAK,EAAE,CAAC;YACV,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACxB,SAAS;QACX,CAAC;QACD,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9C,aAAa,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;QAC/B,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,UAAU,CAAC,IAAY;IAC9B,IAAI,CAAC;QACH,OAAO,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,0FAA0F;QAC1F,0BAA0B;QAC1B,IAAI,GAAG,YAAY,KAAK,IAAI,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QAChF,MAAM,GAAG,CAAC;IACZ,CAAC;AACH,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gusnips/sdkgen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The pieces of an SDK generator: copy your API's types with their comments, write a schema as a TypeScript type, and keep the output current.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Gustavo Salomé",
|
|
8
|
+
"homepage": "https://github.com/gusnips/serverkit/tree/main/sdkgen#readme",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/gusnips/serverkit/issues"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"sdk",
|
|
34
|
+
"codegen",
|
|
35
|
+
"json-schema",
|
|
36
|
+
"typescript",
|
|
37
|
+
"zod"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"lint": "eslint src",
|
|
45
|
+
"sync:docs": "cp ../LICENSE .",
|
|
46
|
+
"prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build && bun run sync:docs",
|
|
47
|
+
"release:patch": "bun pm version patch && bun publish --access public",
|
|
48
|
+
"release:minor": "bun pm version minor && bun publish --access public",
|
|
49
|
+
"release:major": "bun pm version major && bun publish --access public"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"prettier": ">=3.4 <4"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^24.12.0",
|
|
56
|
+
"prettier": "^3.4.2",
|
|
57
|
+
"typescript": "^5.9.3",
|
|
58
|
+
"vitest": "^4.1.2",
|
|
59
|
+
"zod": "^4.4.3"
|
|
60
|
+
},
|
|
61
|
+
"repository": {
|
|
62
|
+
"type": "git",
|
|
63
|
+
"url": "git+https://github.com/gusnips/serverkit.git",
|
|
64
|
+
"directory": "sdkgen"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { blankCommentsAndStrings, liftContract } from "./contract.ts";
|
|
6
|
+
|
|
7
|
+
function repo(files: Record<string, string>): string {
|
|
8
|
+
const root = mkdtempSync(join(tmpdir(), "sdkgen-"));
|
|
9
|
+
for (const [name, text] of Object.entries(files)) writeFileSync(join(root, name), text);
|
|
10
|
+
return root;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const HTTP = `import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
/** Every code the API answers with. */
|
|
16
|
+
export const ERROR_STATUS = {
|
|
17
|
+
VALIDATION_ERROR: 400,
|
|
18
|
+
NOT_FOUND: 404,
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export type ErrorCode = keyof typeof ERROR_STATUS;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A refusal.
|
|
25
|
+
* The message is for a log; the code is for a program.
|
|
26
|
+
*/
|
|
27
|
+
export interface ApiError {
|
|
28
|
+
code: ErrorCode;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const internal = 1;
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const DTO = `/** One message; see Status. */
|
|
36
|
+
export interface MessageDto {
|
|
37
|
+
id: string;
|
|
38
|
+
/** Where it is; "Sent" means the server took it; not a type. */
|
|
39
|
+
status: MessageStatus;
|
|
40
|
+
sentAt: Date | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const STATUSES = [
|
|
44
|
+
/** It's waiting; nothing has gone out. */
|
|
45
|
+
"queued",
|
|
46
|
+
"sent",
|
|
47
|
+
"failed",
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
export type MessageStatus = (typeof STATUSES)[number];
|
|
51
|
+
|
|
52
|
+
export type Page<T> = { items: T[]; next: string | null };
|
|
53
|
+
|
|
54
|
+
export type ByStatus = { [K in MessageStatus]: number; };
|
|
55
|
+
|
|
56
|
+
export function isSent(message: MessageDto): boolean {
|
|
57
|
+
return message.status === "sent";
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
describe("liftContract", () => {
|
|
62
|
+
const root = repo({ "http.ts": HTTP, "dto.ts": DTO });
|
|
63
|
+
const lift = (roots: string[], inlineTuples = false) =>
|
|
64
|
+
liftContract({ root, sources: ["http.ts", "dto.ts"], roots, inlineTuples });
|
|
65
|
+
|
|
66
|
+
it("copies a declaration with its comment, and what it mentions, in source order", () => {
|
|
67
|
+
const out = lift(["ApiError"]);
|
|
68
|
+
expect(out).toContain("/** Every code the API answers with. */\nexport const ERROR_STATUS");
|
|
69
|
+
expect(out).toContain("export type ErrorCode = keyof typeof ERROR_STATUS;");
|
|
70
|
+
expect(out).toContain(
|
|
71
|
+
" * A refusal.\n * The message is for a log; the code is for a program.\n */",
|
|
72
|
+
);
|
|
73
|
+
expect(out.indexOf("ERROR_STATUS =")).toBeLessThan(out.indexOf("interface ApiError"));
|
|
74
|
+
expect(out).not.toContain("internal");
|
|
75
|
+
expect(out).not.toContain("MessageDto");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does not read a property key as a type to find", () => {
|
|
79
|
+
// VALIDATION_ERROR and NOT_FOUND are keys of ERROR_STATUS; declared nowhere, they would throw.
|
|
80
|
+
expect(() => lift(["ERROR_STATUS"])).not.toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("copies a tuple as it is written, its members' comments included", () => {
|
|
84
|
+
const out = lift(["MessageDto"]);
|
|
85
|
+
expect(out).toContain(
|
|
86
|
+
"export const STATUSES = [\n /** It's waiting; nothing has gone out. */",
|
|
87
|
+
);
|
|
88
|
+
expect(out).toContain("export type MessageStatus = (typeof STATUSES)[number];");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("can write a tuple's element type as its literal union, and leave the array behind", () => {
|
|
92
|
+
// The apostrophe in the member's comment is not a quote.
|
|
93
|
+
const out = lift(["MessageDto"], true);
|
|
94
|
+
expect(out).toContain('export type MessageStatus = "queued" | "sent" | "failed";');
|
|
95
|
+
expect(out).not.toContain("STATUSES");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("ignores words in comments and strings, and a semicolon inside a mapped type", () => {
|
|
99
|
+
// "Status" and "Sent" in the prose are not types; the mapped type's `;` is inside braces.
|
|
100
|
+
const out = lift(["MessageDto", "ByStatus"]);
|
|
101
|
+
expect(out).toContain("export type ByStatus = { [K in MessageStatus]: number; };");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("can keep export on the roots only", () => {
|
|
105
|
+
const out = lift(["ApiError"]).replace(/\n+/g, "\n");
|
|
106
|
+
expect(out).toContain("export const ERROR_STATUS");
|
|
107
|
+
const quiet = liftContract({
|
|
108
|
+
root,
|
|
109
|
+
sources: ["http.ts", "dto.ts"],
|
|
110
|
+
roots: ["ApiError"],
|
|
111
|
+
exportOnlyRoots: true,
|
|
112
|
+
});
|
|
113
|
+
expect(quiet).toContain("/** Every code the API answers with. */\nconst ERROR_STATUS = {");
|
|
114
|
+
expect(quiet).toContain("\ntype ErrorCode = keyof typeof ERROR_STATUS;");
|
|
115
|
+
expect(quiet).toContain("\nexport interface ApiError {");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("copies an exported function whole", () => {
|
|
119
|
+
expect(lift(["isSent"])).toContain(' return message.status === "sent";\n}');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("names what it could not find, and where it looked", () => {
|
|
123
|
+
expect(() => lift(["Missing"])).toThrow(
|
|
124
|
+
/No declaration found for Missing\. Export it from one of:\n {2}http\.ts\n {2}dto\.ts/,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("unescapes a quoted member before writing it", () => {
|
|
129
|
+
const quoted = repo({
|
|
130
|
+
"a.ts": `export const KINDS = ["a", 'b\\'c'] as const;\n\nexport type Kind = (typeof KINDS)[number];\n`,
|
|
131
|
+
});
|
|
132
|
+
expect(
|
|
133
|
+
liftContract({ root: quoted, sources: ["a.ts"], roots: ["Kind"], inlineTuples: true }),
|
|
134
|
+
).toContain(`export type Kind = "a" | "b'c";`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("refuses a member escape it cannot read, rather than writing a wrong member", () => {
|
|
138
|
+
const escaped = repo({
|
|
139
|
+
"a.ts": `export const KINDS = ["a\\nb"] as const;\n\nexport type Kind = (typeof KINDS)[number];\n`,
|
|
140
|
+
});
|
|
141
|
+
expect(() =>
|
|
142
|
+
liftContract({ root: escaped, sources: ["a.ts"], roots: ["Kind"], inlineTuples: true }),
|
|
143
|
+
).toThrow(/KINDS has a member with an escape this cannot read: "a\\nb"/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("refuses a tuple it cannot read as string literals", () => {
|
|
147
|
+
const odd = repo({
|
|
148
|
+
"a.ts": `export const SIZES = [1, 2] as const;\n\nexport type Size = (typeof SIZES)[number];\n`,
|
|
149
|
+
});
|
|
150
|
+
expect(() =>
|
|
151
|
+
liftContract({ root: odd, sources: ["a.ts"], roots: ["Size"], inlineTuples: true }),
|
|
152
|
+
).toThrow(/SIZES must be a tuple of string literals/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps every later declaration in place after an escaped newline", () => {
|
|
156
|
+
const tricky = repo({
|
|
157
|
+
"a.ts": [
|
|
158
|
+
'export const HELP = "one \\',
|
|
159
|
+
'two";',
|
|
160
|
+
"",
|
|
161
|
+
"/** The plan. */",
|
|
162
|
+
"export interface Plan {",
|
|
163
|
+
" id: string;",
|
|
164
|
+
"}",
|
|
165
|
+
"",
|
|
166
|
+
].join("\n"),
|
|
167
|
+
});
|
|
168
|
+
expect(liftContract({ root: tricky, sources: ["a.ts"], roots: ["Plan"] })).toContain(
|
|
169
|
+
"/** The plan. */\nexport interface Plan {\n id: string;\n}",
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
it("ends a one-line interface on its own line", () => {
|
|
173
|
+
const flat = repo({
|
|
174
|
+
"a.ts": "export interface Empty {}\n\nexport interface Plan {\n id: string;\n}\n",
|
|
175
|
+
});
|
|
176
|
+
const out = liftContract({ root: flat, sources: ["a.ts"], roots: ["Empty"] });
|
|
177
|
+
expect(out).toContain("export interface Empty {}");
|
|
178
|
+
expect(out).not.toContain("Plan");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("blankCommentsAndStrings", () => {
|
|
183
|
+
it("keeps every line and every column", () => {
|
|
184
|
+
const source = 'const a = "x;\\"y"; // Type\n/* Also\n a Type */ const b = `t\\\nu`;\n';
|
|
185
|
+
const clean = blankCommentsAndStrings(source);
|
|
186
|
+
expect(clean.length).toBe(source.length);
|
|
187
|
+
expect(clean.split("\n").length).toBe(source.split("\n").length);
|
|
188
|
+
expect(clean).not.toMatch(/Type|x;|y/);
|
|
189
|
+
expect(clean).toContain("const b =");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("ends a line comment at its newline, even after a backslash", () => {
|
|
193
|
+
expect(blankCommentsAndStrings("// C:\\\nexport type X = 1;")).toBe(
|
|
194
|
+
" \nexport type X = 1;",
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("does not close a comment on the star that opened it", () => {
|
|
199
|
+
expect(blankCommentsAndStrings("/*/ Type */ x")).toBe(" x");
|
|
200
|
+
});
|
|
201
|
+
});
|