trickle-backend 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/dist/db/connection.d.ts +3 -0
- package/dist/db/connection.js +16 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.js +51 -0
- package/dist/db/queries.d.ts +70 -0
- package/dist/db/queries.js +186 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +10 -0
- package/dist/routes/audit.d.ts +2 -0
- package/dist/routes/audit.js +251 -0
- package/dist/routes/codegen.d.ts +2 -0
- package/dist/routes/codegen.js +224 -0
- package/dist/routes/coverage.d.ts +2 -0
- package/dist/routes/coverage.js +98 -0
- package/dist/routes/dashboard.d.ts +2 -0
- package/dist/routes/dashboard.js +433 -0
- package/dist/routes/diff.d.ts +2 -0
- package/dist/routes/diff.js +181 -0
- package/dist/routes/errors.d.ts +2 -0
- package/dist/routes/errors.js +86 -0
- package/dist/routes/functions.d.ts +2 -0
- package/dist/routes/functions.js +69 -0
- package/dist/routes/ingest.d.ts +2 -0
- package/dist/routes/ingest.js +111 -0
- package/dist/routes/mock.d.ts +2 -0
- package/dist/routes/mock.js +57 -0
- package/dist/routes/search.d.ts +2 -0
- package/dist/routes/search.js +136 -0
- package/dist/routes/tail.d.ts +2 -0
- package/dist/routes/tail.js +11 -0
- package/dist/routes/types.d.ts +2 -0
- package/dist/routes/types.js +97 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +40 -0
- package/dist/services/sse-broker.d.ts +10 -0
- package/dist/services/sse-broker.js +39 -0
- package/dist/services/type-differ.d.ts +2 -0
- package/dist/services/type-differ.js +126 -0
- package/dist/services/type-generator.d.ts +319 -0
- package/dist/services/type-generator.js +3207 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +2 -0
- package/package.json +22 -0
- package/src/db/connection.ts +16 -0
- package/src/db/migrations.ts +50 -0
- package/src/db/queries.ts +260 -0
- package/src/index.ts +11 -0
- package/src/routes/audit.ts +283 -0
- package/src/routes/codegen.ts +237 -0
- package/src/routes/coverage.ts +120 -0
- package/src/routes/dashboard.ts +435 -0
- package/src/routes/diff.ts +215 -0
- package/src/routes/errors.ts +91 -0
- package/src/routes/functions.ts +75 -0
- package/src/routes/ingest.ts +139 -0
- package/src/routes/mock.ts +66 -0
- package/src/routes/search.ts +169 -0
- package/src/routes/tail.ts +12 -0
- package/src/routes/types.ts +106 -0
- package/src/server.ts +40 -0
- package/src/services/sse-broker.ts +51 -0
- package/src/services/type-differ.ts +141 -0
- package/src/services/type-generator.ts +3853 -0
- package/src/types.ts +37 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,3207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateFunctionTypes = generateFunctionTypes;
|
|
4
|
+
exports.generateAllTypes = generateAllTypes;
|
|
5
|
+
exports.generatePythonTypes = generatePythonTypes;
|
|
6
|
+
exports.generateApiClient = generateApiClient;
|
|
7
|
+
exports.generateOpenApiSpec = generateOpenApiSpec;
|
|
8
|
+
exports.generateHandlerTypes = generateHandlerTypes;
|
|
9
|
+
exports.generateZodSchemas = generateZodSchemas;
|
|
10
|
+
exports.generateReactQueryHooks = generateReactQueryHooks;
|
|
11
|
+
exports.generateTypeGuards = generateTypeGuards;
|
|
12
|
+
exports.generateMiddleware = generateMiddleware;
|
|
13
|
+
exports.generateMswHandlers = generateMswHandlers;
|
|
14
|
+
exports.generateJsonSchemas = generateJsonSchemas;
|
|
15
|
+
exports.generateSwrHooks = generateSwrHooks;
|
|
16
|
+
exports.generatePydanticModels = generatePydanticModels;
|
|
17
|
+
exports.generateClassValidatorDtos = generateClassValidatorDtos;
|
|
18
|
+
exports.generateGraphqlSchema = generateGraphqlSchema;
|
|
19
|
+
exports.generateTrpcRouter = generateTrpcRouter;
|
|
20
|
+
exports.generateAxiosClient = generateAxiosClient;
|
|
21
|
+
exports.generateInlineAnnotations = generateInlineAnnotations;
|
|
22
|
+
exports.typeNodeToTSPublic = typeNodeToTS;
|
|
23
|
+
// ── Naming helpers ──
|
|
24
|
+
function toPascalCase(name) {
|
|
25
|
+
// Sanitize route-style names like "GET /api/users/:id" → "GetApiUsersId"
|
|
26
|
+
// Also handles camelCase/PascalCase input by splitting on boundaries
|
|
27
|
+
return name
|
|
28
|
+
.replace(/[^a-zA-Z0-9]+/g, " ") // replace non-alphanumeric with spaces
|
|
29
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2") // split camelCase: "userId" → "user Id"
|
|
30
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") // split acronyms: "XMLParser" → "XML Parser"
|
|
31
|
+
.trim()
|
|
32
|
+
.split(/\s+/)
|
|
33
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
34
|
+
.join("");
|
|
35
|
+
}
|
|
36
|
+
function toSnakeCase(name) {
|
|
37
|
+
return name
|
|
38
|
+
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
39
|
+
.replace(/[-\s]+/g, "_")
|
|
40
|
+
.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
function formatTimeAgo(isoDate) {
|
|
43
|
+
try {
|
|
44
|
+
const date = new Date(isoDate);
|
|
45
|
+
const now = new Date();
|
|
46
|
+
const diffMs = now.getTime() - date.getTime();
|
|
47
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
48
|
+
if (diffSec < 60)
|
|
49
|
+
return `${diffSec}s ago`;
|
|
50
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
51
|
+
if (diffMin < 60)
|
|
52
|
+
return `${diffMin}m ago`;
|
|
53
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
54
|
+
if (diffHr < 24)
|
|
55
|
+
return `${diffHr}h ago`;
|
|
56
|
+
const diffDay = Math.floor(diffHr / 24);
|
|
57
|
+
return `${diffDay}d ago`;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return isoDate;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Convert a TypeNode to a TypeScript type string.
|
|
65
|
+
*
|
|
66
|
+
* Large nested objects (>2 properties) are extracted into named interfaces
|
|
67
|
+
* so the output stays readable.
|
|
68
|
+
*/
|
|
69
|
+
function typeNodeToTS(node, extracted, parentName, propName, indent) {
|
|
70
|
+
switch (node.kind) {
|
|
71
|
+
case "primitive":
|
|
72
|
+
return node.name;
|
|
73
|
+
case "unknown":
|
|
74
|
+
return "unknown";
|
|
75
|
+
case "array": {
|
|
76
|
+
const inner = typeNodeToTS(node.element, extracted, parentName, propName, indent);
|
|
77
|
+
if (node.element.kind === "union" || node.element.kind === "function") {
|
|
78
|
+
return `Array<${inner}>`;
|
|
79
|
+
}
|
|
80
|
+
return `${inner}[]`;
|
|
81
|
+
}
|
|
82
|
+
case "tuple": {
|
|
83
|
+
const elements = node.elements.map((el, i) => typeNodeToTS(el, extracted, parentName, `${propName || "el"}${i}`, indent));
|
|
84
|
+
return `[${elements.join(", ")}]`;
|
|
85
|
+
}
|
|
86
|
+
case "union": {
|
|
87
|
+
const members = node.members.map((m) => typeNodeToTS(m, extracted, parentName, propName, indent));
|
|
88
|
+
return members.join(" | ");
|
|
89
|
+
}
|
|
90
|
+
case "map": {
|
|
91
|
+
const k = typeNodeToTS(node.key, extracted, parentName, "key", indent);
|
|
92
|
+
const v = typeNodeToTS(node.value, extracted, parentName, "value", indent);
|
|
93
|
+
return `Map<${k}, ${v}>`;
|
|
94
|
+
}
|
|
95
|
+
case "set": {
|
|
96
|
+
const inner = typeNodeToTS(node.element, extracted, parentName, propName, indent);
|
|
97
|
+
return `Set<${inner}>`;
|
|
98
|
+
}
|
|
99
|
+
case "promise": {
|
|
100
|
+
const inner = typeNodeToTS(node.resolved, extracted, parentName, propName, indent);
|
|
101
|
+
return `Promise<${inner}>`;
|
|
102
|
+
}
|
|
103
|
+
case "function": {
|
|
104
|
+
const params = node.params.map((p, i) => `arg${i}: ${typeNodeToTS(p, extracted, parentName, `param${i}`, indent)}`);
|
|
105
|
+
const ret = typeNodeToTS(node.returnType, extracted, parentName, "return", indent);
|
|
106
|
+
return `(${params.join(", ")}) => ${ret}`;
|
|
107
|
+
}
|
|
108
|
+
case "object": {
|
|
109
|
+
const keys = Object.keys(node.properties);
|
|
110
|
+
if (keys.length === 0)
|
|
111
|
+
return "Record<string, never>";
|
|
112
|
+
// Extract large nested objects into named interfaces for readability
|
|
113
|
+
if (keys.length > 2 && propName) {
|
|
114
|
+
const ifaceName = toPascalCase(parentName) + toPascalCase(propName);
|
|
115
|
+
// Avoid duplicate extractions
|
|
116
|
+
if (!extracted.some((e) => e.name === ifaceName)) {
|
|
117
|
+
extracted.push({ name: ifaceName, node });
|
|
118
|
+
}
|
|
119
|
+
return ifaceName;
|
|
120
|
+
}
|
|
121
|
+
// Inline small objects
|
|
122
|
+
const pad = " ".repeat(indent + 1);
|
|
123
|
+
const closePad = " ".repeat(indent);
|
|
124
|
+
const entries = keys.map((key) => {
|
|
125
|
+
const val = typeNodeToTS(node.properties[key], extracted, parentName, key, indent + 1);
|
|
126
|
+
return `${pad}${key}: ${val};`;
|
|
127
|
+
});
|
|
128
|
+
return `{\n${entries.join("\n")}\n${closePad}}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Render a named interface from an extracted object TypeNode.
|
|
134
|
+
* Recursively extracts any further nested objects.
|
|
135
|
+
*/
|
|
136
|
+
function renderInterface(name, node, allExtracted) {
|
|
137
|
+
const keys = Object.keys(node.properties);
|
|
138
|
+
const lines = [];
|
|
139
|
+
lines.push(`export interface ${name} {`);
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
const val = typeNodeToTS(node.properties[key], allExtracted, name, key, 1);
|
|
142
|
+
lines.push(` ${key}: ${val};`);
|
|
143
|
+
}
|
|
144
|
+
lines.push(`}`);
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Generate TypeScript definitions for a single function.
|
|
149
|
+
*
|
|
150
|
+
* For a function `processOrder(order)` that takes an object and returns an object:
|
|
151
|
+
* ```ts
|
|
152
|
+
* export interface ProcessOrderInput { id: string; customer: Customer; ... }
|
|
153
|
+
* export interface ProcessOrderOutput { orderId: string; total: number; ... }
|
|
154
|
+
* export declare function processOrder(order: ProcessOrderInput): ProcessOrderOutput;
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
function generateFunctionTypes(functionName, argsType, returnType, meta) {
|
|
158
|
+
const baseName = toPascalCase(functionName);
|
|
159
|
+
const extracted = [];
|
|
160
|
+
const lines = [];
|
|
161
|
+
// Metadata comment
|
|
162
|
+
const metaParts = [];
|
|
163
|
+
if (meta?.module)
|
|
164
|
+
metaParts.push(`${meta.module} module`);
|
|
165
|
+
if (meta?.env)
|
|
166
|
+
metaParts.push(`observed in ${meta.env}`);
|
|
167
|
+
if (meta?.observedAt)
|
|
168
|
+
metaParts.push(formatTimeAgo(meta.observedAt));
|
|
169
|
+
const metaStr = metaParts.length > 0 ? ` — ${metaParts.join(", ")}` : "";
|
|
170
|
+
// ── Determine argument structure ──
|
|
171
|
+
// argsType is typically a tuple of the function's positional args.
|
|
172
|
+
// For a single-object arg, we use that object directly as the "Input" interface.
|
|
173
|
+
// For multiple args, we generate separate types and a function declaration with individual params.
|
|
174
|
+
let argEntries = [];
|
|
175
|
+
if (argsType.kind === "tuple") {
|
|
176
|
+
argEntries = argsType.elements.map((el, i) => ({
|
|
177
|
+
paramName: `arg${i}`,
|
|
178
|
+
typeNode: el,
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
else if (argsType.kind === "object") {
|
|
182
|
+
// Named params from Python kwargs
|
|
183
|
+
for (const key of Object.keys(argsType.properties)) {
|
|
184
|
+
argEntries.push({ paramName: key, typeNode: argsType.properties[key] });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
argEntries = [{ paramName: "input", typeNode: argsType }];
|
|
189
|
+
}
|
|
190
|
+
// ── Single-object shortcut ──
|
|
191
|
+
// If there's exactly one arg and it's an object, promote it to a named interface.
|
|
192
|
+
const singleObjectArg = argEntries.length === 1 && argEntries[0].typeNode.kind === "object";
|
|
193
|
+
// ── Generate input type(s) ──
|
|
194
|
+
if (singleObjectArg) {
|
|
195
|
+
const inputName = `${baseName}Input`;
|
|
196
|
+
const objNode = argEntries[0].typeNode;
|
|
197
|
+
// Extract nested objects from within the input
|
|
198
|
+
const inputBody = renderInterface(inputName, objNode, extracted);
|
|
199
|
+
lines.push(`/**`);
|
|
200
|
+
lines.push(` * Input type for \`${functionName}\`${metaStr}`);
|
|
201
|
+
lines.push(` */`);
|
|
202
|
+
lines.push(inputBody);
|
|
203
|
+
lines.push("");
|
|
204
|
+
}
|
|
205
|
+
else if (argEntries.length > 1) {
|
|
206
|
+
// Multiple args — generate a type for each non-primitive arg
|
|
207
|
+
for (let i = 0; i < argEntries.length; i++) {
|
|
208
|
+
const entry = argEntries[i];
|
|
209
|
+
if (entry.typeNode.kind === "object" && Object.keys(entry.typeNode.properties).length > 0) {
|
|
210
|
+
const typeName = `${baseName}${toPascalCase(entry.paramName)}`;
|
|
211
|
+
const body = renderInterface(typeName, entry.typeNode, extracted);
|
|
212
|
+
lines.push(body);
|
|
213
|
+
lines.push("");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// ── Generate output type ──
|
|
218
|
+
const outputName = `${baseName}Output`;
|
|
219
|
+
if (returnType.kind === "object" && Object.keys(returnType.properties).length > 0) {
|
|
220
|
+
const outputBody = renderInterface(outputName, returnType, extracted);
|
|
221
|
+
lines.push(`/**`);
|
|
222
|
+
lines.push(` * Output type for \`${functionName}\`${metaStr}`);
|
|
223
|
+
lines.push(` */`);
|
|
224
|
+
lines.push(outputBody);
|
|
225
|
+
lines.push("");
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const retStr = typeNodeToTS(returnType, extracted, baseName, undefined, 0);
|
|
229
|
+
lines.push(`/**`);
|
|
230
|
+
lines.push(` * Output type for \`${functionName}\`${metaStr}`);
|
|
231
|
+
lines.push(` */`);
|
|
232
|
+
lines.push(`export type ${outputName} = ${retStr};`);
|
|
233
|
+
lines.push("");
|
|
234
|
+
}
|
|
235
|
+
// ── Emit extracted interfaces (dependencies) ──
|
|
236
|
+
// Process in order: render each, which may add more extractions.
|
|
237
|
+
const emitted = new Set();
|
|
238
|
+
const extractedLines = [];
|
|
239
|
+
let cursor = 0;
|
|
240
|
+
while (cursor < extracted.length) {
|
|
241
|
+
const iface = extracted[cursor];
|
|
242
|
+
cursor++;
|
|
243
|
+
if (emitted.has(iface.name))
|
|
244
|
+
continue;
|
|
245
|
+
emitted.add(iface.name);
|
|
246
|
+
extractedLines.push(renderInterface(iface.name, iface.node, extracted));
|
|
247
|
+
extractedLines.push("");
|
|
248
|
+
}
|
|
249
|
+
// ── Build function declaration ──
|
|
250
|
+
// Use camelCase version of baseName for function declaration identifier
|
|
251
|
+
const funcIdent = baseName.charAt(0).toLowerCase() + baseName.slice(1);
|
|
252
|
+
let funcDecl;
|
|
253
|
+
if (singleObjectArg) {
|
|
254
|
+
const inputName = `${baseName}Input`;
|
|
255
|
+
funcDecl = `export declare function ${funcIdent}(input: ${inputName}): ${outputName};`;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
// Build param list with proper types
|
|
259
|
+
const params = argEntries.map((entry) => {
|
|
260
|
+
if (entry.typeNode.kind === "object" && Object.keys(entry.typeNode.properties).length > 0) {
|
|
261
|
+
return `${entry.paramName}: ${baseName}${toPascalCase(entry.paramName)}`;
|
|
262
|
+
}
|
|
263
|
+
return `${entry.paramName}: ${typeNodeToTS(entry.typeNode, extracted, baseName, entry.paramName, 0)}`;
|
|
264
|
+
});
|
|
265
|
+
funcDecl = `export declare function ${funcIdent}(${params.join(", ")}): ${outputName};`;
|
|
266
|
+
}
|
|
267
|
+
// ── Assemble output ──
|
|
268
|
+
// Order: extracted interfaces → input → output → function declaration
|
|
269
|
+
const result = [];
|
|
270
|
+
if (extractedLines.length > 0) {
|
|
271
|
+
result.push(...extractedLines);
|
|
272
|
+
}
|
|
273
|
+
result.push(...lines);
|
|
274
|
+
result.push(funcDecl);
|
|
275
|
+
return result.join("\n");
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Generate a complete TypeScript declarations file for all functions.
|
|
279
|
+
*/
|
|
280
|
+
function generateAllTypes(functions) {
|
|
281
|
+
const sections = [];
|
|
282
|
+
sections.push("// Auto-generated by trickle from runtime type observations");
|
|
283
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
284
|
+
sections.push("// Do not edit manually — re-run `trickle codegen` to update");
|
|
285
|
+
sections.push("");
|
|
286
|
+
for (const fn of functions) {
|
|
287
|
+
sections.push(generateFunctionTypes(fn.name, fn.argsType, fn.returnType, {
|
|
288
|
+
module: fn.module,
|
|
289
|
+
env: fn.env,
|
|
290
|
+
observedAt: fn.observedAt,
|
|
291
|
+
}));
|
|
292
|
+
sections.push("");
|
|
293
|
+
}
|
|
294
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
295
|
+
}
|
|
296
|
+
// ── Python stub generation ──
|
|
297
|
+
function typeNodeToPython(node, extracted, parentName, propName, depth) {
|
|
298
|
+
switch (node.kind) {
|
|
299
|
+
case "primitive":
|
|
300
|
+
switch (node.name) {
|
|
301
|
+
case "string": return "str";
|
|
302
|
+
case "number": return "float";
|
|
303
|
+
case "boolean": return "bool";
|
|
304
|
+
case "null": return "None";
|
|
305
|
+
case "undefined": return "None";
|
|
306
|
+
case "bigint": return "int";
|
|
307
|
+
case "symbol": return "str";
|
|
308
|
+
default: return "Any";
|
|
309
|
+
}
|
|
310
|
+
case "unknown":
|
|
311
|
+
return "Any";
|
|
312
|
+
case "array": {
|
|
313
|
+
const inner = typeNodeToPython(node.element, extracted, parentName, propName, depth + 1);
|
|
314
|
+
return `List[${inner}]`;
|
|
315
|
+
}
|
|
316
|
+
case "tuple": {
|
|
317
|
+
const elements = node.elements.map((el, i) => typeNodeToPython(el, extracted, parentName, `el${i}`, depth + 1));
|
|
318
|
+
return `Tuple[${elements.join(", ")}]`;
|
|
319
|
+
}
|
|
320
|
+
case "union": {
|
|
321
|
+
const members = node.members.map((m) => typeNodeToPython(m, extracted, parentName, propName, depth + 1));
|
|
322
|
+
if (members.length === 2 && members.includes("None")) {
|
|
323
|
+
const nonNone = members.find((m) => m !== "None");
|
|
324
|
+
return `Optional[${nonNone}]`;
|
|
325
|
+
}
|
|
326
|
+
return `Union[${members.join(", ")}]`;
|
|
327
|
+
}
|
|
328
|
+
case "map": {
|
|
329
|
+
const k = typeNodeToPython(node.key, extracted, parentName, "key", depth + 1);
|
|
330
|
+
const v = typeNodeToPython(node.value, extracted, parentName, "value", depth + 1);
|
|
331
|
+
return `Dict[${k}, ${v}]`;
|
|
332
|
+
}
|
|
333
|
+
case "set": {
|
|
334
|
+
const inner = typeNodeToPython(node.element, extracted, parentName, propName, depth + 1);
|
|
335
|
+
return `Set[${inner}]`;
|
|
336
|
+
}
|
|
337
|
+
case "promise": {
|
|
338
|
+
const inner = typeNodeToPython(node.resolved, extracted, parentName, propName, depth + 1);
|
|
339
|
+
return `Awaitable[${inner}]`;
|
|
340
|
+
}
|
|
341
|
+
case "function": {
|
|
342
|
+
const params = node.params.map((p) => typeNodeToPython(p, extracted, parentName, undefined, depth + 1));
|
|
343
|
+
const ret = typeNodeToPython(node.returnType, extracted, parentName, "return", depth + 1);
|
|
344
|
+
return `Callable[[${params.join(", ")}], ${ret}]`;
|
|
345
|
+
}
|
|
346
|
+
case "object": {
|
|
347
|
+
const keys = Object.keys(node.properties);
|
|
348
|
+
if (keys.length === 0)
|
|
349
|
+
return "Dict[str, Any]";
|
|
350
|
+
// Always extract objects as named TypedDicts
|
|
351
|
+
if (propName) {
|
|
352
|
+
const className = toPascalCase(parentName) + toPascalCase(propName);
|
|
353
|
+
if (!extracted.some((e) => e.name === className)) {
|
|
354
|
+
extracted.push({ name: className, node });
|
|
355
|
+
}
|
|
356
|
+
return className;
|
|
357
|
+
}
|
|
358
|
+
return "Dict[str, Any]";
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function renderPythonTypedDict(name, node, extracted) {
|
|
363
|
+
const keys = Object.keys(node.properties);
|
|
364
|
+
const innerExtracted = [];
|
|
365
|
+
const lines = [];
|
|
366
|
+
const entries = keys.map((key) => {
|
|
367
|
+
const pyType = typeNodeToPython(node.properties[key], innerExtracted, name, key, 1);
|
|
368
|
+
return ` ${toSnakeCase(key)}: ${pyType}`;
|
|
369
|
+
});
|
|
370
|
+
// Emit nested TypedDicts first
|
|
371
|
+
for (const iface of innerExtracted) {
|
|
372
|
+
lines.push(renderPythonTypedDict(iface.name, iface.node, innerExtracted));
|
|
373
|
+
lines.push("");
|
|
374
|
+
lines.push("");
|
|
375
|
+
}
|
|
376
|
+
lines.push(`class ${name}(TypedDict):`);
|
|
377
|
+
if (entries.length === 0) {
|
|
378
|
+
lines.push(" pass");
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
lines.push(...entries);
|
|
382
|
+
}
|
|
383
|
+
return lines.join("\n");
|
|
384
|
+
}
|
|
385
|
+
function generatePythonTypes(functions) {
|
|
386
|
+
const sections = [];
|
|
387
|
+
sections.push("# Auto-generated by trickle from runtime type observations");
|
|
388
|
+
sections.push(`# Generated at ${new Date().toISOString()}`);
|
|
389
|
+
sections.push("# Do not edit manually — re-run `trickle codegen --python` to update");
|
|
390
|
+
sections.push("");
|
|
391
|
+
sections.push("from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, TypedDict, Union");
|
|
392
|
+
sections.push("");
|
|
393
|
+
sections.push("");
|
|
394
|
+
for (const fn of functions) {
|
|
395
|
+
const baseName = toPascalCase(fn.name);
|
|
396
|
+
const extracted = [];
|
|
397
|
+
// Meta comment
|
|
398
|
+
const metaParts = [];
|
|
399
|
+
if (fn.module)
|
|
400
|
+
metaParts.push(`${fn.module} module`);
|
|
401
|
+
if (fn.env)
|
|
402
|
+
metaParts.push(`observed in ${fn.env}`);
|
|
403
|
+
if (fn.observedAt)
|
|
404
|
+
metaParts.push(formatTimeAgo(fn.observedAt));
|
|
405
|
+
if (metaParts.length > 0) {
|
|
406
|
+
sections.push(`# ${baseName} — ${metaParts.join(", ")}`);
|
|
407
|
+
}
|
|
408
|
+
// Input type
|
|
409
|
+
if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1 && fn.argsType.elements[0].kind === "object") {
|
|
410
|
+
// Single-object arg: use it directly
|
|
411
|
+
sections.push(renderPythonTypedDict(`${baseName}Input`, fn.argsType.elements[0], extracted));
|
|
412
|
+
}
|
|
413
|
+
else if (fn.argsType.kind === "object") {
|
|
414
|
+
sections.push(renderPythonTypedDict(`${baseName}Input`, fn.argsType, extracted));
|
|
415
|
+
}
|
|
416
|
+
else if (fn.argsType.kind === "tuple") {
|
|
417
|
+
// Multiple args — create a TypedDict with param names
|
|
418
|
+
const fakeObj = { kind: "object", properties: {} };
|
|
419
|
+
fn.argsType.elements.forEach((el, i) => {
|
|
420
|
+
fakeObj.properties[`arg${i}`] = el;
|
|
421
|
+
});
|
|
422
|
+
sections.push(renderPythonTypedDict(`${baseName}Args`, fakeObj, extracted));
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
const pyType = typeNodeToPython(fn.argsType, extracted, baseName, undefined, 0);
|
|
426
|
+
sections.push(`${baseName}Input = ${pyType}`);
|
|
427
|
+
}
|
|
428
|
+
sections.push("");
|
|
429
|
+
sections.push("");
|
|
430
|
+
// Output type
|
|
431
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
432
|
+
sections.push(renderPythonTypedDict(`${baseName}Output`, fn.returnType, extracted));
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
const pyType = typeNodeToPython(fn.returnType, extracted, baseName, undefined, 0);
|
|
436
|
+
sections.push(`${baseName}Output = ${pyType}`);
|
|
437
|
+
}
|
|
438
|
+
// Emit extracted TypedDicts that were accumulated
|
|
439
|
+
const emitted = new Set();
|
|
440
|
+
const pendingExtracted = [];
|
|
441
|
+
for (const iface of extracted) {
|
|
442
|
+
if (emitted.has(iface.name))
|
|
443
|
+
continue;
|
|
444
|
+
emitted.add(iface.name);
|
|
445
|
+
pendingExtracted.push("");
|
|
446
|
+
pendingExtracted.push("");
|
|
447
|
+
pendingExtracted.push(renderPythonTypedDict(iface.name, iface.node, extracted));
|
|
448
|
+
}
|
|
449
|
+
// Insert extracted defs before the last output (they need to be defined first)
|
|
450
|
+
// Actually, since Python TypedDicts reference forward, just append
|
|
451
|
+
if (pendingExtracted.length > 0) {
|
|
452
|
+
// Re-order: put extracted before the Input/Output that reference them
|
|
453
|
+
// For simplicity, just include them — Python TypedDict forward refs work with __future__.annotations
|
|
454
|
+
}
|
|
455
|
+
sections.push("");
|
|
456
|
+
sections.push("");
|
|
457
|
+
}
|
|
458
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
459
|
+
}
|
|
460
|
+
function parseRouteName(name) {
|
|
461
|
+
const match = name.match(/^(GET|POST|PUT|DELETE|PATCH)\s+(.+)$/i);
|
|
462
|
+
if (!match)
|
|
463
|
+
return null;
|
|
464
|
+
const method = match[1].toUpperCase();
|
|
465
|
+
const path = match[2];
|
|
466
|
+
// Extract path params like :id, :userId
|
|
467
|
+
const pathParams = [];
|
|
468
|
+
path.replace(/:(\w+)/g, (_, param) => {
|
|
469
|
+
pathParams.push(param);
|
|
470
|
+
return _;
|
|
471
|
+
});
|
|
472
|
+
const typeName = toPascalCase(name);
|
|
473
|
+
// For camelCase: lowercase the HTTP method prefix (e.g., GETApiUsers → getApiUsers)
|
|
474
|
+
const methodLower = method.charAt(0).toUpperCase() + method.slice(1).toLowerCase();
|
|
475
|
+
const pathPart = toPascalCase(path);
|
|
476
|
+
const funcName = method.toLowerCase() + pathPart;
|
|
477
|
+
return { method, path, pathParams, funcName, typeName };
|
|
478
|
+
}
|
|
479
|
+
function toCamelCase(name) {
|
|
480
|
+
const pascal = toPascalCase(name);
|
|
481
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Generate a fully-typed fetch-based API client from runtime-observed routes.
|
|
485
|
+
*
|
|
486
|
+
* Output is a single TypeScript file with:
|
|
487
|
+
* - All request/response interfaces
|
|
488
|
+
* - A `createTrickleClient(baseUrl)` factory that returns typed fetch wrappers
|
|
489
|
+
* - Proper path parameter substitution
|
|
490
|
+
* - Request body typing for POST/PUT/PATCH
|
|
491
|
+
*/
|
|
492
|
+
function generateApiClient(functions) {
|
|
493
|
+
// Filter to route-style functions only
|
|
494
|
+
const routes = [];
|
|
495
|
+
for (const fn of functions) {
|
|
496
|
+
const parsed = parseRouteName(fn.name);
|
|
497
|
+
if (parsed) {
|
|
498
|
+
routes.push({ parsed, fn });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (routes.length === 0) {
|
|
502
|
+
return "// No API routes found. Instrument your Express/FastAPI app to generate a typed client.\n";
|
|
503
|
+
}
|
|
504
|
+
const sections = [];
|
|
505
|
+
sections.push("// Auto-generated typed API client by trickle");
|
|
506
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
507
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --client` to update");
|
|
508
|
+
sections.push("");
|
|
509
|
+
// Generate interfaces for each route
|
|
510
|
+
const extracted = [];
|
|
511
|
+
for (const { parsed, fn } of routes) {
|
|
512
|
+
const baseName = parsed.typeName;
|
|
513
|
+
// --- Input types (only for methods with body) ---
|
|
514
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
515
|
+
// argsType is typically an object with { body, params, query } from Express instrumentation
|
|
516
|
+
if (fn.argsType.kind === "object") {
|
|
517
|
+
const bodyNode = fn.argsType.properties["body"];
|
|
518
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
519
|
+
const inputName = `${baseName}Input`;
|
|
520
|
+
sections.push(renderInterface(inputName, bodyNode, extracted));
|
|
521
|
+
sections.push("");
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
|
|
525
|
+
const el = fn.argsType.elements[0];
|
|
526
|
+
if (el.kind === "object") {
|
|
527
|
+
const bodyNode = el.properties["body"];
|
|
528
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
529
|
+
const inputName = `${baseName}Input`;
|
|
530
|
+
sections.push(renderInterface(inputName, bodyNode, extracted));
|
|
531
|
+
sections.push("");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// --- Path params type (if route has :params) ---
|
|
537
|
+
if (parsed.pathParams.length > 0) {
|
|
538
|
+
// Try to extract from argsType.properties.params
|
|
539
|
+
let paramsNode;
|
|
540
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["params"]) {
|
|
541
|
+
paramsNode = fn.argsType.properties["params"];
|
|
542
|
+
}
|
|
543
|
+
// Only generate if we have object-typed params
|
|
544
|
+
if (paramsNode && paramsNode.kind === "object" && Object.keys(paramsNode.properties).length > 0) {
|
|
545
|
+
const paramsName = `${baseName}Params`;
|
|
546
|
+
sections.push(renderInterface(paramsName, paramsNode, extracted));
|
|
547
|
+
sections.push("");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// --- Query params type ---
|
|
551
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
|
|
552
|
+
const queryNode = fn.argsType.properties["query"];
|
|
553
|
+
if (queryNode.kind === "object" && Object.keys(queryNode.properties).length > 0) {
|
|
554
|
+
const queryName = `${baseName}Query`;
|
|
555
|
+
sections.push(renderInterface(queryName, queryNode, extracted));
|
|
556
|
+
sections.push("");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// --- Output type ---
|
|
560
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
561
|
+
const outputName = `${baseName}Output`;
|
|
562
|
+
sections.push(renderInterface(outputName, fn.returnType, extracted));
|
|
563
|
+
sections.push("");
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
const outputName = `${baseName}Output`;
|
|
567
|
+
const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
|
|
568
|
+
sections.push(`export type ${outputName} = ${retStr};`);
|
|
569
|
+
sections.push("");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Emit extracted sub-interfaces
|
|
573
|
+
const emitted = new Set();
|
|
574
|
+
const extractedLines = [];
|
|
575
|
+
let cursor = 0;
|
|
576
|
+
while (cursor < extracted.length) {
|
|
577
|
+
const iface = extracted[cursor];
|
|
578
|
+
cursor++;
|
|
579
|
+
if (emitted.has(iface.name))
|
|
580
|
+
continue;
|
|
581
|
+
emitted.add(iface.name);
|
|
582
|
+
extractedLines.push(renderInterface(iface.name, iface.node, extracted));
|
|
583
|
+
extractedLines.push("");
|
|
584
|
+
}
|
|
585
|
+
if (extractedLines.length > 0) {
|
|
586
|
+
// Insert extracted interfaces before the main interfaces
|
|
587
|
+
sections.splice(4, 0, ...extractedLines);
|
|
588
|
+
}
|
|
589
|
+
// --- Generate the client factory ---
|
|
590
|
+
sections.push("// ── API Client ──");
|
|
591
|
+
sections.push("");
|
|
592
|
+
sections.push("export function createTrickleClient(baseUrl: string) {");
|
|
593
|
+
sections.push(" async function request<T>(method: string, path: string, body?: unknown, query?: Record<string, string>): Promise<T> {");
|
|
594
|
+
sections.push(" const url = new URL(path, baseUrl);");
|
|
595
|
+
sections.push(" if (query) { for (const [k, v] of Object.entries(query)) { if (v !== undefined) url.searchParams.set(k, v); } }");
|
|
596
|
+
sections.push(" const opts: RequestInit = { method, headers: { 'Content-Type': 'application/json' } };");
|
|
597
|
+
sections.push(" if (body !== undefined) opts.body = JSON.stringify(body);");
|
|
598
|
+
sections.push(" const res = await fetch(url.toString(), opts);");
|
|
599
|
+
sections.push(" if (!res.ok) throw new Error(`${method} ${path}: HTTP ${res.status}`);");
|
|
600
|
+
sections.push(" return res.json() as Promise<T>;");
|
|
601
|
+
sections.push(" }");
|
|
602
|
+
sections.push("");
|
|
603
|
+
sections.push(" return {");
|
|
604
|
+
for (const { parsed, fn } of routes) {
|
|
605
|
+
const baseName = parsed.typeName;
|
|
606
|
+
const outputType = `${baseName}Output`;
|
|
607
|
+
const hasBody = ["POST", "PUT", "PATCH"].includes(parsed.method);
|
|
608
|
+
// Determine if we have a typed input
|
|
609
|
+
let hasInputType = false;
|
|
610
|
+
if (hasBody && fn.argsType.kind === "object") {
|
|
611
|
+
const bodyNode = fn.argsType.properties["body"];
|
|
612
|
+
hasInputType = !!(bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0);
|
|
613
|
+
}
|
|
614
|
+
// Build path expression with template literals for path params
|
|
615
|
+
let pathExpr;
|
|
616
|
+
if (parsed.pathParams.length > 0) {
|
|
617
|
+
pathExpr = "`" + parsed.path.replace(/:(\w+)/g, (_, param) => `\${${toCamelCase(param)}}`) + "`";
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
pathExpr = `"${parsed.path}"`;
|
|
621
|
+
}
|
|
622
|
+
// Build method signature
|
|
623
|
+
const params = [];
|
|
624
|
+
if (parsed.pathParams.length > 0) {
|
|
625
|
+
for (const p of parsed.pathParams) {
|
|
626
|
+
params.push(`${toCamelCase(p)}: string`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (hasInputType) {
|
|
630
|
+
params.push(`input: ${baseName}Input`);
|
|
631
|
+
}
|
|
632
|
+
const bodyArg = hasInputType ? "input" : "undefined";
|
|
633
|
+
const paramStr = params.length > 0 ? params.join(", ") : "";
|
|
634
|
+
// Generate JSDoc comment
|
|
635
|
+
sections.push(` /** ${parsed.method} ${parsed.path} */`);
|
|
636
|
+
sections.push(` ${parsed.funcName}: (${paramStr}): Promise<${outputType}> =>`);
|
|
637
|
+
sections.push(` request<${outputType}>("${parsed.method}", ${pathExpr}, ${bodyArg}),`);
|
|
638
|
+
sections.push("");
|
|
639
|
+
}
|
|
640
|
+
sections.push(" };");
|
|
641
|
+
sections.push("}");
|
|
642
|
+
sections.push("");
|
|
643
|
+
sections.push("export type TrickleClient = ReturnType<typeof createTrickleClient>;");
|
|
644
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
645
|
+
}
|
|
646
|
+
// ── OpenAPI spec generation ──
|
|
647
|
+
/**
|
|
648
|
+
* Convert a TypeNode to a JSON Schema object.
|
|
649
|
+
*/
|
|
650
|
+
function typeNodeToJsonSchema(node, defs, parentName, propName) {
|
|
651
|
+
switch (node.kind) {
|
|
652
|
+
case "primitive":
|
|
653
|
+
switch (node.name) {
|
|
654
|
+
case "string": return { type: "string" };
|
|
655
|
+
case "number": return { type: "number" };
|
|
656
|
+
case "boolean": return { type: "boolean" };
|
|
657
|
+
case "null": return { type: "string", nullable: true };
|
|
658
|
+
case "undefined": return { type: "string", nullable: true };
|
|
659
|
+
case "bigint": return { type: "integer", format: "int64" };
|
|
660
|
+
case "symbol": return { type: "string" };
|
|
661
|
+
default: return {};
|
|
662
|
+
}
|
|
663
|
+
case "unknown":
|
|
664
|
+
return {};
|
|
665
|
+
case "array":
|
|
666
|
+
return {
|
|
667
|
+
type: "array",
|
|
668
|
+
items: typeNodeToJsonSchema(node.element, defs, parentName, propName),
|
|
669
|
+
};
|
|
670
|
+
case "tuple":
|
|
671
|
+
return {
|
|
672
|
+
type: "array",
|
|
673
|
+
items: node.elements.length > 0
|
|
674
|
+
? typeNodeToJsonSchema(node.elements[0], defs, parentName, propName)
|
|
675
|
+
: {},
|
|
676
|
+
minItems: node.elements.length,
|
|
677
|
+
maxItems: node.elements.length,
|
|
678
|
+
};
|
|
679
|
+
case "union": {
|
|
680
|
+
const schemas = node.members.map((m) => typeNodeToJsonSchema(m, defs, parentName, propName));
|
|
681
|
+
// Simplify nullable unions: { type: "string" } | null → nullable string
|
|
682
|
+
const nonNull = schemas.filter((s) => !("nullable" in s && s.nullable === true));
|
|
683
|
+
if (nonNull.length === 1 && nonNull.length < schemas.length) {
|
|
684
|
+
return { ...nonNull[0], nullable: true };
|
|
685
|
+
}
|
|
686
|
+
return { oneOf: schemas };
|
|
687
|
+
}
|
|
688
|
+
case "map":
|
|
689
|
+
return {
|
|
690
|
+
type: "object",
|
|
691
|
+
additionalProperties: typeNodeToJsonSchema(node.value, defs, parentName, propName),
|
|
692
|
+
};
|
|
693
|
+
case "set":
|
|
694
|
+
return {
|
|
695
|
+
type: "array",
|
|
696
|
+
items: typeNodeToJsonSchema(node.element, defs, parentName, propName),
|
|
697
|
+
uniqueItems: true,
|
|
698
|
+
};
|
|
699
|
+
case "promise":
|
|
700
|
+
return typeNodeToJsonSchema(node.resolved, defs, parentName, propName);
|
|
701
|
+
case "function":
|
|
702
|
+
return { type: "object", description: "function" };
|
|
703
|
+
case "object": {
|
|
704
|
+
const keys = Object.keys(node.properties);
|
|
705
|
+
if (keys.length === 0)
|
|
706
|
+
return { type: "object" };
|
|
707
|
+
// Extract complex objects as $ref to keep schemas readable
|
|
708
|
+
if (keys.length > 2 && propName) {
|
|
709
|
+
const schemaName = toPascalCase(parentName) + toPascalCase(propName);
|
|
710
|
+
if (!defs[schemaName]) {
|
|
711
|
+
// Placeholder to prevent infinite recursion
|
|
712
|
+
defs[schemaName] = {};
|
|
713
|
+
const properties = {};
|
|
714
|
+
const required = [];
|
|
715
|
+
for (const key of keys) {
|
|
716
|
+
properties[key] = typeNodeToJsonSchema(node.properties[key], defs, schemaName, key);
|
|
717
|
+
required.push(key);
|
|
718
|
+
}
|
|
719
|
+
defs[schemaName] = { type: "object", properties, required };
|
|
720
|
+
}
|
|
721
|
+
return { $ref: `#/components/schemas/${schemaName}` };
|
|
722
|
+
}
|
|
723
|
+
const properties = {};
|
|
724
|
+
const required = [];
|
|
725
|
+
for (const key of keys) {
|
|
726
|
+
properties[key] = typeNodeToJsonSchema(node.properties[key], defs, parentName, key);
|
|
727
|
+
required.push(key);
|
|
728
|
+
}
|
|
729
|
+
return { type: "object", properties, required };
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Generate an OpenAPI 3.0 specification from runtime-observed route types.
|
|
735
|
+
*/
|
|
736
|
+
function generateOpenApiSpec(functions, options) {
|
|
737
|
+
const title = options?.title || "API";
|
|
738
|
+
const version = options?.version || "1.0.0";
|
|
739
|
+
const paths = {};
|
|
740
|
+
const schemas = {};
|
|
741
|
+
for (const fn of functions) {
|
|
742
|
+
const parsed = parseRouteName(fn.name);
|
|
743
|
+
if (!parsed)
|
|
744
|
+
continue;
|
|
745
|
+
const method = parsed.method.toLowerCase();
|
|
746
|
+
const baseName = parsed.typeName;
|
|
747
|
+
// Convert Express :param to OpenAPI {param}
|
|
748
|
+
const openApiPath = parsed.path.replace(/:(\w+)/g, "{$1}");
|
|
749
|
+
// Build response schema
|
|
750
|
+
const responseDefs = {};
|
|
751
|
+
const responseSchema = typeNodeToJsonSchema(fn.returnType, responseDefs, baseName + "Output", undefined);
|
|
752
|
+
Object.assign(schemas, responseDefs);
|
|
753
|
+
// If response is a complex object, extract to a named schema
|
|
754
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
755
|
+
const responseSchemaName = `${baseName}Response`;
|
|
756
|
+
schemas[responseSchemaName] = responseSchema;
|
|
757
|
+
}
|
|
758
|
+
const responseRef = fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0
|
|
759
|
+
? { $ref: `#/components/schemas/${baseName}Response` }
|
|
760
|
+
: responseSchema;
|
|
761
|
+
// Build operation object
|
|
762
|
+
const operation = {
|
|
763
|
+
operationId: parsed.funcName,
|
|
764
|
+
summary: `${parsed.method} ${parsed.path}`,
|
|
765
|
+
responses: {
|
|
766
|
+
"200": {
|
|
767
|
+
description: "Successful response",
|
|
768
|
+
content: {
|
|
769
|
+
"application/json": {
|
|
770
|
+
schema: responseRef,
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
};
|
|
776
|
+
// Add path parameters
|
|
777
|
+
if (parsed.pathParams.length > 0) {
|
|
778
|
+
operation.parameters = parsed.pathParams.map((param) => ({
|
|
779
|
+
name: param,
|
|
780
|
+
in: "path",
|
|
781
|
+
required: true,
|
|
782
|
+
schema: { type: "string" },
|
|
783
|
+
}));
|
|
784
|
+
}
|
|
785
|
+
// Add query parameters from argsType
|
|
786
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
|
|
787
|
+
const queryNode = fn.argsType.properties["query"];
|
|
788
|
+
if (queryNode.kind === "object") {
|
|
789
|
+
const queryParams = Object.keys(queryNode.properties).map((param) => {
|
|
790
|
+
const paramSchema = typeNodeToJsonSchema(queryNode.properties[param], schemas, baseName, param);
|
|
791
|
+
return {
|
|
792
|
+
name: param,
|
|
793
|
+
in: "query",
|
|
794
|
+
required: false,
|
|
795
|
+
schema: paramSchema,
|
|
796
|
+
};
|
|
797
|
+
});
|
|
798
|
+
const existing = operation.parameters || [];
|
|
799
|
+
operation.parameters = [...existing, ...queryParams];
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// Add request body for POST/PUT/PATCH
|
|
803
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
804
|
+
let bodyNode;
|
|
805
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
806
|
+
bodyNode = fn.argsType.properties["body"];
|
|
807
|
+
}
|
|
808
|
+
else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
|
|
809
|
+
const el = fn.argsType.elements[0];
|
|
810
|
+
if (el.kind === "object" && el.properties["body"]) {
|
|
811
|
+
bodyNode = el.properties["body"];
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
815
|
+
const bodyDefs = {};
|
|
816
|
+
const bodySchema = typeNodeToJsonSchema(bodyNode, bodyDefs, baseName + "Request", undefined);
|
|
817
|
+
Object.assign(schemas, bodyDefs);
|
|
818
|
+
const requestSchemaName = `${baseName}Request`;
|
|
819
|
+
schemas[requestSchemaName] = bodySchema;
|
|
820
|
+
operation.requestBody = {
|
|
821
|
+
required: true,
|
|
822
|
+
content: {
|
|
823
|
+
"application/json": {
|
|
824
|
+
schema: { $ref: `#/components/schemas/${requestSchemaName}` },
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// Add tags based on path prefix
|
|
831
|
+
const pathParts = parsed.path.split("/").filter(Boolean);
|
|
832
|
+
if (pathParts.length >= 2) {
|
|
833
|
+
operation.tags = [pathParts[1]]; // e.g., /api/users → "users" tag would be pathParts[1]
|
|
834
|
+
// But /api is the first meaningful segment, so use the one after /api
|
|
835
|
+
if (pathParts[0] === "api" && pathParts.length >= 2) {
|
|
836
|
+
operation.tags = [pathParts[1]];
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (!paths[openApiPath]) {
|
|
840
|
+
paths[openApiPath] = {};
|
|
841
|
+
}
|
|
842
|
+
paths[openApiPath][method] = operation;
|
|
843
|
+
}
|
|
844
|
+
const spec = {
|
|
845
|
+
openapi: "3.0.3",
|
|
846
|
+
info: { title, version },
|
|
847
|
+
paths,
|
|
848
|
+
};
|
|
849
|
+
if (Object.keys(schemas).length > 0) {
|
|
850
|
+
spec.components = { schemas };
|
|
851
|
+
}
|
|
852
|
+
if (options?.serverUrl) {
|
|
853
|
+
spec.servers = [{ url: options.serverUrl }];
|
|
854
|
+
}
|
|
855
|
+
return spec;
|
|
856
|
+
}
|
|
857
|
+
// ── Express handler type generation ──
|
|
858
|
+
/**
|
|
859
|
+
* Generate typed Express handler type aliases from runtime-observed routes.
|
|
860
|
+
*
|
|
861
|
+
* For each route like `GET /api/users/:id`, produces:
|
|
862
|
+
* - `GetApiUsersIdHandler` — a fully typed `RequestHandler` with
|
|
863
|
+
* `Request<Params, ResBody, ReqBody, Query>` and `Response<ResBody>`
|
|
864
|
+
*
|
|
865
|
+
* Developers can use these to type their route handlers:
|
|
866
|
+
* app.get('/api/users/:id', ((req, res) => { ... }) as GetApiUsersIdHandler);
|
|
867
|
+
*/
|
|
868
|
+
function generateHandlerTypes(functions) {
|
|
869
|
+
// Filter to route-style functions only
|
|
870
|
+
const routes = [];
|
|
871
|
+
for (const fn of functions) {
|
|
872
|
+
const parsed = parseRouteName(fn.name);
|
|
873
|
+
if (parsed) {
|
|
874
|
+
routes.push({ parsed, fn });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (routes.length === 0) {
|
|
878
|
+
return "// No API routes found. Instrument your Express app to generate handler types.\n";
|
|
879
|
+
}
|
|
880
|
+
const sections = [];
|
|
881
|
+
sections.push("// Auto-generated Express handler types by trickle");
|
|
882
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
883
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --handlers` to update");
|
|
884
|
+
sections.push("");
|
|
885
|
+
sections.push('import { Request, Response, NextFunction } from "express";');
|
|
886
|
+
sections.push("");
|
|
887
|
+
const extracted = [];
|
|
888
|
+
for (const { parsed, fn } of routes) {
|
|
889
|
+
const baseName = parsed.typeName;
|
|
890
|
+
// --- Params type ---
|
|
891
|
+
let paramsType = "Record<string, string>";
|
|
892
|
+
if (parsed.pathParams.length > 0) {
|
|
893
|
+
const paramsName = `${baseName}Params`;
|
|
894
|
+
// Check if we have observed param types
|
|
895
|
+
let paramsNode;
|
|
896
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["params"]) {
|
|
897
|
+
paramsNode = fn.argsType.properties["params"];
|
|
898
|
+
}
|
|
899
|
+
if (paramsNode && paramsNode.kind === "object" && Object.keys(paramsNode.properties).length > 0) {
|
|
900
|
+
sections.push(renderInterface(paramsName, paramsNode, extracted));
|
|
901
|
+
sections.push("");
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
// Generate from path params — all strings
|
|
905
|
+
const props = parsed.pathParams.map(p => ` ${p}: string;`);
|
|
906
|
+
sections.push(`export interface ${paramsName} {`);
|
|
907
|
+
sections.push(...props);
|
|
908
|
+
sections.push("}");
|
|
909
|
+
sections.push("");
|
|
910
|
+
}
|
|
911
|
+
paramsType = paramsName;
|
|
912
|
+
}
|
|
913
|
+
// --- Response body type ---
|
|
914
|
+
let resBodyType = "unknown";
|
|
915
|
+
const resBodyName = `${baseName}ResBody`;
|
|
916
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
917
|
+
sections.push(renderInterface(resBodyName, fn.returnType, extracted));
|
|
918
|
+
sections.push("");
|
|
919
|
+
resBodyType = resBodyName;
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
|
|
923
|
+
sections.push(`export type ${resBodyName} = ${retStr};`);
|
|
924
|
+
sections.push("");
|
|
925
|
+
resBodyType = resBodyName;
|
|
926
|
+
}
|
|
927
|
+
// --- Request body type ---
|
|
928
|
+
let reqBodyType = "unknown";
|
|
929
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
930
|
+
let bodyNode;
|
|
931
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
932
|
+
bodyNode = fn.argsType.properties["body"];
|
|
933
|
+
}
|
|
934
|
+
else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
|
|
935
|
+
const el = fn.argsType.elements[0];
|
|
936
|
+
if (el.kind === "object" && el.properties["body"]) {
|
|
937
|
+
bodyNode = el.properties["body"];
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
941
|
+
const reqBodyName = `${baseName}ReqBody`;
|
|
942
|
+
sections.push(renderInterface(reqBodyName, bodyNode, extracted));
|
|
943
|
+
sections.push("");
|
|
944
|
+
reqBodyType = reqBodyName;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
// --- Query type ---
|
|
948
|
+
let queryType = "qs.ParsedQs";
|
|
949
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
|
|
950
|
+
const queryNode = fn.argsType.properties["query"];
|
|
951
|
+
if (queryNode.kind === "object" && Object.keys(queryNode.properties).length > 0) {
|
|
952
|
+
const queryName = `${baseName}Query`;
|
|
953
|
+
sections.push(renderInterface(queryName, queryNode, extracted));
|
|
954
|
+
sections.push("");
|
|
955
|
+
queryType = queryName;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
// --- Handler type alias ---
|
|
959
|
+
sections.push(`/** ${parsed.method} ${parsed.path} */`);
|
|
960
|
+
sections.push(`export type ${baseName}Handler = (` +
|
|
961
|
+
`req: Request<${paramsType}, ${resBodyType}, ${reqBodyType}, ${queryType}>, ` +
|
|
962
|
+
`res: Response<${resBodyType}>, ` +
|
|
963
|
+
`next: NextFunction` +
|
|
964
|
+
`) => void;`);
|
|
965
|
+
sections.push("");
|
|
966
|
+
}
|
|
967
|
+
// Emit extracted sub-interfaces
|
|
968
|
+
const emitted = new Set();
|
|
969
|
+
const extractedLines = [];
|
|
970
|
+
let cursor = 0;
|
|
971
|
+
while (cursor < extracted.length) {
|
|
972
|
+
const iface = extracted[cursor];
|
|
973
|
+
cursor++;
|
|
974
|
+
if (emitted.has(iface.name))
|
|
975
|
+
continue;
|
|
976
|
+
emitted.add(iface.name);
|
|
977
|
+
extractedLines.push(renderInterface(iface.name, iface.node, extracted));
|
|
978
|
+
extractedLines.push("");
|
|
979
|
+
}
|
|
980
|
+
if (extractedLines.length > 0) {
|
|
981
|
+
// Insert after the import line (index 4 = after the blank line after import)
|
|
982
|
+
sections.splice(5, 0, ...extractedLines);
|
|
983
|
+
}
|
|
984
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
985
|
+
}
|
|
986
|
+
// ── Zod schema generation ──
|
|
987
|
+
/**
|
|
988
|
+
* Convert a TypeNode to a Zod schema expression string.
|
|
989
|
+
*/
|
|
990
|
+
function typeNodeToZod(node, indent) {
|
|
991
|
+
const pad = " ".repeat(indent);
|
|
992
|
+
switch (node.kind) {
|
|
993
|
+
case "primitive":
|
|
994
|
+
switch (node.name) {
|
|
995
|
+
case "string": return "z.string()";
|
|
996
|
+
case "number": return "z.number()";
|
|
997
|
+
case "boolean": return "z.boolean()";
|
|
998
|
+
case "null": return "z.null()";
|
|
999
|
+
case "undefined": return "z.undefined()";
|
|
1000
|
+
case "bigint": return "z.bigint()";
|
|
1001
|
+
case "symbol": return "z.symbol()";
|
|
1002
|
+
default: return "z.unknown()";
|
|
1003
|
+
}
|
|
1004
|
+
case "unknown":
|
|
1005
|
+
return "z.unknown()";
|
|
1006
|
+
case "array":
|
|
1007
|
+
return `z.array(${typeNodeToZod(node.element, indent)})`;
|
|
1008
|
+
case "tuple": {
|
|
1009
|
+
if (node.elements.length === 0)
|
|
1010
|
+
return "z.tuple([])";
|
|
1011
|
+
const els = node.elements.map((el) => typeNodeToZod(el, indent));
|
|
1012
|
+
return `z.tuple([${els.join(", ")}])`;
|
|
1013
|
+
}
|
|
1014
|
+
case "union": {
|
|
1015
|
+
const members = node.members.map((m) => typeNodeToZod(m, indent));
|
|
1016
|
+
if (members.length === 1)
|
|
1017
|
+
return members[0];
|
|
1018
|
+
return `z.union([${members.join(", ")}])`;
|
|
1019
|
+
}
|
|
1020
|
+
case "map": {
|
|
1021
|
+
return `z.map(${typeNodeToZod(node.key, indent)}, ${typeNodeToZod(node.value, indent)})`;
|
|
1022
|
+
}
|
|
1023
|
+
case "set": {
|
|
1024
|
+
return `z.set(${typeNodeToZod(node.element, indent)})`;
|
|
1025
|
+
}
|
|
1026
|
+
case "promise": {
|
|
1027
|
+
return `z.promise(${typeNodeToZod(node.resolved, indent)})`;
|
|
1028
|
+
}
|
|
1029
|
+
case "function": {
|
|
1030
|
+
return "z.function()";
|
|
1031
|
+
}
|
|
1032
|
+
case "object": {
|
|
1033
|
+
const keys = Object.keys(node.properties);
|
|
1034
|
+
if (keys.length === 0)
|
|
1035
|
+
return "z.object({})";
|
|
1036
|
+
const innerPad = " ".repeat(indent + 1);
|
|
1037
|
+
const entries = keys.map((key) => {
|
|
1038
|
+
const val = typeNodeToZod(node.properties[key], indent + 1);
|
|
1039
|
+
return `${innerPad}${key}: ${val},`;
|
|
1040
|
+
});
|
|
1041
|
+
return `z.object({\n${entries.join("\n")}\n${pad}})`;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Generate Zod validation schemas from runtime-observed types.
|
|
1047
|
+
*
|
|
1048
|
+
* For each function/route, generates a named Zod schema that can be used for:
|
|
1049
|
+
* - Runtime validation of API inputs/outputs
|
|
1050
|
+
* - TypeScript type inference via `z.infer<typeof schema>`
|
|
1051
|
+
* - Form validation, config parsing, etc.
|
|
1052
|
+
*/
|
|
1053
|
+
function generateZodSchemas(functions) {
|
|
1054
|
+
if (functions.length === 0) {
|
|
1055
|
+
return "// No functions found. Instrument your app to generate Zod schemas.\n";
|
|
1056
|
+
}
|
|
1057
|
+
const sections = [];
|
|
1058
|
+
sections.push("// Auto-generated Zod schemas by trickle");
|
|
1059
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
1060
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --zod` to update");
|
|
1061
|
+
sections.push("");
|
|
1062
|
+
sections.push('import { z } from "zod";');
|
|
1063
|
+
sections.push("");
|
|
1064
|
+
// Check if any are route-style functions
|
|
1065
|
+
const hasRoutes = functions.some((fn) => parseRouteName(fn.name) !== null);
|
|
1066
|
+
for (const fn of functions) {
|
|
1067
|
+
const parsed = parseRouteName(fn.name);
|
|
1068
|
+
if (parsed) {
|
|
1069
|
+
// Route-style: generate Input/Output schemas
|
|
1070
|
+
const baseName = toCamelCase(parsed.typeName);
|
|
1071
|
+
const BaseNamePascal = parsed.typeName;
|
|
1072
|
+
// --- Response schema ---
|
|
1073
|
+
sections.push(`/** ${parsed.method} ${parsed.path} — response */`);
|
|
1074
|
+
sections.push(`export const ${baseName}ResponseSchema = ${typeNodeToZod(fn.returnType, 0)};`);
|
|
1075
|
+
sections.push(`export type ${BaseNamePascal}Response = z.infer<typeof ${baseName}ResponseSchema>;`);
|
|
1076
|
+
sections.push("");
|
|
1077
|
+
// --- Request body schema (for POST/PUT/PATCH) ---
|
|
1078
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
1079
|
+
let bodyNode;
|
|
1080
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
1081
|
+
bodyNode = fn.argsType.properties["body"];
|
|
1082
|
+
}
|
|
1083
|
+
else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
|
|
1084
|
+
const el = fn.argsType.elements[0];
|
|
1085
|
+
if (el.kind === "object" && el.properties["body"]) {
|
|
1086
|
+
bodyNode = el.properties["body"];
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
1090
|
+
sections.push(`/** ${parsed.method} ${parsed.path} — request body */`);
|
|
1091
|
+
sections.push(`export const ${baseName}RequestSchema = ${typeNodeToZod(bodyNode, 0)};`);
|
|
1092
|
+
sections.push(`export type ${BaseNamePascal}Request = z.infer<typeof ${baseName}RequestSchema>;`);
|
|
1093
|
+
sections.push("");
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// --- Path params schema (if route has :params) ---
|
|
1097
|
+
if (parsed.pathParams.length > 0) {
|
|
1098
|
+
let paramsNode;
|
|
1099
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["params"]) {
|
|
1100
|
+
paramsNode = fn.argsType.properties["params"];
|
|
1101
|
+
}
|
|
1102
|
+
if (paramsNode && paramsNode.kind === "object" && Object.keys(paramsNode.properties).length > 0) {
|
|
1103
|
+
sections.push(`/** ${parsed.method} ${parsed.path} — path params */`);
|
|
1104
|
+
sections.push(`export const ${baseName}ParamsSchema = ${typeNodeToZod(paramsNode, 0)};`);
|
|
1105
|
+
sections.push(`export type ${BaseNamePascal}Params = z.infer<typeof ${baseName}ParamsSchema>;`);
|
|
1106
|
+
sections.push("");
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
// --- Query params schema ---
|
|
1110
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
|
|
1111
|
+
const queryNode = fn.argsType.properties["query"];
|
|
1112
|
+
if (queryNode.kind === "object" && Object.keys(queryNode.properties).length > 0) {
|
|
1113
|
+
sections.push(`/** ${parsed.method} ${parsed.path} — query params */`);
|
|
1114
|
+
sections.push(`export const ${baseName}QuerySchema = ${typeNodeToZod(queryNode, 0)};`);
|
|
1115
|
+
sections.push(`export type ${BaseNamePascal}Query = z.infer<typeof ${baseName}QuerySchema>;`);
|
|
1116
|
+
sections.push("");
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
else {
|
|
1121
|
+
// Non-route function: generate Input/Output schemas
|
|
1122
|
+
const baseName = toCamelCase(toPascalCase(fn.name));
|
|
1123
|
+
const BaseNamePascal = toPascalCase(fn.name);
|
|
1124
|
+
// Input schema
|
|
1125
|
+
if (fn.argsType.kind !== "unknown") {
|
|
1126
|
+
sections.push(`/** ${fn.name} — input */`);
|
|
1127
|
+
if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
|
|
1128
|
+
sections.push(`export const ${baseName}InputSchema = ${typeNodeToZod(fn.argsType.elements[0], 0)};`);
|
|
1129
|
+
}
|
|
1130
|
+
else {
|
|
1131
|
+
sections.push(`export const ${baseName}InputSchema = ${typeNodeToZod(fn.argsType, 0)};`);
|
|
1132
|
+
}
|
|
1133
|
+
sections.push(`export type ${BaseNamePascal}Input = z.infer<typeof ${baseName}InputSchema>;`);
|
|
1134
|
+
sections.push("");
|
|
1135
|
+
}
|
|
1136
|
+
// Output schema
|
|
1137
|
+
sections.push(`/** ${fn.name} — output */`);
|
|
1138
|
+
sections.push(`export const ${baseName}OutputSchema = ${typeNodeToZod(fn.returnType, 0)};`);
|
|
1139
|
+
sections.push(`export type ${BaseNamePascal}Output = z.infer<typeof ${baseName}OutputSchema>;`);
|
|
1140
|
+
sections.push("");
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
1144
|
+
}
|
|
1145
|
+
// ── React Query hooks generation ──
|
|
1146
|
+
/**
|
|
1147
|
+
* Generate fully-typed TanStack Query (React Query) hooks from runtime-observed routes.
|
|
1148
|
+
*
|
|
1149
|
+
* For each route:
|
|
1150
|
+
* - GET → useQuery hook with typed response and query keys
|
|
1151
|
+
* - POST/PUT/PATCH/DELETE → useMutation hook with typed input/output
|
|
1152
|
+
* - Query key factory for cache invalidation
|
|
1153
|
+
* - All request/response interfaces included
|
|
1154
|
+
*/
|
|
1155
|
+
function generateReactQueryHooks(functions) {
|
|
1156
|
+
// Filter to route-style functions only
|
|
1157
|
+
const routes = [];
|
|
1158
|
+
for (const fn of functions) {
|
|
1159
|
+
const parsed = parseRouteName(fn.name);
|
|
1160
|
+
if (parsed) {
|
|
1161
|
+
routes.push({ parsed, fn });
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
if (routes.length === 0) {
|
|
1165
|
+
return "// No API routes found. Instrument your Express/FastAPI app to generate React Query hooks.\n";
|
|
1166
|
+
}
|
|
1167
|
+
const sections = [];
|
|
1168
|
+
sections.push("// Auto-generated React Query hooks by trickle");
|
|
1169
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
1170
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --react-query` to update");
|
|
1171
|
+
sections.push("");
|
|
1172
|
+
sections.push('import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";');
|
|
1173
|
+
sections.push('import type { UseQueryOptions, UseMutationOptions } from "@tanstack/react-query";');
|
|
1174
|
+
sections.push("");
|
|
1175
|
+
// --- Generate interfaces ---
|
|
1176
|
+
const extracted = [];
|
|
1177
|
+
for (const { parsed, fn } of routes) {
|
|
1178
|
+
const baseName = parsed.typeName;
|
|
1179
|
+
// Response type
|
|
1180
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
1181
|
+
sections.push(renderInterface(`${baseName}Response`, fn.returnType, extracted));
|
|
1182
|
+
sections.push("");
|
|
1183
|
+
}
|
|
1184
|
+
else {
|
|
1185
|
+
const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
|
|
1186
|
+
sections.push(`export type ${baseName}Response = ${retStr};`);
|
|
1187
|
+
sections.push("");
|
|
1188
|
+
}
|
|
1189
|
+
// Request body type (POST/PUT/PATCH)
|
|
1190
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
1191
|
+
let bodyNode;
|
|
1192
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
1193
|
+
bodyNode = fn.argsType.properties["body"];
|
|
1194
|
+
}
|
|
1195
|
+
else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
|
|
1196
|
+
const el = fn.argsType.elements[0];
|
|
1197
|
+
if (el.kind === "object" && el.properties["body"]) {
|
|
1198
|
+
bodyNode = el.properties["body"];
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
1202
|
+
sections.push(renderInterface(`${baseName}Input`, bodyNode, extracted));
|
|
1203
|
+
sections.push("");
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
// Emit extracted sub-interfaces
|
|
1208
|
+
const emitted = new Set();
|
|
1209
|
+
const extractedLines = [];
|
|
1210
|
+
let cursor = 0;
|
|
1211
|
+
while (cursor < extracted.length) {
|
|
1212
|
+
const iface = extracted[cursor];
|
|
1213
|
+
cursor++;
|
|
1214
|
+
if (emitted.has(iface.name))
|
|
1215
|
+
continue;
|
|
1216
|
+
emitted.add(iface.name);
|
|
1217
|
+
extractedLines.push(renderInterface(iface.name, iface.node, extracted));
|
|
1218
|
+
extractedLines.push("");
|
|
1219
|
+
}
|
|
1220
|
+
if (extractedLines.length > 0) {
|
|
1221
|
+
sections.push(...extractedLines);
|
|
1222
|
+
}
|
|
1223
|
+
// --- Internal fetch helper ---
|
|
1224
|
+
sections.push("// ── Internal fetch helper ──");
|
|
1225
|
+
sections.push("");
|
|
1226
|
+
sections.push("let _baseUrl = \"\";");
|
|
1227
|
+
sections.push("");
|
|
1228
|
+
sections.push("/** Set the base URL for all API requests. Call once at app startup. */");
|
|
1229
|
+
sections.push("export function configureTrickleHooks(baseUrl: string) {");
|
|
1230
|
+
sections.push(" _baseUrl = baseUrl;");
|
|
1231
|
+
sections.push("}");
|
|
1232
|
+
sections.push("");
|
|
1233
|
+
sections.push("async function _fetch<T>(method: string, path: string, body?: unknown): Promise<T> {");
|
|
1234
|
+
sections.push(" const opts: RequestInit = { method, headers: { \"Content-Type\": \"application/json\" } };");
|
|
1235
|
+
sections.push(" if (body !== undefined) opts.body = JSON.stringify(body);");
|
|
1236
|
+
sections.push(" const res = await fetch(`${_baseUrl}${path}`, opts);");
|
|
1237
|
+
sections.push(" if (!res.ok) throw new Error(`${method} ${path}: HTTP ${res.status}`);");
|
|
1238
|
+
sections.push(" return res.json() as Promise<T>;");
|
|
1239
|
+
sections.push("}");
|
|
1240
|
+
sections.push("");
|
|
1241
|
+
// --- Query key factory ---
|
|
1242
|
+
sections.push("// ── Query Keys ──");
|
|
1243
|
+
sections.push("");
|
|
1244
|
+
sections.push("export const queryKeys = {");
|
|
1245
|
+
// Group routes by resource (first path segment after /api/)
|
|
1246
|
+
const resources = new Set();
|
|
1247
|
+
for (const { parsed } of routes) {
|
|
1248
|
+
const parts = parsed.path.split("/").filter(Boolean);
|
|
1249
|
+
// /api/users → "users", /products → "products"
|
|
1250
|
+
const resource = parts[0] === "api" && parts.length >= 2 ? parts[1] : parts[0];
|
|
1251
|
+
resources.add(resource);
|
|
1252
|
+
}
|
|
1253
|
+
for (const resource of resources) {
|
|
1254
|
+
sections.push(` ${resource}: {`);
|
|
1255
|
+
sections.push(` all: ["${resource}"] as const,`);
|
|
1256
|
+
// Add specific keys for routes in this resource
|
|
1257
|
+
const resourceRoutes = routes.filter(({ parsed }) => {
|
|
1258
|
+
const parts = parsed.path.split("/").filter(Boolean);
|
|
1259
|
+
const r = parts[0] === "api" && parts.length >= 2 ? parts[1] : parts[0];
|
|
1260
|
+
return r === resource;
|
|
1261
|
+
});
|
|
1262
|
+
for (const { parsed } of resourceRoutes) {
|
|
1263
|
+
if (parsed.method === "GET") {
|
|
1264
|
+
if (parsed.pathParams.length > 0) {
|
|
1265
|
+
const paramArgs = parsed.pathParams.map((p) => `${p}: string`).join(", ");
|
|
1266
|
+
const paramKeys = parsed.pathParams.map((p) => p).join(", ");
|
|
1267
|
+
sections.push(` detail: (${paramArgs}) => ["${resource}", ${paramKeys}] as const,`);
|
|
1268
|
+
}
|
|
1269
|
+
else {
|
|
1270
|
+
sections.push(` list: () => ["${resource}", "list"] as const,`);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
sections.push(" },");
|
|
1275
|
+
}
|
|
1276
|
+
sections.push("} as const;");
|
|
1277
|
+
sections.push("");
|
|
1278
|
+
// --- Generate hooks ---
|
|
1279
|
+
sections.push("// ── Hooks ──");
|
|
1280
|
+
sections.push("");
|
|
1281
|
+
for (const { parsed, fn } of routes) {
|
|
1282
|
+
const baseName = parsed.typeName;
|
|
1283
|
+
const hookName = `use${baseName}`;
|
|
1284
|
+
const responseType = `${baseName}Response`;
|
|
1285
|
+
// Determine resource for query keys
|
|
1286
|
+
const parts = parsed.path.split("/").filter(Boolean);
|
|
1287
|
+
const resource = parts[0] === "api" && parts.length >= 2 ? parts[1] : parts[0];
|
|
1288
|
+
if (parsed.method === "GET") {
|
|
1289
|
+
// --- useQuery hook ---
|
|
1290
|
+
const hasPathParams = parsed.pathParams.length > 0;
|
|
1291
|
+
// Build params
|
|
1292
|
+
const fnParams = [];
|
|
1293
|
+
if (hasPathParams) {
|
|
1294
|
+
for (const p of parsed.pathParams) {
|
|
1295
|
+
fnParams.push(`${p}: string`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
fnParams.push(`options?: Omit<UseQueryOptions<${responseType}, Error>, "queryKey" | "queryFn">`);
|
|
1299
|
+
// Build path expression
|
|
1300
|
+
let pathExpr;
|
|
1301
|
+
if (hasPathParams) {
|
|
1302
|
+
pathExpr = "`" + parsed.path.replace(/:(\w+)/g, (_, param) => `\${${param}}`) + "`";
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
pathExpr = `"${parsed.path}"`;
|
|
1306
|
+
}
|
|
1307
|
+
// Build query key
|
|
1308
|
+
let queryKeyExpr;
|
|
1309
|
+
if (hasPathParams) {
|
|
1310
|
+
queryKeyExpr = `queryKeys.${resource}.detail(${parsed.pathParams.join(", ")})`;
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
queryKeyExpr = `queryKeys.${resource}.list()`;
|
|
1314
|
+
}
|
|
1315
|
+
sections.push(`/** ${parsed.method} ${parsed.path} */`);
|
|
1316
|
+
sections.push(`export function ${hookName}(${fnParams.join(", ")}) {`);
|
|
1317
|
+
sections.push(` return useQuery({`);
|
|
1318
|
+
sections.push(` queryKey: ${queryKeyExpr},`);
|
|
1319
|
+
sections.push(` queryFn: () => _fetch<${responseType}>("GET", ${pathExpr}),`);
|
|
1320
|
+
sections.push(` ...options,`);
|
|
1321
|
+
sections.push(` });`);
|
|
1322
|
+
sections.push(`}`);
|
|
1323
|
+
sections.push("");
|
|
1324
|
+
}
|
|
1325
|
+
else {
|
|
1326
|
+
// --- useMutation hook ---
|
|
1327
|
+
const hasBody = ["POST", "PUT", "PATCH"].includes(parsed.method);
|
|
1328
|
+
let inputType = "void";
|
|
1329
|
+
// Check if we have a typed input
|
|
1330
|
+
if (hasBody) {
|
|
1331
|
+
let bodyNode;
|
|
1332
|
+
if (fn.argsType.kind === "object") {
|
|
1333
|
+
bodyNode = fn.argsType.properties["body"];
|
|
1334
|
+
}
|
|
1335
|
+
else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
|
|
1336
|
+
const el = fn.argsType.elements[0];
|
|
1337
|
+
if (el.kind === "object")
|
|
1338
|
+
bodyNode = el.properties["body"];
|
|
1339
|
+
}
|
|
1340
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
1341
|
+
inputType = `${baseName}Input`;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
// For mutations with path params, create a combined variables type
|
|
1345
|
+
const hasPathParams = parsed.pathParams.length > 0;
|
|
1346
|
+
let variablesType;
|
|
1347
|
+
if (hasPathParams && inputType !== "void") {
|
|
1348
|
+
variablesType = `{ ${parsed.pathParams.map((p) => `${p}: string`).join("; ")}; input: ${inputType} }`;
|
|
1349
|
+
}
|
|
1350
|
+
else if (hasPathParams) {
|
|
1351
|
+
variablesType = `{ ${parsed.pathParams.map((p) => `${p}: string`).join("; ")} }`;
|
|
1352
|
+
}
|
|
1353
|
+
else {
|
|
1354
|
+
variablesType = inputType;
|
|
1355
|
+
}
|
|
1356
|
+
// Build path expression
|
|
1357
|
+
let pathExpr;
|
|
1358
|
+
if (hasPathParams) {
|
|
1359
|
+
pathExpr = "`" + parsed.path.replace(/:(\w+)/g, (_, param) => `\${vars.${param}}`) + "`";
|
|
1360
|
+
}
|
|
1361
|
+
else {
|
|
1362
|
+
pathExpr = `"${parsed.path}"`;
|
|
1363
|
+
}
|
|
1364
|
+
// Build body expression
|
|
1365
|
+
let bodyExpr;
|
|
1366
|
+
if (hasPathParams && inputType !== "void") {
|
|
1367
|
+
bodyExpr = "vars.input";
|
|
1368
|
+
}
|
|
1369
|
+
else if (inputType !== "void") {
|
|
1370
|
+
bodyExpr = "vars";
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
bodyExpr = "undefined";
|
|
1374
|
+
}
|
|
1375
|
+
const optionsType = `UseMutationOptions<${responseType}, Error, ${variablesType}>`;
|
|
1376
|
+
sections.push(`/** ${parsed.method} ${parsed.path} */`);
|
|
1377
|
+
sections.push(`export function ${hookName}(options?: Omit<${optionsType}, "mutationFn">) {`);
|
|
1378
|
+
sections.push(` const queryClient = useQueryClient();`);
|
|
1379
|
+
sections.push(` return useMutation({`);
|
|
1380
|
+
if (variablesType === "void") {
|
|
1381
|
+
sections.push(` mutationFn: () => _fetch<${responseType}>("${parsed.method}", ${pathExpr}, ${bodyExpr}),`);
|
|
1382
|
+
}
|
|
1383
|
+
else {
|
|
1384
|
+
sections.push(` mutationFn: (vars: ${variablesType}) => _fetch<${responseType}>("${parsed.method}", ${pathExpr}, ${bodyExpr}),`);
|
|
1385
|
+
}
|
|
1386
|
+
sections.push(` onSuccess: (...args) => {`);
|
|
1387
|
+
sections.push(` queryClient.invalidateQueries({ queryKey: queryKeys.${resource}.all });`);
|
|
1388
|
+
sections.push(` options?.onSuccess?.(...args);`);
|
|
1389
|
+
sections.push(` },`);
|
|
1390
|
+
sections.push(` ...options,`);
|
|
1391
|
+
sections.push(` });`);
|
|
1392
|
+
sections.push(`}`);
|
|
1393
|
+
sections.push("");
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
1397
|
+
}
|
|
1398
|
+
// ── Type guard generation ──
|
|
1399
|
+
/**
|
|
1400
|
+
* Generate a runtime type guard check expression for a TypeNode.
|
|
1401
|
+
* Returns a string that evaluates to boolean when `varName` is the variable.
|
|
1402
|
+
*/
|
|
1403
|
+
function typeNodeToGuardCheck(node, varName, depth = 0) {
|
|
1404
|
+
if (depth > 4)
|
|
1405
|
+
return "true"; // Limit recursion depth
|
|
1406
|
+
switch (node.kind) {
|
|
1407
|
+
case "primitive": {
|
|
1408
|
+
if (node.name === "null")
|
|
1409
|
+
return `${varName} === null`;
|
|
1410
|
+
if (node.name === "undefined")
|
|
1411
|
+
return `${varName} === undefined`;
|
|
1412
|
+
return `typeof ${varName} === "${node.name}"`;
|
|
1413
|
+
}
|
|
1414
|
+
case "unknown":
|
|
1415
|
+
return "true";
|
|
1416
|
+
case "array": {
|
|
1417
|
+
const elemCheck = typeNodeToGuardCheck(node.element, `${varName}[0]`, depth + 1);
|
|
1418
|
+
if (elemCheck === "true") {
|
|
1419
|
+
return `Array.isArray(${varName})`;
|
|
1420
|
+
}
|
|
1421
|
+
return `(Array.isArray(${varName}) && (${varName}.length === 0 || ${elemCheck}))`;
|
|
1422
|
+
}
|
|
1423
|
+
case "tuple": {
|
|
1424
|
+
const checks = [`Array.isArray(${varName})`];
|
|
1425
|
+
checks.push(`${varName}.length === ${node.elements.length}`);
|
|
1426
|
+
node.elements.forEach((el, i) => {
|
|
1427
|
+
const c = typeNodeToGuardCheck(el, `${varName}[${i}]`, depth + 1);
|
|
1428
|
+
if (c !== "true")
|
|
1429
|
+
checks.push(c);
|
|
1430
|
+
});
|
|
1431
|
+
return checks.join(" && ");
|
|
1432
|
+
}
|
|
1433
|
+
case "union": {
|
|
1434
|
+
const memberChecks = node.members.map((m) => typeNodeToGuardCheck(m, varName, depth + 1));
|
|
1435
|
+
return `(${memberChecks.join(" || ")})`;
|
|
1436
|
+
}
|
|
1437
|
+
case "object": {
|
|
1438
|
+
const keys = Object.keys(node.properties);
|
|
1439
|
+
if (keys.length === 0) {
|
|
1440
|
+
return `(typeof ${varName} === "object" && ${varName} !== null)`;
|
|
1441
|
+
}
|
|
1442
|
+
const checks = [
|
|
1443
|
+
`typeof ${varName} === "object"`,
|
|
1444
|
+
`${varName} !== null`,
|
|
1445
|
+
];
|
|
1446
|
+
for (const key of keys) {
|
|
1447
|
+
checks.push(`"${key}" in ${varName}`);
|
|
1448
|
+
if (depth < 3) {
|
|
1449
|
+
const propCheck = typeNodeToGuardCheck(node.properties[key], `(${varName} as any).${key}`, depth + 1);
|
|
1450
|
+
if (propCheck !== "true") {
|
|
1451
|
+
checks.push(propCheck);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return checks.join(" && ");
|
|
1456
|
+
}
|
|
1457
|
+
case "map":
|
|
1458
|
+
return `${varName} instanceof Map`;
|
|
1459
|
+
case "set":
|
|
1460
|
+
return `${varName} instanceof Set`;
|
|
1461
|
+
case "promise":
|
|
1462
|
+
return `${varName} instanceof Promise`;
|
|
1463
|
+
case "function":
|
|
1464
|
+
return `typeof ${varName} === "function"`;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Generate TypeScript type guard functions from runtime-observed types.
|
|
1469
|
+
*
|
|
1470
|
+
* For each route:
|
|
1471
|
+
* - `isGetApiUsersResponse(value): value is GetApiUsersResponse`
|
|
1472
|
+
* - `isPostApiUsersRequest(value): value is PostApiUsersRequest`
|
|
1473
|
+
*
|
|
1474
|
+
* Type guards perform structural checks: verify typeof, key existence,
|
|
1475
|
+
* array shapes, and nested object structure.
|
|
1476
|
+
*/
|
|
1477
|
+
function generateTypeGuards(functions) {
|
|
1478
|
+
if (functions.length === 0) {
|
|
1479
|
+
return "// No functions found. Instrument your app to generate type guards.\n";
|
|
1480
|
+
}
|
|
1481
|
+
const sections = [];
|
|
1482
|
+
sections.push("// Auto-generated type guards by trickle");
|
|
1483
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
1484
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --guards` to update");
|
|
1485
|
+
sections.push("");
|
|
1486
|
+
// First, collect all interfaces we'll need to reference
|
|
1487
|
+
const interfaces = [];
|
|
1488
|
+
const guards = [];
|
|
1489
|
+
for (const fn of functions) {
|
|
1490
|
+
const parsed = parseRouteName(fn.name);
|
|
1491
|
+
if (parsed) {
|
|
1492
|
+
const basePascal = parsed.typeName;
|
|
1493
|
+
const baseCamel = toCamelCase(basePascal);
|
|
1494
|
+
// Response type + guard
|
|
1495
|
+
const responseTypeName = `${basePascal}Response`;
|
|
1496
|
+
const responseInterface = generateGuardInterface(responseTypeName, fn.returnType);
|
|
1497
|
+
if (responseInterface)
|
|
1498
|
+
interfaces.push(responseInterface);
|
|
1499
|
+
const responseCheck = typeNodeToGuardCheck(fn.returnType, "value");
|
|
1500
|
+
guards.push(`/** Type guard for ${parsed.method} ${parsed.path} response */`);
|
|
1501
|
+
guards.push(`export function is${responseTypeName}(value: unknown): value is ${responseTypeName} {`);
|
|
1502
|
+
guards.push(` return ${responseCheck};`);
|
|
1503
|
+
guards.push(`}`);
|
|
1504
|
+
guards.push("");
|
|
1505
|
+
// Request body type + guard (for POST/PUT/PATCH)
|
|
1506
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
1507
|
+
let bodyNode;
|
|
1508
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
1509
|
+
bodyNode = fn.argsType.properties["body"];
|
|
1510
|
+
}
|
|
1511
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
1512
|
+
const requestTypeName = `${basePascal}Request`;
|
|
1513
|
+
const requestInterface = generateGuardInterface(requestTypeName, bodyNode);
|
|
1514
|
+
if (requestInterface)
|
|
1515
|
+
interfaces.push(requestInterface);
|
|
1516
|
+
const requestCheck = typeNodeToGuardCheck(bodyNode, "value");
|
|
1517
|
+
guards.push(`/** Type guard for ${parsed.method} ${parsed.path} request body */`);
|
|
1518
|
+
guards.push(`export function is${requestTypeName}(value: unknown): value is ${requestTypeName} {`);
|
|
1519
|
+
guards.push(` return ${requestCheck};`);
|
|
1520
|
+
guards.push(`}`);
|
|
1521
|
+
guards.push("");
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
else {
|
|
1526
|
+
// Non-route function
|
|
1527
|
+
const basePascal = toPascalCase(fn.name);
|
|
1528
|
+
// Output guard
|
|
1529
|
+
const outputTypeName = `${basePascal}Output`;
|
|
1530
|
+
const outputInterface = generateGuardInterface(outputTypeName, fn.returnType);
|
|
1531
|
+
if (outputInterface)
|
|
1532
|
+
interfaces.push(outputInterface);
|
|
1533
|
+
const outputCheck = typeNodeToGuardCheck(fn.returnType, "value");
|
|
1534
|
+
guards.push(`/** Type guard for ${fn.name} output */`);
|
|
1535
|
+
guards.push(`export function is${outputTypeName}(value: unknown): value is ${outputTypeName} {`);
|
|
1536
|
+
guards.push(` return ${outputCheck};`);
|
|
1537
|
+
guards.push(`}`);
|
|
1538
|
+
guards.push("");
|
|
1539
|
+
// Input guard (if non-trivial)
|
|
1540
|
+
if (fn.argsType.kind !== "unknown" && fn.argsType.kind !== "object" || (fn.argsType.kind === "object" && Object.keys(fn.argsType.properties).length > 0)) {
|
|
1541
|
+
const inputTypeName = `${basePascal}Input`;
|
|
1542
|
+
const inputNode = fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1
|
|
1543
|
+
? fn.argsType.elements[0]
|
|
1544
|
+
: fn.argsType;
|
|
1545
|
+
const inputInterface = generateGuardInterface(inputTypeName, inputNode);
|
|
1546
|
+
if (inputInterface)
|
|
1547
|
+
interfaces.push(inputInterface);
|
|
1548
|
+
const inputCheck = typeNodeToGuardCheck(inputNode, "value");
|
|
1549
|
+
guards.push(`/** Type guard for ${fn.name} input */`);
|
|
1550
|
+
guards.push(`export function is${inputTypeName}(value: unknown): value is ${inputTypeName} {`);
|
|
1551
|
+
guards.push(` return ${inputCheck};`);
|
|
1552
|
+
guards.push(`}`);
|
|
1553
|
+
guards.push("");
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
// Output: interfaces first, then guards
|
|
1558
|
+
sections.push("// ── Type Interfaces ──");
|
|
1559
|
+
sections.push("");
|
|
1560
|
+
sections.push(...interfaces);
|
|
1561
|
+
sections.push("// ── Type Guards ──");
|
|
1562
|
+
sections.push("");
|
|
1563
|
+
sections.push(...guards);
|
|
1564
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Generate a simple interface declaration for use with type guards.
|
|
1568
|
+
*/
|
|
1569
|
+
function generateGuardInterface(name, node) {
|
|
1570
|
+
if (node.kind !== "object") {
|
|
1571
|
+
// For non-object types, generate a type alias
|
|
1572
|
+
const extracted = [];
|
|
1573
|
+
const tsType = typeNodeToTS(node, extracted, name, undefined, 0);
|
|
1574
|
+
return `export type ${name} = ${tsType};\n`;
|
|
1575
|
+
}
|
|
1576
|
+
const keys = Object.keys(node.properties);
|
|
1577
|
+
if (keys.length === 0)
|
|
1578
|
+
return `export type ${name} = Record<string, unknown>;\n`;
|
|
1579
|
+
const extracted = [];
|
|
1580
|
+
const lines = [];
|
|
1581
|
+
lines.push(`export interface ${name} {`);
|
|
1582
|
+
for (const key of keys) {
|
|
1583
|
+
const val = typeNodeToTS(node.properties[key], extracted, name, key, 1);
|
|
1584
|
+
lines.push(` ${key}: ${val};`);
|
|
1585
|
+
}
|
|
1586
|
+
lines.push(`}`);
|
|
1587
|
+
// Include any extracted sub-interfaces
|
|
1588
|
+
const subInterfaces = [];
|
|
1589
|
+
for (const ext of extracted) {
|
|
1590
|
+
if (ext.node.kind === "object") {
|
|
1591
|
+
subInterfaces.push(renderInterface(ext.name, ext.node, extracted));
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return [...subInterfaces, lines.join("\n")].join("\n\n") + "\n";
|
|
1595
|
+
}
|
|
1596
|
+
// ── Express validation middleware generation ──
|
|
1597
|
+
/**
|
|
1598
|
+
* Generate a validation expression that returns an array of error strings.
|
|
1599
|
+
* `varName` is the variable being checked.
|
|
1600
|
+
*/
|
|
1601
|
+
function typeNodeToValidation(node, varName, path, depth = 0) {
|
|
1602
|
+
if (depth > 3)
|
|
1603
|
+
return [];
|
|
1604
|
+
const checks = [];
|
|
1605
|
+
switch (node.kind) {
|
|
1606
|
+
case "primitive": {
|
|
1607
|
+
if (node.name === "null") {
|
|
1608
|
+
checks.push(`if (${varName} !== null) errors.push(\`${path} must be null\`);`);
|
|
1609
|
+
}
|
|
1610
|
+
else if (node.name === "undefined") {
|
|
1611
|
+
// Don't validate undefined — field just shouldn't be required
|
|
1612
|
+
}
|
|
1613
|
+
else {
|
|
1614
|
+
checks.push(`if (typeof ${varName} !== "${node.name}") errors.push(\`${path} must be a ${node.name}\`);`);
|
|
1615
|
+
}
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
case "array": {
|
|
1619
|
+
checks.push(`if (!Array.isArray(${varName})) errors.push(\`${path} must be an array\`);`);
|
|
1620
|
+
if (node.element.kind !== "unknown" && depth < 3) {
|
|
1621
|
+
checks.push(`else if (${varName}.length > 0) {`);
|
|
1622
|
+
checks.push(...typeNodeToValidation(node.element, `${varName}[0]`, `${path}[0]`, depth + 1).map(l => " " + l));
|
|
1623
|
+
checks.push(`}`);
|
|
1624
|
+
}
|
|
1625
|
+
break;
|
|
1626
|
+
}
|
|
1627
|
+
case "object": {
|
|
1628
|
+
const keys = Object.keys(node.properties);
|
|
1629
|
+
checks.push(`if (typeof ${varName} !== "object" || ${varName} === null) errors.push(\`${path} must be an object\`);`);
|
|
1630
|
+
if (keys.length > 0 && depth < 3) {
|
|
1631
|
+
checks.push(`else {`);
|
|
1632
|
+
for (const key of keys) {
|
|
1633
|
+
const propPath = path ? `${path}.${key}` : key;
|
|
1634
|
+
const propAccess = `${varName}["${key}"]`;
|
|
1635
|
+
// Check key existence
|
|
1636
|
+
checks.push(` if (!("${key}" in ${varName})) errors.push(\`${propPath} is required\`);`);
|
|
1637
|
+
// Type check if present
|
|
1638
|
+
const innerChecks = typeNodeToValidation(node.properties[key], propAccess, propPath, depth + 1);
|
|
1639
|
+
if (innerChecks.length > 0) {
|
|
1640
|
+
checks.push(` else {`);
|
|
1641
|
+
checks.push(...innerChecks.map(l => " " + l));
|
|
1642
|
+
checks.push(` }`);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
checks.push(`}`);
|
|
1646
|
+
}
|
|
1647
|
+
break;
|
|
1648
|
+
}
|
|
1649
|
+
case "union": {
|
|
1650
|
+
// For unions, value must match at least one member — skip detailed validation
|
|
1651
|
+
break;
|
|
1652
|
+
}
|
|
1653
|
+
default:
|
|
1654
|
+
break;
|
|
1655
|
+
}
|
|
1656
|
+
return checks;
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Generate Express validation middleware from runtime-observed types.
|
|
1660
|
+
*
|
|
1661
|
+
* For each POST/PUT/PATCH route, generates middleware that:
|
|
1662
|
+
* - Validates request body structure and types
|
|
1663
|
+
* - Returns 400 with structured errors on failure
|
|
1664
|
+
* - Passes through to next() on success
|
|
1665
|
+
*
|
|
1666
|
+
* Self-contained — no external validation library required.
|
|
1667
|
+
*/
|
|
1668
|
+
function generateMiddleware(functions) {
|
|
1669
|
+
// Filter to routes with request bodies
|
|
1670
|
+
const routes = [];
|
|
1671
|
+
for (const fn of functions) {
|
|
1672
|
+
const parsed = parseRouteName(fn.name);
|
|
1673
|
+
if (!parsed)
|
|
1674
|
+
continue;
|
|
1675
|
+
if (!["POST", "PUT", "PATCH"].includes(parsed.method))
|
|
1676
|
+
continue;
|
|
1677
|
+
let bodyNode;
|
|
1678
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
1679
|
+
bodyNode = fn.argsType.properties["body"];
|
|
1680
|
+
}
|
|
1681
|
+
if (!bodyNode || bodyNode.kind === "unknown")
|
|
1682
|
+
continue;
|
|
1683
|
+
if (bodyNode.kind === "object" && Object.keys(bodyNode.properties).length === 0)
|
|
1684
|
+
continue;
|
|
1685
|
+
routes.push({ parsed, fn, bodyNode });
|
|
1686
|
+
}
|
|
1687
|
+
if (routes.length === 0) {
|
|
1688
|
+
return "// No POST/PUT/PATCH routes with request bodies found.\n// Instrument your app and make some requests first.\n";
|
|
1689
|
+
}
|
|
1690
|
+
const sections = [];
|
|
1691
|
+
sections.push("// Auto-generated Express validation middleware by trickle");
|
|
1692
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
1693
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --middleware` to update");
|
|
1694
|
+
sections.push("");
|
|
1695
|
+
sections.push('import { Request, Response, NextFunction } from "express";');
|
|
1696
|
+
sections.push("");
|
|
1697
|
+
// Generate interfaces for request body types
|
|
1698
|
+
const extracted = [];
|
|
1699
|
+
for (const { parsed, bodyNode } of routes) {
|
|
1700
|
+
const typeName = `${parsed.typeName}Body`;
|
|
1701
|
+
if (bodyNode.kind === "object") {
|
|
1702
|
+
sections.push(renderInterface(typeName, bodyNode, extracted));
|
|
1703
|
+
}
|
|
1704
|
+
else {
|
|
1705
|
+
const tsType = typeNodeToTS(bodyNode, extracted, typeName, undefined, 0);
|
|
1706
|
+
sections.push(`export type ${typeName} = ${tsType};`);
|
|
1707
|
+
}
|
|
1708
|
+
sections.push("");
|
|
1709
|
+
}
|
|
1710
|
+
// Render any extracted sub-interfaces
|
|
1711
|
+
for (const ext of extracted) {
|
|
1712
|
+
if (ext.node.kind === "object") {
|
|
1713
|
+
sections.push(renderInterface(ext.name, ext.node, extracted));
|
|
1714
|
+
sections.push("");
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
// Generate validation middleware for each route
|
|
1718
|
+
for (const { parsed, bodyNode } of routes) {
|
|
1719
|
+
const middlewareName = `validate${parsed.typeName}`;
|
|
1720
|
+
const typeName = `${parsed.typeName}Body`;
|
|
1721
|
+
sections.push(`/**`);
|
|
1722
|
+
sections.push(` * Validates request body for ${parsed.method} ${parsed.path}`);
|
|
1723
|
+
sections.push(` * Returns 400 with structured errors if validation fails.`);
|
|
1724
|
+
sections.push(` */`);
|
|
1725
|
+
sections.push(`export function ${middlewareName}(req: Request, res: Response, next: NextFunction): void {`);
|
|
1726
|
+
sections.push(` const errors: string[] = [];`);
|
|
1727
|
+
sections.push(` const body = req.body;`);
|
|
1728
|
+
sections.push("");
|
|
1729
|
+
// Null/undefined check
|
|
1730
|
+
sections.push(` if (body === null || body === undefined || typeof body !== "object") {`);
|
|
1731
|
+
sections.push(` res.status(400).json({ error: "Request body is required", errors: ["body must be an object"] });`);
|
|
1732
|
+
sections.push(` return;`);
|
|
1733
|
+
sections.push(` }`);
|
|
1734
|
+
sections.push("");
|
|
1735
|
+
// Generate field validations
|
|
1736
|
+
if (bodyNode.kind === "object") {
|
|
1737
|
+
const keys = Object.keys(bodyNode.properties);
|
|
1738
|
+
for (const key of keys) {
|
|
1739
|
+
const propNode = bodyNode.properties[key];
|
|
1740
|
+
sections.push(` // Validate ${key}`);
|
|
1741
|
+
sections.push(` if (!("${key}" in body)) {`);
|
|
1742
|
+
sections.push(` errors.push("${key} is required");`);
|
|
1743
|
+
sections.push(` }`);
|
|
1744
|
+
const innerChecks = typeNodeToValidation(propNode, `body["${key}"]`, key, 1);
|
|
1745
|
+
if (innerChecks.length > 0) {
|
|
1746
|
+
sections.push(` else {`);
|
|
1747
|
+
for (const check of innerChecks) {
|
|
1748
|
+
sections.push(` ${check}`);
|
|
1749
|
+
}
|
|
1750
|
+
sections.push(` }`);
|
|
1751
|
+
}
|
|
1752
|
+
sections.push("");
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
sections.push(` if (errors.length > 0) {`);
|
|
1756
|
+
sections.push(` res.status(400).json({ error: "Validation failed", errors });`);
|
|
1757
|
+
sections.push(` return;`);
|
|
1758
|
+
sections.push(` }`);
|
|
1759
|
+
sections.push("");
|
|
1760
|
+
sections.push(` next();`);
|
|
1761
|
+
sections.push(`}`);
|
|
1762
|
+
sections.push("");
|
|
1763
|
+
}
|
|
1764
|
+
// Export a combined middleware map for convenience
|
|
1765
|
+
sections.push("/** Map of route patterns to their validation middleware */");
|
|
1766
|
+
sections.push("export const validators = {");
|
|
1767
|
+
for (const { parsed } of routes) {
|
|
1768
|
+
const middlewareName = `validate${parsed.typeName}`;
|
|
1769
|
+
sections.push(` "${parsed.method} ${parsed.path}": ${middlewareName},`);
|
|
1770
|
+
}
|
|
1771
|
+
sections.push("} as const;");
|
|
1772
|
+
sections.push("");
|
|
1773
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
1774
|
+
}
|
|
1775
|
+
// ── MSW Handler Generation ──
|
|
1776
|
+
/**
|
|
1777
|
+
* Generate a sample value literal string from a TypeNode.
|
|
1778
|
+
* Used to create realistic mock responses in MSW handlers.
|
|
1779
|
+
*/
|
|
1780
|
+
function typeNodeToSampleLiteral(node, indent = 0) {
|
|
1781
|
+
const pad = " ".repeat(indent);
|
|
1782
|
+
const innerPad = " ".repeat(indent + 1);
|
|
1783
|
+
switch (node.kind) {
|
|
1784
|
+
case "primitive":
|
|
1785
|
+
switch (node.name) {
|
|
1786
|
+
case "string": return '""';
|
|
1787
|
+
case "number": return "0";
|
|
1788
|
+
case "boolean": return "true";
|
|
1789
|
+
case "null": return "null";
|
|
1790
|
+
case "undefined": return "undefined";
|
|
1791
|
+
case "bigint": return "0";
|
|
1792
|
+
case "symbol": return '"symbol"';
|
|
1793
|
+
default: return "null";
|
|
1794
|
+
}
|
|
1795
|
+
case "array":
|
|
1796
|
+
return `[${typeNodeToSampleLiteral(node.element, indent)}]`;
|
|
1797
|
+
case "tuple": {
|
|
1798
|
+
const elements = node.elements.map((el) => typeNodeToSampleLiteral(el, indent));
|
|
1799
|
+
return `[${elements.join(", ")}]`;
|
|
1800
|
+
}
|
|
1801
|
+
case "object": {
|
|
1802
|
+
const keys = Object.keys(node.properties);
|
|
1803
|
+
if (keys.length === 0)
|
|
1804
|
+
return "{}";
|
|
1805
|
+
const entries = keys.map((key) => `${innerPad}${key}: ${typeNodeToSampleLiteral(node.properties[key], indent + 1)}`);
|
|
1806
|
+
return `{\n${entries.join(",\n")}\n${pad}}`;
|
|
1807
|
+
}
|
|
1808
|
+
case "union":
|
|
1809
|
+
// Use the first non-null/undefined member
|
|
1810
|
+
for (const m of node.members) {
|
|
1811
|
+
if (m.kind === "primitive" && (m.name === "null" || m.name === "undefined"))
|
|
1812
|
+
continue;
|
|
1813
|
+
return typeNodeToSampleLiteral(m, indent);
|
|
1814
|
+
}
|
|
1815
|
+
return "null";
|
|
1816
|
+
case "map":
|
|
1817
|
+
return "new Map()";
|
|
1818
|
+
case "set":
|
|
1819
|
+
return "new Set()";
|
|
1820
|
+
case "promise":
|
|
1821
|
+
return typeNodeToSampleLiteral(node.resolved, indent);
|
|
1822
|
+
case "function":
|
|
1823
|
+
return "() => {}";
|
|
1824
|
+
case "unknown":
|
|
1825
|
+
return "null";
|
|
1826
|
+
default:
|
|
1827
|
+
return "null";
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Generate Mock Service Worker (MSW) request handlers from observed API routes.
|
|
1832
|
+
*
|
|
1833
|
+
* Output:
|
|
1834
|
+
* - Import from 'msw'
|
|
1835
|
+
* - Response type interfaces for each route
|
|
1836
|
+
* - Individual handler exports (e.g. getApiUsersHandler)
|
|
1837
|
+
* - A combined `handlers` array for setupServer/setupWorker
|
|
1838
|
+
*/
|
|
1839
|
+
function generateMswHandlers(functions) {
|
|
1840
|
+
const routes = [];
|
|
1841
|
+
for (const fn of functions) {
|
|
1842
|
+
const parsed = parseRouteName(fn.name);
|
|
1843
|
+
if (parsed) {
|
|
1844
|
+
routes.push({ parsed, fn });
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
if (routes.length === 0) {
|
|
1848
|
+
return "// No API routes found. Instrument your Express app to generate MSW handlers.\n";
|
|
1849
|
+
}
|
|
1850
|
+
const sections = [];
|
|
1851
|
+
sections.push("// Auto-generated MSW request handlers by trickle");
|
|
1852
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
1853
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --msw` to update");
|
|
1854
|
+
sections.push("");
|
|
1855
|
+
sections.push('import { http, HttpResponse } from "msw";');
|
|
1856
|
+
sections.push("");
|
|
1857
|
+
// Generate response type interfaces
|
|
1858
|
+
const extracted = [];
|
|
1859
|
+
for (const { parsed, fn } of routes) {
|
|
1860
|
+
const responseName = `${parsed.typeName}Response`;
|
|
1861
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
1862
|
+
sections.push(renderInterface(responseName, fn.returnType, extracted));
|
|
1863
|
+
}
|
|
1864
|
+
else {
|
|
1865
|
+
const tsType = typeNodeToTS(fn.returnType, extracted, responseName, undefined, 0);
|
|
1866
|
+
sections.push(`export type ${responseName} = ${tsType};`);
|
|
1867
|
+
}
|
|
1868
|
+
sections.push("");
|
|
1869
|
+
}
|
|
1870
|
+
// Render extracted sub-interfaces
|
|
1871
|
+
for (const ext of extracted) {
|
|
1872
|
+
if (ext.node.kind === "object") {
|
|
1873
|
+
sections.push(renderInterface(ext.name, ext.node, extracted));
|
|
1874
|
+
sections.push("");
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
// Generate individual handler exports
|
|
1878
|
+
const handlerNames = [];
|
|
1879
|
+
for (const { parsed, fn } of routes) {
|
|
1880
|
+
const handlerName = `${parsed.funcName}Handler`;
|
|
1881
|
+
handlerNames.push(handlerName);
|
|
1882
|
+
const method = parsed.method.toLowerCase();
|
|
1883
|
+
// Convert Express-style :param to MSW-style :param (same format, already compatible)
|
|
1884
|
+
const mswPath = parsed.path;
|
|
1885
|
+
// Generate sample response from returnType
|
|
1886
|
+
const sampleResponse = typeNodeToSampleLiteral(fn.returnType, 1);
|
|
1887
|
+
sections.push(`/**`);
|
|
1888
|
+
sections.push(` * Mock handler for ${parsed.method} ${parsed.path}`);
|
|
1889
|
+
sections.push(` */`);
|
|
1890
|
+
sections.push(`export const ${handlerName} = http.${method}("${mswPath}", () => {`);
|
|
1891
|
+
sections.push(` return HttpResponse.json(${sampleResponse} satisfies ${parsed.typeName}Response);`);
|
|
1892
|
+
sections.push(`});`);
|
|
1893
|
+
sections.push("");
|
|
1894
|
+
}
|
|
1895
|
+
// Export combined handlers array
|
|
1896
|
+
sections.push("/** All mock handlers — use with setupServer(...handlers) or setupWorker(...handlers) */");
|
|
1897
|
+
sections.push("export const handlers = [");
|
|
1898
|
+
for (const name of handlerNames) {
|
|
1899
|
+
sections.push(` ${name},`);
|
|
1900
|
+
}
|
|
1901
|
+
sections.push("];");
|
|
1902
|
+
sections.push("");
|
|
1903
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
1904
|
+
}
|
|
1905
|
+
// ── JSON Schema Generation ──
|
|
1906
|
+
/**
|
|
1907
|
+
* Convert a TypeNode to a standalone JSON Schema (Draft 2020-12).
|
|
1908
|
+
* Unlike the OpenAPI variant, this produces proper JSON Schema with
|
|
1909
|
+
* $defs, oneOf, prefixItems, and nullable via type arrays.
|
|
1910
|
+
*/
|
|
1911
|
+
function typeNodeToStandaloneSchema(node) {
|
|
1912
|
+
switch (node.kind) {
|
|
1913
|
+
case "primitive":
|
|
1914
|
+
switch (node.name) {
|
|
1915
|
+
case "string": return { type: "string" };
|
|
1916
|
+
case "number": return { type: "number" };
|
|
1917
|
+
case "boolean": return { type: "boolean" };
|
|
1918
|
+
case "null": return { type: "null" };
|
|
1919
|
+
case "undefined": return { type: "null" };
|
|
1920
|
+
case "bigint": return { type: "integer" };
|
|
1921
|
+
case "symbol": return { type: "string" };
|
|
1922
|
+
default: return {};
|
|
1923
|
+
}
|
|
1924
|
+
case "array":
|
|
1925
|
+
return { type: "array", items: typeNodeToStandaloneSchema(node.element) };
|
|
1926
|
+
case "tuple":
|
|
1927
|
+
return {
|
|
1928
|
+
type: "array",
|
|
1929
|
+
prefixItems: node.elements.map(typeNodeToStandaloneSchema),
|
|
1930
|
+
minItems: node.elements.length,
|
|
1931
|
+
maxItems: node.elements.length,
|
|
1932
|
+
};
|
|
1933
|
+
case "object": {
|
|
1934
|
+
const properties = {};
|
|
1935
|
+
const required = [];
|
|
1936
|
+
for (const [key, val] of Object.entries(node.properties)) {
|
|
1937
|
+
properties[key] = typeNodeToStandaloneSchema(val);
|
|
1938
|
+
required.push(key);
|
|
1939
|
+
}
|
|
1940
|
+
const schema = { type: "object", properties };
|
|
1941
|
+
if (required.length > 0)
|
|
1942
|
+
schema.required = required;
|
|
1943
|
+
return schema;
|
|
1944
|
+
}
|
|
1945
|
+
case "union": {
|
|
1946
|
+
const nonNull = node.members.filter((m) => !(m.kind === "primitive" && (m.name === "null" || m.name === "undefined")));
|
|
1947
|
+
const hasNull = nonNull.length < node.members.length;
|
|
1948
|
+
if (nonNull.length === 1) {
|
|
1949
|
+
const inner = typeNodeToStandaloneSchema(nonNull[0]);
|
|
1950
|
+
if (hasNull) {
|
|
1951
|
+
if (typeof inner.type === "string") {
|
|
1952
|
+
return { ...inner, type: [inner.type, "null"] };
|
|
1953
|
+
}
|
|
1954
|
+
return { oneOf: [inner, { type: "null" }] };
|
|
1955
|
+
}
|
|
1956
|
+
return inner;
|
|
1957
|
+
}
|
|
1958
|
+
const schemas = nonNull.map(typeNodeToStandaloneSchema);
|
|
1959
|
+
if (hasNull)
|
|
1960
|
+
schemas.push({ type: "null" });
|
|
1961
|
+
return { oneOf: schemas };
|
|
1962
|
+
}
|
|
1963
|
+
case "map":
|
|
1964
|
+
return {
|
|
1965
|
+
type: "object",
|
|
1966
|
+
additionalProperties: typeNodeToStandaloneSchema(node.value),
|
|
1967
|
+
};
|
|
1968
|
+
case "set":
|
|
1969
|
+
return {
|
|
1970
|
+
type: "array",
|
|
1971
|
+
uniqueItems: true,
|
|
1972
|
+
items: typeNodeToStandaloneSchema(node.element),
|
|
1973
|
+
};
|
|
1974
|
+
case "promise":
|
|
1975
|
+
return typeNodeToStandaloneSchema(node.resolved);
|
|
1976
|
+
case "function":
|
|
1977
|
+
return {};
|
|
1978
|
+
case "unknown":
|
|
1979
|
+
return {};
|
|
1980
|
+
default:
|
|
1981
|
+
return {};
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Generate JSON Schema definitions from observed runtime types.
|
|
1986
|
+
*
|
|
1987
|
+
* Output is a single JSON object with $defs for each route/function's
|
|
1988
|
+
* request and response types, suitable for use with ajv, joi, or any
|
|
1989
|
+
* JSON Schema-compatible validator.
|
|
1990
|
+
*/
|
|
1991
|
+
function generateJsonSchemas(functions) {
|
|
1992
|
+
const defs = {};
|
|
1993
|
+
for (const fn of functions) {
|
|
1994
|
+
const parsed = parseRouteName(fn.name);
|
|
1995
|
+
const baseName = parsed ? parsed.typeName : toPascalCase(fn.name);
|
|
1996
|
+
// For routes, generate request body + response schemas
|
|
1997
|
+
if (parsed) {
|
|
1998
|
+
// Request body (only for methods with body)
|
|
1999
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
2000
|
+
let bodyNode;
|
|
2001
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
2002
|
+
bodyNode = fn.argsType.properties["body"];
|
|
2003
|
+
}
|
|
2004
|
+
if (bodyNode && bodyNode.kind !== "unknown") {
|
|
2005
|
+
const bodySchema = typeNodeToStandaloneSchema(bodyNode);
|
|
2006
|
+
defs[`${baseName}Request`] = {
|
|
2007
|
+
description: `Request body for ${parsed.method} ${parsed.path}`,
|
|
2008
|
+
...bodySchema,
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
// Response
|
|
2013
|
+
if (fn.returnType.kind !== "unknown") {
|
|
2014
|
+
const responseSchema = typeNodeToStandaloneSchema(fn.returnType);
|
|
2015
|
+
defs[`${baseName}Response`] = {
|
|
2016
|
+
description: `Response for ${parsed.method} ${parsed.path}`,
|
|
2017
|
+
...responseSchema,
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
else {
|
|
2022
|
+
// Non-route function: generate input/output schemas
|
|
2023
|
+
if (fn.argsType.kind !== "unknown") {
|
|
2024
|
+
defs[`${baseName}Input`] = typeNodeToStandaloneSchema(fn.argsType);
|
|
2025
|
+
}
|
|
2026
|
+
if (fn.returnType.kind !== "unknown") {
|
|
2027
|
+
defs[`${baseName}Output`] = typeNodeToStandaloneSchema(fn.returnType);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
const schema = {
|
|
2032
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
2033
|
+
title: "API Schemas",
|
|
2034
|
+
description: "Auto-generated JSON Schema definitions by trickle",
|
|
2035
|
+
$defs: defs,
|
|
2036
|
+
};
|
|
2037
|
+
return JSON.stringify(schema, null, 2) + "\n";
|
|
2038
|
+
}
|
|
2039
|
+
// ── SWR Hook Generation ──
|
|
2040
|
+
/**
|
|
2041
|
+
* Generate typed SWR hooks from observed API routes.
|
|
2042
|
+
*
|
|
2043
|
+
* Output:
|
|
2044
|
+
* - Import from 'swr' and 'swr/mutation'
|
|
2045
|
+
* - Response/input type interfaces
|
|
2046
|
+
* - A configurable fetcher
|
|
2047
|
+
* - useSWR hooks for GET routes
|
|
2048
|
+
* - useSWRMutation hooks for POST/PUT/PATCH/DELETE routes
|
|
2049
|
+
*/
|
|
2050
|
+
function generateSwrHooks(functions) {
|
|
2051
|
+
const routes = [];
|
|
2052
|
+
for (const fn of functions) {
|
|
2053
|
+
const parsed = parseRouteName(fn.name);
|
|
2054
|
+
if (parsed) {
|
|
2055
|
+
routes.push({ parsed, fn });
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
if (routes.length === 0) {
|
|
2059
|
+
return "// No API routes found. Instrument your Express/FastAPI app to generate SWR hooks.\n";
|
|
2060
|
+
}
|
|
2061
|
+
const sections = [];
|
|
2062
|
+
sections.push("// Auto-generated SWR hooks by trickle");
|
|
2063
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
2064
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --swr` to update");
|
|
2065
|
+
sections.push("");
|
|
2066
|
+
sections.push('import useSWR from "swr";');
|
|
2067
|
+
sections.push('import useSWRMutation from "swr/mutation";');
|
|
2068
|
+
sections.push('import type { SWRConfiguration, SWRResponse } from "swr";');
|
|
2069
|
+
sections.push('import type { SWRMutationConfiguration, SWRMutationResponse } from "swr/mutation";');
|
|
2070
|
+
sections.push("");
|
|
2071
|
+
// Generate interfaces
|
|
2072
|
+
const extracted = [];
|
|
2073
|
+
for (const { parsed, fn } of routes) {
|
|
2074
|
+
const baseName = parsed.typeName;
|
|
2075
|
+
// Response type
|
|
2076
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
2077
|
+
sections.push(renderInterface(`${baseName}Response`, fn.returnType, extracted));
|
|
2078
|
+
}
|
|
2079
|
+
else {
|
|
2080
|
+
const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
|
|
2081
|
+
sections.push(`export type ${baseName}Response = ${retStr};`);
|
|
2082
|
+
}
|
|
2083
|
+
sections.push("");
|
|
2084
|
+
// Request body type (POST/PUT/PATCH)
|
|
2085
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
2086
|
+
let bodyNode;
|
|
2087
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
2088
|
+
bodyNode = fn.argsType.properties["body"];
|
|
2089
|
+
}
|
|
2090
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
2091
|
+
sections.push(renderInterface(`${baseName}Input`, bodyNode, extracted));
|
|
2092
|
+
sections.push("");
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
// Emit extracted sub-interfaces
|
|
2097
|
+
const emitted = new Set();
|
|
2098
|
+
let cursor = 0;
|
|
2099
|
+
while (cursor < extracted.length) {
|
|
2100
|
+
const iface = extracted[cursor];
|
|
2101
|
+
cursor++;
|
|
2102
|
+
if (emitted.has(iface.name))
|
|
2103
|
+
continue;
|
|
2104
|
+
emitted.add(iface.name);
|
|
2105
|
+
sections.push(renderInterface(iface.name, iface.node, extracted));
|
|
2106
|
+
sections.push("");
|
|
2107
|
+
}
|
|
2108
|
+
// Fetcher setup
|
|
2109
|
+
sections.push("// ── Fetcher ──");
|
|
2110
|
+
sections.push("");
|
|
2111
|
+
sections.push("let _baseUrl = \"\";");
|
|
2112
|
+
sections.push("");
|
|
2113
|
+
sections.push("/** Set the base URL for all API requests. Call once at app startup. */");
|
|
2114
|
+
sections.push("export function configureSwrHooks(baseUrl: string) {");
|
|
2115
|
+
sections.push(" _baseUrl = baseUrl;");
|
|
2116
|
+
sections.push("}");
|
|
2117
|
+
sections.push("");
|
|
2118
|
+
sections.push("const fetcher = <T>(path: string): Promise<T> =>");
|
|
2119
|
+
sections.push(" fetch(`${_baseUrl}${path}`).then((res) => {");
|
|
2120
|
+
sections.push(" if (!res.ok) throw new Error(`GET ${path}: HTTP ${res.status}`);");
|
|
2121
|
+
sections.push(" return res.json() as Promise<T>;");
|
|
2122
|
+
sections.push(" });");
|
|
2123
|
+
sections.push("");
|
|
2124
|
+
sections.push("async function mutationFetcher<T>(");
|
|
2125
|
+
sections.push(" url: string,");
|
|
2126
|
+
sections.push(" { arg }: { arg: { method: string; body?: unknown } },");
|
|
2127
|
+
sections.push("): Promise<T> {");
|
|
2128
|
+
sections.push(" const opts: RequestInit = {");
|
|
2129
|
+
sections.push(" method: arg.method,");
|
|
2130
|
+
sections.push(' headers: { "Content-Type": "application/json" },');
|
|
2131
|
+
sections.push(" };");
|
|
2132
|
+
sections.push(" if (arg.body !== undefined) opts.body = JSON.stringify(arg.body);");
|
|
2133
|
+
sections.push(" const res = await fetch(`${_baseUrl}${url}`, opts);");
|
|
2134
|
+
sections.push(" if (!res.ok) throw new Error(`${arg.method} ${url}: HTTP ${res.status}`);");
|
|
2135
|
+
sections.push(" return res.json() as Promise<T>;");
|
|
2136
|
+
sections.push("}");
|
|
2137
|
+
sections.push("");
|
|
2138
|
+
// Generate hooks
|
|
2139
|
+
sections.push("// ── Hooks ──");
|
|
2140
|
+
sections.push("");
|
|
2141
|
+
for (const { parsed, fn } of routes) {
|
|
2142
|
+
const baseName = parsed.typeName;
|
|
2143
|
+
const hookName = `use${baseName}`;
|
|
2144
|
+
const responseType = `${baseName}Response`;
|
|
2145
|
+
if (parsed.method === "GET") {
|
|
2146
|
+
// useSWR hook
|
|
2147
|
+
const hasPathParams = parsed.pathParams.length > 0;
|
|
2148
|
+
const fnParams = [];
|
|
2149
|
+
if (hasPathParams) {
|
|
2150
|
+
for (const p of parsed.pathParams) {
|
|
2151
|
+
fnParams.push(`${p}: string`);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
fnParams.push(`config?: SWRConfiguration<${responseType}, Error>`);
|
|
2155
|
+
let pathExpr;
|
|
2156
|
+
if (hasPathParams) {
|
|
2157
|
+
pathExpr = "`" + parsed.path.replace(/:(\w+)/g, (_, param) => `\${${param}}`) + "`";
|
|
2158
|
+
}
|
|
2159
|
+
else {
|
|
2160
|
+
pathExpr = `"${parsed.path}"`;
|
|
2161
|
+
}
|
|
2162
|
+
sections.push(`/** ${parsed.method} ${parsed.path} */`);
|
|
2163
|
+
sections.push(`export function ${hookName}(${fnParams.join(", ")}): SWRResponse<${responseType}, Error> {`);
|
|
2164
|
+
sections.push(` return useSWR<${responseType}, Error>(${pathExpr}, fetcher<${responseType}>, config);`);
|
|
2165
|
+
sections.push("}");
|
|
2166
|
+
sections.push("");
|
|
2167
|
+
}
|
|
2168
|
+
else {
|
|
2169
|
+
// useSWRMutation hook for POST/PUT/PATCH/DELETE
|
|
2170
|
+
const method = parsed.method;
|
|
2171
|
+
const hasPathParams = parsed.pathParams.length > 0;
|
|
2172
|
+
const fnParams = [];
|
|
2173
|
+
if (hasPathParams) {
|
|
2174
|
+
for (const p of parsed.pathParams) {
|
|
2175
|
+
fnParams.push(`${p}: string`);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
// Check if route has input body
|
|
2179
|
+
let inputType;
|
|
2180
|
+
if (["POST", "PUT", "PATCH"].includes(method)) {
|
|
2181
|
+
let bodyNode;
|
|
2182
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
2183
|
+
bodyNode = fn.argsType.properties["body"];
|
|
2184
|
+
}
|
|
2185
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
2186
|
+
inputType = `${baseName}Input`;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
const triggerArgType = inputType || "void";
|
|
2190
|
+
fnParams.push(`config?: SWRMutationConfiguration<${responseType}, Error, string, ${triggerArgType}>`);
|
|
2191
|
+
let pathExpr;
|
|
2192
|
+
if (hasPathParams) {
|
|
2193
|
+
pathExpr = "`" + parsed.path.replace(/:(\w+)/g, (_, param) => `\${${param}}`) + "`";
|
|
2194
|
+
}
|
|
2195
|
+
else {
|
|
2196
|
+
pathExpr = `"${parsed.path}"`;
|
|
2197
|
+
}
|
|
2198
|
+
sections.push(`/** ${method} ${parsed.path} */`);
|
|
2199
|
+
sections.push(`export function ${hookName}(${fnParams.join(", ")}): SWRMutationResponse<${responseType}, Error, string, ${triggerArgType}> {`);
|
|
2200
|
+
sections.push(` return useSWRMutation<${responseType}, Error, string, ${triggerArgType}>(`);
|
|
2201
|
+
sections.push(` ${pathExpr},`);
|
|
2202
|
+
if (inputType) {
|
|
2203
|
+
sections.push(` (url, { arg }) => mutationFetcher<${responseType}>(url, { arg: { method: "${method}", body: arg } }),`);
|
|
2204
|
+
}
|
|
2205
|
+
else {
|
|
2206
|
+
sections.push(` (url) => mutationFetcher<${responseType}>(url, { arg: { method: "${method}" } }),`);
|
|
2207
|
+
}
|
|
2208
|
+
sections.push(" config,");
|
|
2209
|
+
sections.push(" );");
|
|
2210
|
+
sections.push("}");
|
|
2211
|
+
sections.push("");
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
2215
|
+
}
|
|
2216
|
+
// ── Pydantic Model Generation ──
|
|
2217
|
+
/**
|
|
2218
|
+
* Convert a TypeNode to a Pydantic-compatible Python type string.
|
|
2219
|
+
*/
|
|
2220
|
+
function typeNodeToPydantic(node, extracted, parentName, propName) {
|
|
2221
|
+
switch (node.kind) {
|
|
2222
|
+
case "primitive":
|
|
2223
|
+
switch (node.name) {
|
|
2224
|
+
case "string": return "str";
|
|
2225
|
+
case "number": return "float";
|
|
2226
|
+
case "boolean": return "bool";
|
|
2227
|
+
case "null": return "None";
|
|
2228
|
+
case "undefined": return "None";
|
|
2229
|
+
case "bigint": return "int";
|
|
2230
|
+
case "symbol": return "str";
|
|
2231
|
+
default: return "Any";
|
|
2232
|
+
}
|
|
2233
|
+
case "unknown":
|
|
2234
|
+
return "Any";
|
|
2235
|
+
case "array": {
|
|
2236
|
+
const inner = typeNodeToPydantic(node.element, extracted, parentName, propName);
|
|
2237
|
+
return `List[${inner}]`;
|
|
2238
|
+
}
|
|
2239
|
+
case "tuple": {
|
|
2240
|
+
const elements = node.elements.map((el, i) => typeNodeToPydantic(el, extracted, parentName, `el${i}`));
|
|
2241
|
+
return `Tuple[${elements.join(", ")}]`;
|
|
2242
|
+
}
|
|
2243
|
+
case "union": {
|
|
2244
|
+
const members = node.members.map((m) => typeNodeToPydantic(m, extracted, parentName, propName));
|
|
2245
|
+
if (members.length === 2 && members.includes("None")) {
|
|
2246
|
+
const nonNone = members.find((m) => m !== "None");
|
|
2247
|
+
return `Optional[${nonNone}]`;
|
|
2248
|
+
}
|
|
2249
|
+
return `Union[${members.join(", ")}]`;
|
|
2250
|
+
}
|
|
2251
|
+
case "map": {
|
|
2252
|
+
const v = typeNodeToPydantic(node.value, extracted, parentName, "value");
|
|
2253
|
+
return `Dict[str, ${v}]`;
|
|
2254
|
+
}
|
|
2255
|
+
case "set": {
|
|
2256
|
+
const inner = typeNodeToPydantic(node.element, extracted, parentName, propName);
|
|
2257
|
+
return `Set[${inner}]`;
|
|
2258
|
+
}
|
|
2259
|
+
case "promise":
|
|
2260
|
+
return typeNodeToPydantic(node.resolved, extracted, parentName, propName);
|
|
2261
|
+
case "function":
|
|
2262
|
+
return "Any";
|
|
2263
|
+
case "object": {
|
|
2264
|
+
const keys = Object.keys(node.properties);
|
|
2265
|
+
if (keys.length === 0)
|
|
2266
|
+
return "Dict[str, Any]";
|
|
2267
|
+
if (propName) {
|
|
2268
|
+
const className = toPascalCase(parentName) + toPascalCase(propName);
|
|
2269
|
+
if (!extracted.some((e) => e.name === className)) {
|
|
2270
|
+
extracted.push({ name: className, node });
|
|
2271
|
+
}
|
|
2272
|
+
return className;
|
|
2273
|
+
}
|
|
2274
|
+
return "Dict[str, Any]";
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Render a Pydantic BaseModel class from an object TypeNode.
|
|
2280
|
+
*/
|
|
2281
|
+
function renderPydanticModel(name, node, extracted) {
|
|
2282
|
+
const keys = Object.keys(node.properties);
|
|
2283
|
+
const innerExtracted = [];
|
|
2284
|
+
const lines = [];
|
|
2285
|
+
const entries = keys.map((key) => {
|
|
2286
|
+
const pyType = typeNodeToPydantic(node.properties[key], innerExtracted, name, key);
|
|
2287
|
+
return ` ${toSnakeCase(key)}: ${pyType}`;
|
|
2288
|
+
});
|
|
2289
|
+
// Emit nested models first (they must be defined before use)
|
|
2290
|
+
for (const iface of innerExtracted) {
|
|
2291
|
+
lines.push(renderPydanticModel(iface.name, iface.node, innerExtracted));
|
|
2292
|
+
lines.push("");
|
|
2293
|
+
lines.push("");
|
|
2294
|
+
}
|
|
2295
|
+
lines.push(`class ${name}(BaseModel):`);
|
|
2296
|
+
if (entries.length === 0) {
|
|
2297
|
+
lines.push(" pass");
|
|
2298
|
+
}
|
|
2299
|
+
else {
|
|
2300
|
+
lines.push(...entries);
|
|
2301
|
+
}
|
|
2302
|
+
return lines.join("\n");
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Generate Pydantic BaseModel classes from observed runtime types.
|
|
2306
|
+
*
|
|
2307
|
+
* Unlike --python (TypedDict), Pydantic models provide:
|
|
2308
|
+
* - Runtime validation (model_validate)
|
|
2309
|
+
* - JSON serialization (model_dump_json)
|
|
2310
|
+
* - JSON Schema generation (model_json_schema)
|
|
2311
|
+
* - Direct use as FastAPI request/response models
|
|
2312
|
+
*/
|
|
2313
|
+
function generatePydanticModels(functions) {
|
|
2314
|
+
const sections = [];
|
|
2315
|
+
sections.push("# Auto-generated Pydantic models by trickle");
|
|
2316
|
+
sections.push(`# Generated at ${new Date().toISOString()}`);
|
|
2317
|
+
sections.push("# Do not edit manually — re-run `trickle codegen --pydantic` to update");
|
|
2318
|
+
sections.push("");
|
|
2319
|
+
sections.push("from __future__ import annotations");
|
|
2320
|
+
sections.push("");
|
|
2321
|
+
sections.push("from typing import Any, Dict, List, Optional, Set, Tuple, Union");
|
|
2322
|
+
sections.push("");
|
|
2323
|
+
sections.push("from pydantic import BaseModel");
|
|
2324
|
+
sections.push("");
|
|
2325
|
+
sections.push("");
|
|
2326
|
+
for (const fn of functions) {
|
|
2327
|
+
const parsed = parseRouteName(fn.name);
|
|
2328
|
+
const baseName = parsed ? parsed.typeName : toPascalCase(fn.name);
|
|
2329
|
+
const metaParts = [];
|
|
2330
|
+
if (fn.module)
|
|
2331
|
+
metaParts.push(`${fn.module} module`);
|
|
2332
|
+
if (fn.env)
|
|
2333
|
+
metaParts.push(`observed in ${fn.env}`);
|
|
2334
|
+
if (fn.observedAt)
|
|
2335
|
+
metaParts.push(formatTimeAgo(fn.observedAt));
|
|
2336
|
+
if (metaParts.length > 0) {
|
|
2337
|
+
sections.push(`# ${baseName} — ${metaParts.join(", ")}`);
|
|
2338
|
+
}
|
|
2339
|
+
if (parsed) {
|
|
2340
|
+
// Route-style: generate Request (body) and Response models
|
|
2341
|
+
// Request body (only for methods with body)
|
|
2342
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
2343
|
+
let bodyNode;
|
|
2344
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
2345
|
+
bodyNode = fn.argsType.properties["body"];
|
|
2346
|
+
}
|
|
2347
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
2348
|
+
const extracted = [];
|
|
2349
|
+
sections.push(renderPydanticModel(`${baseName}Request`, bodyNode, extracted));
|
|
2350
|
+
sections.push("");
|
|
2351
|
+
sections.push("");
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
// Response model
|
|
2355
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
2356
|
+
const extracted = [];
|
|
2357
|
+
sections.push(renderPydanticModel(`${baseName}Response`, fn.returnType, extracted));
|
|
2358
|
+
}
|
|
2359
|
+
else if (fn.returnType.kind !== "unknown") {
|
|
2360
|
+
const extracted = [];
|
|
2361
|
+
const pyType = typeNodeToPydantic(fn.returnType, extracted, baseName, undefined);
|
|
2362
|
+
sections.push(`${baseName}Response = ${pyType}`);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
else {
|
|
2366
|
+
// Non-route: generate Input/Output models
|
|
2367
|
+
if (fn.argsType.kind === "object" && Object.keys(fn.argsType.properties).length > 0) {
|
|
2368
|
+
const extracted = [];
|
|
2369
|
+
sections.push(renderPydanticModel(`${baseName}Input`, fn.argsType, extracted));
|
|
2370
|
+
sections.push("");
|
|
2371
|
+
sections.push("");
|
|
2372
|
+
}
|
|
2373
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
2374
|
+
const extracted = [];
|
|
2375
|
+
sections.push(renderPydanticModel(`${baseName}Output`, fn.returnType, extracted));
|
|
2376
|
+
}
|
|
2377
|
+
else if (fn.returnType.kind !== "unknown") {
|
|
2378
|
+
const extracted = [];
|
|
2379
|
+
const pyType = typeNodeToPydantic(fn.returnType, extracted, baseName, undefined);
|
|
2380
|
+
sections.push(`${baseName}Output = ${pyType}`);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
sections.push("");
|
|
2384
|
+
sections.push("");
|
|
2385
|
+
}
|
|
2386
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
2387
|
+
}
|
|
2388
|
+
// ── class-validator DTO Generation (NestJS) ──
|
|
2389
|
+
/**
|
|
2390
|
+
* Get the class-validator decorator for a TypeNode.
|
|
2391
|
+
* Returns the decorator string(s) and the TypeScript type.
|
|
2392
|
+
*/
|
|
2393
|
+
function classValidatorField(node, propName, parentName, nestedClasses) {
|
|
2394
|
+
switch (node.kind) {
|
|
2395
|
+
case "primitive":
|
|
2396
|
+
switch (node.name) {
|
|
2397
|
+
case "string":
|
|
2398
|
+
return { decorators: ["@IsString()"], tsType: "string" };
|
|
2399
|
+
case "number":
|
|
2400
|
+
return { decorators: ["@IsNumber()"], tsType: "number" };
|
|
2401
|
+
case "boolean":
|
|
2402
|
+
return { decorators: ["@IsBoolean()"], tsType: "boolean" };
|
|
2403
|
+
default:
|
|
2404
|
+
return { decorators: [], tsType: "any" };
|
|
2405
|
+
}
|
|
2406
|
+
case "array": {
|
|
2407
|
+
const innerDecorators = ["@IsArray()"];
|
|
2408
|
+
if (node.element.kind === "object" && Object.keys(node.element.properties).length > 0) {
|
|
2409
|
+
const nestedName = parentName + toPascalCase(propName) + "Item";
|
|
2410
|
+
if (!nestedClasses.some((c) => c.name === nestedName)) {
|
|
2411
|
+
nestedClasses.push({ name: nestedName, node: node.element });
|
|
2412
|
+
}
|
|
2413
|
+
innerDecorators.push("@ValidateNested({ each: true })");
|
|
2414
|
+
innerDecorators.push(`@Type(() => ${nestedName})`);
|
|
2415
|
+
return { decorators: innerDecorators, tsType: `${nestedName}[]` };
|
|
2416
|
+
}
|
|
2417
|
+
const inner = classValidatorField(node.element, propName, parentName, nestedClasses);
|
|
2418
|
+
return { decorators: innerDecorators, tsType: `${inner.tsType}[]` };
|
|
2419
|
+
}
|
|
2420
|
+
case "object": {
|
|
2421
|
+
const keys = Object.keys(node.properties);
|
|
2422
|
+
if (keys.length === 0) {
|
|
2423
|
+
return { decorators: ["@IsObject()"], tsType: "Record<string, any>" };
|
|
2424
|
+
}
|
|
2425
|
+
const nestedName = parentName + toPascalCase(propName);
|
|
2426
|
+
if (!nestedClasses.some((c) => c.name === nestedName)) {
|
|
2427
|
+
nestedClasses.push({ name: nestedName, node });
|
|
2428
|
+
}
|
|
2429
|
+
return {
|
|
2430
|
+
decorators: ["@ValidateNested()", `@Type(() => ${nestedName})`],
|
|
2431
|
+
tsType: nestedName,
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
case "union": {
|
|
2435
|
+
// Check for nullable (T | null)
|
|
2436
|
+
const nonNull = node.members.filter((m) => !(m.kind === "primitive" && (m.name === "null" || m.name === "undefined")));
|
|
2437
|
+
const isNullable = nonNull.length < node.members.length;
|
|
2438
|
+
if (nonNull.length === 1) {
|
|
2439
|
+
const inner = classValidatorField(nonNull[0], propName, parentName, nestedClasses);
|
|
2440
|
+
if (isNullable) {
|
|
2441
|
+
return {
|
|
2442
|
+
decorators: ["@IsOptional()", ...inner.decorators],
|
|
2443
|
+
tsType: `${inner.tsType} | null`,
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
return inner;
|
|
2447
|
+
}
|
|
2448
|
+
// Complex union — use basic validation
|
|
2449
|
+
return { decorators: [], tsType: "any" };
|
|
2450
|
+
}
|
|
2451
|
+
case "unknown":
|
|
2452
|
+
return { decorators: [], tsType: "any" };
|
|
2453
|
+
default:
|
|
2454
|
+
return { decorators: [], tsType: "any" };
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
/**
|
|
2458
|
+
* Render a class-validator DTO class.
|
|
2459
|
+
*/
|
|
2460
|
+
function renderValidatorClass(name, node, nestedClasses) {
|
|
2461
|
+
const lines = [];
|
|
2462
|
+
const keys = Object.keys(node.properties);
|
|
2463
|
+
lines.push(`export class ${name} {`);
|
|
2464
|
+
for (let i = 0; i < keys.length; i++) {
|
|
2465
|
+
const key = keys[i];
|
|
2466
|
+
const propNode = node.properties[key];
|
|
2467
|
+
const { decorators, tsType } = classValidatorField(propNode, key, name, nestedClasses);
|
|
2468
|
+
for (const dec of decorators) {
|
|
2469
|
+
lines.push(` ${dec}`);
|
|
2470
|
+
}
|
|
2471
|
+
lines.push(` ${key}: ${tsType};`);
|
|
2472
|
+
if (i < keys.length - 1)
|
|
2473
|
+
lines.push("");
|
|
2474
|
+
}
|
|
2475
|
+
lines.push("}");
|
|
2476
|
+
return lines.join("\n");
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Generate class-validator DTO classes from observed runtime types.
|
|
2480
|
+
*
|
|
2481
|
+
* Output: NestJS-ready DTOs with class-validator decorators for
|
|
2482
|
+
* request validation and class-transformer for nested object support.
|
|
2483
|
+
*/
|
|
2484
|
+
function generateClassValidatorDtos(functions) {
|
|
2485
|
+
const routes = [];
|
|
2486
|
+
for (const fn of functions) {
|
|
2487
|
+
const parsed = parseRouteName(fn.name);
|
|
2488
|
+
if (parsed) {
|
|
2489
|
+
routes.push({ parsed, fn });
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
if (routes.length === 0) {
|
|
2493
|
+
return "// No API routes found. Instrument your Express/NestJS app to generate DTOs.\n";
|
|
2494
|
+
}
|
|
2495
|
+
const sections = [];
|
|
2496
|
+
sections.push("// Auto-generated class-validator DTOs by trickle");
|
|
2497
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
2498
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --class-validator` to update");
|
|
2499
|
+
sections.push("");
|
|
2500
|
+
// Collect which decorators are needed
|
|
2501
|
+
const usedDecorators = new Set();
|
|
2502
|
+
const needsType = { value: false };
|
|
2503
|
+
// Pre-scan to collect all classes and nested classes
|
|
2504
|
+
const allClasses = [];
|
|
2505
|
+
const nestedClasses = [];
|
|
2506
|
+
for (const { parsed, fn } of routes) {
|
|
2507
|
+
// Request body DTO (POST/PUT/PATCH only)
|
|
2508
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
2509
|
+
let bodyNode;
|
|
2510
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
2511
|
+
bodyNode = fn.argsType.properties["body"];
|
|
2512
|
+
}
|
|
2513
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
2514
|
+
allClasses.push({
|
|
2515
|
+
name: `${parsed.typeName}Body`,
|
|
2516
|
+
node: bodyNode,
|
|
2517
|
+
comment: `/** Request body for ${parsed.method} ${parsed.path} */`,
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
// Response DTO
|
|
2522
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
2523
|
+
allClasses.push({
|
|
2524
|
+
name: `${parsed.typeName}Response`,
|
|
2525
|
+
node: fn.returnType,
|
|
2526
|
+
comment: `/** Response for ${parsed.method} ${parsed.path} */`,
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
// Render all classes, collecting nested ones as we go
|
|
2531
|
+
const renderedClasses = [];
|
|
2532
|
+
const rendered = new Set();
|
|
2533
|
+
for (const cls of allClasses) {
|
|
2534
|
+
if (rendered.has(cls.name))
|
|
2535
|
+
continue;
|
|
2536
|
+
rendered.add(cls.name);
|
|
2537
|
+
const code = renderValidatorClass(cls.name, cls.node, nestedClasses);
|
|
2538
|
+
renderedClasses.push({ name: cls.name, code, comment: cls.comment });
|
|
2539
|
+
}
|
|
2540
|
+
// Render nested classes (may add more to nestedClasses as we process)
|
|
2541
|
+
let cursor = 0;
|
|
2542
|
+
while (cursor < nestedClasses.length) {
|
|
2543
|
+
const nested = nestedClasses[cursor];
|
|
2544
|
+
cursor++;
|
|
2545
|
+
if (rendered.has(nested.name))
|
|
2546
|
+
continue;
|
|
2547
|
+
rendered.add(nested.name);
|
|
2548
|
+
const code = renderValidatorClass(nested.name, nested.node, nestedClasses);
|
|
2549
|
+
renderedClasses.unshift({ name: nested.name, code, comment: "" });
|
|
2550
|
+
}
|
|
2551
|
+
// Scan for used decorators
|
|
2552
|
+
const allCode = renderedClasses.map((c) => c.code).join("\n");
|
|
2553
|
+
const decoratorNames = ["IsString", "IsNumber", "IsBoolean", "IsArray", "IsObject", "IsOptional", "ValidateNested", "IsNotEmpty"];
|
|
2554
|
+
for (const name of decoratorNames) {
|
|
2555
|
+
if (allCode.includes(`@${name}`))
|
|
2556
|
+
usedDecorators.add(name);
|
|
2557
|
+
}
|
|
2558
|
+
if (allCode.includes("@Type("))
|
|
2559
|
+
needsType.value = true;
|
|
2560
|
+
// Emit imports
|
|
2561
|
+
if (usedDecorators.size > 0) {
|
|
2562
|
+
const sorted = Array.from(usedDecorators).sort();
|
|
2563
|
+
sections.push(`import { ${sorted.join(", ")} } from "class-validator";`);
|
|
2564
|
+
}
|
|
2565
|
+
if (needsType.value) {
|
|
2566
|
+
sections.push('import { Type } from "class-transformer";');
|
|
2567
|
+
}
|
|
2568
|
+
sections.push("");
|
|
2569
|
+
// Emit classes
|
|
2570
|
+
for (const cls of renderedClasses) {
|
|
2571
|
+
if (cls.comment) {
|
|
2572
|
+
sections.push(cls.comment);
|
|
2573
|
+
}
|
|
2574
|
+
sections.push(cls.code);
|
|
2575
|
+
sections.push("");
|
|
2576
|
+
}
|
|
2577
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
2578
|
+
}
|
|
2579
|
+
// ── GraphQL SDL Generation ──
|
|
2580
|
+
/**
|
|
2581
|
+
* Convert a TypeNode to a GraphQL type string, accumulating named types.
|
|
2582
|
+
*/
|
|
2583
|
+
function typeNodeToGraphQL(node, namedTypes, parentName, propName) {
|
|
2584
|
+
switch (node.kind) {
|
|
2585
|
+
case "primitive":
|
|
2586
|
+
switch (node.name) {
|
|
2587
|
+
case "string": return "String";
|
|
2588
|
+
case "number": return "Float";
|
|
2589
|
+
case "boolean": return "Boolean";
|
|
2590
|
+
case "bigint": return "String";
|
|
2591
|
+
case "null": return "String";
|
|
2592
|
+
case "undefined": return "String";
|
|
2593
|
+
case "symbol": return "String";
|
|
2594
|
+
default: return "String";
|
|
2595
|
+
}
|
|
2596
|
+
case "array": {
|
|
2597
|
+
const elementType = typeNodeToGraphQL(node.element, namedTypes, parentName, propName);
|
|
2598
|
+
return `[${elementType}]`;
|
|
2599
|
+
}
|
|
2600
|
+
case "object": {
|
|
2601
|
+
const typeName = propName
|
|
2602
|
+
? parentName + toPascalCase(propName)
|
|
2603
|
+
: parentName;
|
|
2604
|
+
const fields = [];
|
|
2605
|
+
for (const [key, val] of Object.entries(node.properties)) {
|
|
2606
|
+
const fieldType = typeNodeToGraphQL(val, namedTypes, typeName, key);
|
|
2607
|
+
fields.push(` ${key}: ${fieldType}`);
|
|
2608
|
+
}
|
|
2609
|
+
if (fields.length === 0) {
|
|
2610
|
+
return "String";
|
|
2611
|
+
}
|
|
2612
|
+
const body = `type ${typeName} {\n${fields.join("\n")}\n}`;
|
|
2613
|
+
namedTypes.set(typeName, body);
|
|
2614
|
+
return typeName;
|
|
2615
|
+
}
|
|
2616
|
+
case "union": {
|
|
2617
|
+
// GraphQL unions only work for object types; for primitives, pick the first non-null
|
|
2618
|
+
const nonNull = node.members.filter((m) => !(m.kind === "primitive" && (m.name === "null" || m.name === "undefined")));
|
|
2619
|
+
if (nonNull.length === 0)
|
|
2620
|
+
return "String";
|
|
2621
|
+
return typeNodeToGraphQL(nonNull[0], namedTypes, parentName, propName);
|
|
2622
|
+
}
|
|
2623
|
+
case "promise":
|
|
2624
|
+
return typeNodeToGraphQL(node.resolved, namedTypes, parentName, propName);
|
|
2625
|
+
case "map":
|
|
2626
|
+
return "String"; // JSON scalar
|
|
2627
|
+
case "set": {
|
|
2628
|
+
const elementType = typeNodeToGraphQL(node.element, namedTypes, parentName, propName);
|
|
2629
|
+
return `[${elementType}]`;
|
|
2630
|
+
}
|
|
2631
|
+
case "tuple": {
|
|
2632
|
+
if (node.elements.length === 0)
|
|
2633
|
+
return "[String]";
|
|
2634
|
+
const elementType = typeNodeToGraphQL(node.elements[0], namedTypes, parentName, propName);
|
|
2635
|
+
return `[${elementType}]`;
|
|
2636
|
+
}
|
|
2637
|
+
case "function":
|
|
2638
|
+
case "unknown":
|
|
2639
|
+
default:
|
|
2640
|
+
return "String";
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Generate a GraphQL SDL schema from runtime-observed API routes.
|
|
2645
|
+
*
|
|
2646
|
+
* Converts REST routes into Query (GET) and Mutation (POST/PUT/PATCH/DELETE)
|
|
2647
|
+
* fields with properly typed inputs and outputs.
|
|
2648
|
+
*/
|
|
2649
|
+
function generateGraphqlSchema(functions) {
|
|
2650
|
+
const namedTypes = new Map();
|
|
2651
|
+
const queryFields = [];
|
|
2652
|
+
const mutationFields = [];
|
|
2653
|
+
const inputTypes = [];
|
|
2654
|
+
for (const fn of functions) {
|
|
2655
|
+
const route = parseRouteName(fn.name);
|
|
2656
|
+
if (!route)
|
|
2657
|
+
continue;
|
|
2658
|
+
const typeName = route.typeName;
|
|
2659
|
+
const fieldName = toCamelCase(fn.name);
|
|
2660
|
+
// Generate return type
|
|
2661
|
+
const returnTypeName = typeName + "Response";
|
|
2662
|
+
typeNodeToGraphQL(fn.returnType, namedTypes, returnTypeName);
|
|
2663
|
+
// Build field arguments from argsType
|
|
2664
|
+
const args = [];
|
|
2665
|
+
if (fn.argsType.kind === "object") {
|
|
2666
|
+
const props = fn.argsType.properties;
|
|
2667
|
+
// Path params
|
|
2668
|
+
for (const param of route.pathParams) {
|
|
2669
|
+
args.push(`${param}: String!`);
|
|
2670
|
+
}
|
|
2671
|
+
// Query params
|
|
2672
|
+
if (props.query && props.query.kind === "object") {
|
|
2673
|
+
for (const [key, val] of Object.entries(props.query.properties)) {
|
|
2674
|
+
const gqlType = typeNodeToGraphQL(val, namedTypes, typeName, key);
|
|
2675
|
+
args.push(`${key}: ${gqlType}`);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
// Body → input type for mutations
|
|
2679
|
+
if (props.body && props.body.kind === "object") {
|
|
2680
|
+
const inputName = typeName + "Input";
|
|
2681
|
+
const inputFields = [];
|
|
2682
|
+
for (const [key, val] of Object.entries(props.body.properties)) {
|
|
2683
|
+
const fieldType = typeNodeToGraphQLInput(val, namedTypes, inputName, key);
|
|
2684
|
+
inputFields.push(` ${key}: ${fieldType}`);
|
|
2685
|
+
}
|
|
2686
|
+
if (inputFields.length > 0) {
|
|
2687
|
+
inputTypes.push(`input ${inputName} {\n${inputFields.join("\n")}\n}`);
|
|
2688
|
+
args.push(`input: ${inputName}!`);
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
const argsStr = args.length > 0 ? `(${args.join(", ")})` : "";
|
|
2693
|
+
// Determine the return type name — use the named type if it was created
|
|
2694
|
+
const resolvedReturnType = namedTypes.has(returnTypeName) ? returnTypeName : typeNodeToGraphQL(fn.returnType, namedTypes, returnTypeName);
|
|
2695
|
+
if (route.method === "GET") {
|
|
2696
|
+
queryFields.push(` ${fieldName}${argsStr}: ${resolvedReturnType}`);
|
|
2697
|
+
}
|
|
2698
|
+
else {
|
|
2699
|
+
mutationFields.push(` ${fieldName}${argsStr}: ${resolvedReturnType}`);
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
if (queryFields.length === 0 && mutationFields.length === 0) {
|
|
2703
|
+
return "# No API routes found.\n";
|
|
2704
|
+
}
|
|
2705
|
+
const sections = [];
|
|
2706
|
+
sections.push("# Auto-generated GraphQL schema from runtime-observed types");
|
|
2707
|
+
sections.push("# Generated by trickle — https://github.com/yiheinchai/trickle");
|
|
2708
|
+
sections.push("");
|
|
2709
|
+
// Emit named types (skip duplicates, emit in order)
|
|
2710
|
+
const emittedTypes = new Set();
|
|
2711
|
+
for (const [name, body] of namedTypes) {
|
|
2712
|
+
if (!emittedTypes.has(name)) {
|
|
2713
|
+
emittedTypes.add(name);
|
|
2714
|
+
sections.push(body);
|
|
2715
|
+
sections.push("");
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
// Emit input types
|
|
2719
|
+
for (const input of inputTypes) {
|
|
2720
|
+
sections.push(input);
|
|
2721
|
+
sections.push("");
|
|
2722
|
+
}
|
|
2723
|
+
// Emit Query
|
|
2724
|
+
if (queryFields.length > 0) {
|
|
2725
|
+
sections.push(`type Query {`);
|
|
2726
|
+
for (const f of queryFields) {
|
|
2727
|
+
sections.push(f);
|
|
2728
|
+
}
|
|
2729
|
+
sections.push("}");
|
|
2730
|
+
sections.push("");
|
|
2731
|
+
}
|
|
2732
|
+
// Emit Mutation
|
|
2733
|
+
if (mutationFields.length > 0) {
|
|
2734
|
+
sections.push(`type Mutation {`);
|
|
2735
|
+
for (const f of mutationFields) {
|
|
2736
|
+
sections.push(f);
|
|
2737
|
+
}
|
|
2738
|
+
sections.push("}");
|
|
2739
|
+
sections.push("");
|
|
2740
|
+
}
|
|
2741
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Convert TypeNode to GraphQL input type string (uses `input` instead of `type` for nested objects).
|
|
2745
|
+
*/
|
|
2746
|
+
function typeNodeToGraphQLInput(node, namedTypes, parentName, propName) {
|
|
2747
|
+
switch (node.kind) {
|
|
2748
|
+
case "primitive":
|
|
2749
|
+
switch (node.name) {
|
|
2750
|
+
case "string": return "String";
|
|
2751
|
+
case "number": return "Float";
|
|
2752
|
+
case "boolean": return "Boolean";
|
|
2753
|
+
default: return "String";
|
|
2754
|
+
}
|
|
2755
|
+
case "array": {
|
|
2756
|
+
const elementType = typeNodeToGraphQLInput(node.element, namedTypes, parentName, propName);
|
|
2757
|
+
return `[${elementType}]`;
|
|
2758
|
+
}
|
|
2759
|
+
case "object": {
|
|
2760
|
+
const typeName = propName
|
|
2761
|
+
? parentName + toPascalCase(propName)
|
|
2762
|
+
: parentName;
|
|
2763
|
+
// Don't emit as named type — just inline or skip
|
|
2764
|
+
// For nested input objects, the parent handles them
|
|
2765
|
+
return "String"; // Flatten deep nested inputs to JSON string
|
|
2766
|
+
}
|
|
2767
|
+
case "union": {
|
|
2768
|
+
const nonNull = node.members.filter((m) => !(m.kind === "primitive" && (m.name === "null" || m.name === "undefined")));
|
|
2769
|
+
if (nonNull.length === 0)
|
|
2770
|
+
return "String";
|
|
2771
|
+
return typeNodeToGraphQLInput(nonNull[0], namedTypes, parentName, propName);
|
|
2772
|
+
}
|
|
2773
|
+
default:
|
|
2774
|
+
return "String";
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
// ── tRPC Router Generation ──
|
|
2778
|
+
/**
|
|
2779
|
+
* Generate a fully-typed tRPC router from runtime-observed API routes.
|
|
2780
|
+
*
|
|
2781
|
+
* - GET routes → t.procedure.query()
|
|
2782
|
+
* - POST/PUT/PATCH/DELETE routes → t.procedure.input(zodSchema).mutation()
|
|
2783
|
+
* - Includes Zod input schemas for request body validation
|
|
2784
|
+
* - Includes TypeScript return type annotations from observed responses
|
|
2785
|
+
* - Exports AppRouter type for client-side type inference
|
|
2786
|
+
*/
|
|
2787
|
+
function generateTrpcRouter(functions) {
|
|
2788
|
+
if (functions.length === 0) {
|
|
2789
|
+
return "// No API routes found. Instrument your app to generate a tRPC router.\n";
|
|
2790
|
+
}
|
|
2791
|
+
const sections = [];
|
|
2792
|
+
sections.push("// Auto-generated tRPC router by trickle");
|
|
2793
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
2794
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --trpc` to update");
|
|
2795
|
+
sections.push("");
|
|
2796
|
+
sections.push('import { initTRPC } from "@trpc/server";');
|
|
2797
|
+
sections.push('import { z } from "zod";');
|
|
2798
|
+
sections.push("");
|
|
2799
|
+
sections.push("const t = initTRPC.create();");
|
|
2800
|
+
sections.push("");
|
|
2801
|
+
// Collect route procedures
|
|
2802
|
+
const inputSchemas = [];
|
|
2803
|
+
const procedures = [];
|
|
2804
|
+
const returnTypes = [];
|
|
2805
|
+
for (const fn of functions) {
|
|
2806
|
+
const route = parseRouteName(fn.name);
|
|
2807
|
+
if (!route)
|
|
2808
|
+
continue;
|
|
2809
|
+
const procedureName = toCamelCase(route.typeName);
|
|
2810
|
+
const typeName = route.typeName;
|
|
2811
|
+
// Generate return type interface
|
|
2812
|
+
const extracted = [];
|
|
2813
|
+
const returnTypeStr = typeNodeToTS(fn.returnType, extracted, typeName + "Response", undefined, 0);
|
|
2814
|
+
returnTypes.push(`export interface ${typeName}Response ${returnTypeStr.startsWith("{") ? returnTypeStr : `{ data: ${returnTypeStr} }`}`);
|
|
2815
|
+
// Also include any extracted nested interfaces
|
|
2816
|
+
for (const ext of extracted) {
|
|
2817
|
+
returnTypes.push(renderInterface(ext.name, ext.node, []));
|
|
2818
|
+
}
|
|
2819
|
+
// Check for body input (POST/PUT/PATCH/DELETE)
|
|
2820
|
+
let hasInput = false;
|
|
2821
|
+
let bodyNode;
|
|
2822
|
+
if (["POST", "PUT", "PATCH", "DELETE"].includes(route.method)) {
|
|
2823
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
|
|
2824
|
+
bodyNode = fn.argsType.properties["body"];
|
|
2825
|
+
}
|
|
2826
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
2827
|
+
hasInput = true;
|
|
2828
|
+
const schemaName = `${procedureName}Input`;
|
|
2829
|
+
inputSchemas.push(`const ${schemaName} = ${typeNodeToZod(bodyNode, 0)};`);
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
// Check for query params input (GET)
|
|
2833
|
+
let hasQueryInput = false;
|
|
2834
|
+
let queryNode;
|
|
2835
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
|
|
2836
|
+
queryNode = fn.argsType.properties["query"];
|
|
2837
|
+
if (queryNode.kind === "object" && Object.keys(queryNode.properties).length > 0) {
|
|
2838
|
+
hasQueryInput = true;
|
|
2839
|
+
const schemaName = `${procedureName}Input`;
|
|
2840
|
+
if (!hasInput) {
|
|
2841
|
+
inputSchemas.push(`const ${schemaName} = ${typeNodeToZod(queryNode, 0)};`);
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
// Check for path params
|
|
2846
|
+
const hasPathParams = route.pathParams.length > 0;
|
|
2847
|
+
let pathParamSchema = "";
|
|
2848
|
+
if (hasPathParams && !hasInput && !hasQueryInput) {
|
|
2849
|
+
const pathProps = {};
|
|
2850
|
+
for (const param of route.pathParams) {
|
|
2851
|
+
pathProps[param] = { kind: "primitive", name: "string" };
|
|
2852
|
+
}
|
|
2853
|
+
const pathNode = { kind: "object", properties: pathProps };
|
|
2854
|
+
const schemaName = `${procedureName}Input`;
|
|
2855
|
+
inputSchemas.push(`const ${schemaName} = ${typeNodeToZod(pathNode, 0)};`);
|
|
2856
|
+
pathParamSchema = schemaName;
|
|
2857
|
+
}
|
|
2858
|
+
const inputName = `${procedureName}Input`;
|
|
2859
|
+
const hasAnyInput = hasInput || hasQueryInput || (hasPathParams && pathParamSchema);
|
|
2860
|
+
// Build procedure
|
|
2861
|
+
const isQuery = route.method === "GET";
|
|
2862
|
+
let proc = ` ${procedureName}: t.procedure`;
|
|
2863
|
+
if (hasAnyInput) {
|
|
2864
|
+
proc += `\n .input(${inputName})`;
|
|
2865
|
+
}
|
|
2866
|
+
if (isQuery) {
|
|
2867
|
+
proc += `\n .query(async (${hasAnyInput ? "{ input }" : ""}) => {`;
|
|
2868
|
+
proc += `\n // ${route.method} ${route.path}`;
|
|
2869
|
+
proc += `\n // Return type: ${typeName}Response`;
|
|
2870
|
+
proc += `\n throw new Error("Not implemented — replace with your logic");`;
|
|
2871
|
+
proc += `\n })`;
|
|
2872
|
+
}
|
|
2873
|
+
else {
|
|
2874
|
+
proc += `\n .mutation(async (${hasAnyInput ? "{ input }" : ""}) => {`;
|
|
2875
|
+
proc += `\n // ${route.method} ${route.path}`;
|
|
2876
|
+
proc += `\n // Return type: ${typeName}Response`;
|
|
2877
|
+
proc += `\n throw new Error("Not implemented — replace with your logic");`;
|
|
2878
|
+
proc += `\n })`;
|
|
2879
|
+
}
|
|
2880
|
+
procedures.push(proc);
|
|
2881
|
+
}
|
|
2882
|
+
if (procedures.length === 0) {
|
|
2883
|
+
return "// No API routes found. Instrument your app to generate a tRPC router.\n";
|
|
2884
|
+
}
|
|
2885
|
+
// Emit return type interfaces
|
|
2886
|
+
sections.push("// ── Response types ──");
|
|
2887
|
+
sections.push("");
|
|
2888
|
+
for (const rt of returnTypes) {
|
|
2889
|
+
sections.push(rt);
|
|
2890
|
+
sections.push("");
|
|
2891
|
+
}
|
|
2892
|
+
// Emit input schemas
|
|
2893
|
+
if (inputSchemas.length > 0) {
|
|
2894
|
+
sections.push("// ── Input validation schemas ──");
|
|
2895
|
+
sections.push("");
|
|
2896
|
+
for (const schema of inputSchemas) {
|
|
2897
|
+
sections.push(schema);
|
|
2898
|
+
sections.push("");
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
// Emit router
|
|
2902
|
+
sections.push("// ── Router ──");
|
|
2903
|
+
sections.push("");
|
|
2904
|
+
sections.push("export const appRouter = t.router({");
|
|
2905
|
+
sections.push(procedures.join(",\n\n"));
|
|
2906
|
+
sections.push("});");
|
|
2907
|
+
sections.push("");
|
|
2908
|
+
sections.push("export type AppRouter = typeof appRouter;");
|
|
2909
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
2910
|
+
}
|
|
2911
|
+
// ── Axios Client Generation ──
|
|
2912
|
+
/**
|
|
2913
|
+
* Generate a typed Axios client from runtime-observed API routes.
|
|
2914
|
+
*
|
|
2915
|
+
* For each route:
|
|
2916
|
+
* - Request/response interfaces
|
|
2917
|
+
* - Typed function using axios.get/post/put/patch/delete
|
|
2918
|
+
* - Path parameter interpolation
|
|
2919
|
+
* - Query parameter support
|
|
2920
|
+
* - Configurable base URL and axios instance
|
|
2921
|
+
*/
|
|
2922
|
+
function generateAxiosClient(functions) {
|
|
2923
|
+
const routes = [];
|
|
2924
|
+
for (const fn of functions) {
|
|
2925
|
+
const parsed = parseRouteName(fn.name);
|
|
2926
|
+
if (parsed) {
|
|
2927
|
+
routes.push({ parsed, fn });
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
if (routes.length === 0) {
|
|
2931
|
+
return "// No API routes found. Instrument your app to generate a typed Axios client.\n";
|
|
2932
|
+
}
|
|
2933
|
+
const sections = [];
|
|
2934
|
+
sections.push("// Auto-generated typed Axios client by trickle");
|
|
2935
|
+
sections.push(`// Generated at ${new Date().toISOString()}`);
|
|
2936
|
+
sections.push("// Do not edit manually — re-run `trickle codegen --axios` to update");
|
|
2937
|
+
sections.push("");
|
|
2938
|
+
sections.push('import axios, { AxiosInstance, AxiosRequestConfig } from "axios";');
|
|
2939
|
+
sections.push("");
|
|
2940
|
+
// Generate interfaces
|
|
2941
|
+
const extracted = [];
|
|
2942
|
+
const interfaceSections = [];
|
|
2943
|
+
for (const { parsed, fn } of routes) {
|
|
2944
|
+
const baseName = parsed.typeName;
|
|
2945
|
+
// Body input type (POST/PUT/PATCH)
|
|
2946
|
+
if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
|
|
2947
|
+
if (fn.argsType.kind === "object") {
|
|
2948
|
+
const bodyNode = fn.argsType.properties["body"];
|
|
2949
|
+
if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
|
|
2950
|
+
interfaceSections.push(renderInterface(`${baseName}Body`, bodyNode, extracted));
|
|
2951
|
+
interfaceSections.push("");
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
// Query params type
|
|
2956
|
+
if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
|
|
2957
|
+
const queryNode = fn.argsType.properties["query"];
|
|
2958
|
+
if (queryNode.kind === "object" && Object.keys(queryNode.properties).length > 0) {
|
|
2959
|
+
interfaceSections.push(renderInterface(`${baseName}Query`, queryNode, extracted));
|
|
2960
|
+
interfaceSections.push("");
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
// Response type
|
|
2964
|
+
if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
|
|
2965
|
+
interfaceSections.push(renderInterface(`${baseName}Response`, fn.returnType, extracted));
|
|
2966
|
+
interfaceSections.push("");
|
|
2967
|
+
}
|
|
2968
|
+
else {
|
|
2969
|
+
const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
|
|
2970
|
+
interfaceSections.push(`export type ${baseName}Response = ${retStr};`);
|
|
2971
|
+
interfaceSections.push("");
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
// Emit extracted sub-interfaces first
|
|
2975
|
+
const emitted = new Set();
|
|
2976
|
+
let cursor = 0;
|
|
2977
|
+
while (cursor < extracted.length) {
|
|
2978
|
+
const iface = extracted[cursor];
|
|
2979
|
+
cursor++;
|
|
2980
|
+
if (emitted.has(iface.name))
|
|
2981
|
+
continue;
|
|
2982
|
+
emitted.add(iface.name);
|
|
2983
|
+
sections.push(renderInterface(iface.name, iface.node, extracted));
|
|
2984
|
+
sections.push("");
|
|
2985
|
+
}
|
|
2986
|
+
// Emit main interfaces
|
|
2987
|
+
sections.push(...interfaceSections);
|
|
2988
|
+
// Client setup
|
|
2989
|
+
sections.push("// ── Axios Client ──");
|
|
2990
|
+
sections.push("");
|
|
2991
|
+
sections.push("let _instance: AxiosInstance = axios.create();");
|
|
2992
|
+
sections.push("");
|
|
2993
|
+
sections.push("/**");
|
|
2994
|
+
sections.push(" * Configure the Axios client instance.");
|
|
2995
|
+
sections.push(" * Call this once at app startup with your base URL.");
|
|
2996
|
+
sections.push(" */");
|
|
2997
|
+
sections.push('export function configureAxiosClient(baseURL: string, instance?: AxiosInstance): void {');
|
|
2998
|
+
sections.push(" if (instance) {");
|
|
2999
|
+
sections.push(" _instance = instance;");
|
|
3000
|
+
sections.push(" } else {");
|
|
3001
|
+
sections.push(" _instance = axios.create({ baseURL });");
|
|
3002
|
+
sections.push(" }");
|
|
3003
|
+
sections.push("}");
|
|
3004
|
+
sections.push("");
|
|
3005
|
+
// Generate typed functions
|
|
3006
|
+
sections.push("// ── Typed API Functions ──");
|
|
3007
|
+
sections.push("");
|
|
3008
|
+
for (const { parsed, fn } of routes) {
|
|
3009
|
+
const baseName = parsed.typeName;
|
|
3010
|
+
const funcName = toCamelCase(baseName);
|
|
3011
|
+
const responseName = `${baseName}Response`;
|
|
3012
|
+
// Determine if we have body, query, path params
|
|
3013
|
+
const hasBody = ["POST", "PUT", "PATCH"].includes(parsed.method)
|
|
3014
|
+
&& fn.argsType.kind === "object"
|
|
3015
|
+
&& fn.argsType.properties["body"]
|
|
3016
|
+
&& fn.argsType.properties["body"].kind === "object"
|
|
3017
|
+
&& Object.keys(fn.argsType.properties["body"].properties).length > 0;
|
|
3018
|
+
const hasQuery = fn.argsType.kind === "object"
|
|
3019
|
+
&& fn.argsType.properties["query"]
|
|
3020
|
+
&& fn.argsType.properties["query"].kind === "object"
|
|
3021
|
+
&& Object.keys(fn.argsType.properties["query"].properties).length > 0;
|
|
3022
|
+
const hasPathParams = parsed.pathParams.length > 0;
|
|
3023
|
+
// Build function parameters
|
|
3024
|
+
const params = [];
|
|
3025
|
+
if (hasPathParams) {
|
|
3026
|
+
for (const p of parsed.pathParams) {
|
|
3027
|
+
params.push(`${p}: string`);
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
if (hasBody) {
|
|
3031
|
+
params.push(`body: ${baseName}Body`);
|
|
3032
|
+
}
|
|
3033
|
+
if (hasQuery) {
|
|
3034
|
+
params.push(`query?: ${baseName}Query`);
|
|
3035
|
+
}
|
|
3036
|
+
params.push("config?: AxiosRequestConfig");
|
|
3037
|
+
// Build URL path with interpolation
|
|
3038
|
+
let urlPath = parsed.path;
|
|
3039
|
+
for (const p of parsed.pathParams) {
|
|
3040
|
+
urlPath = urlPath.replace(`:${p}`, `\${${p}}`);
|
|
3041
|
+
}
|
|
3042
|
+
const urlExpr = hasPathParams ? `\`${urlPath}\`` : `"${parsed.path}"`;
|
|
3043
|
+
// Build function body
|
|
3044
|
+
const method = parsed.method.toLowerCase();
|
|
3045
|
+
sections.push(`/** ${parsed.method} ${parsed.path} */`);
|
|
3046
|
+
sections.push(`export async function ${funcName}(${params.join(", ")}): Promise<${responseName}> {`);
|
|
3047
|
+
if (hasQuery) {
|
|
3048
|
+
sections.push(" const requestConfig: AxiosRequestConfig = { ...config, params: query };");
|
|
3049
|
+
}
|
|
3050
|
+
const configArg = hasQuery ? "requestConfig" : "config";
|
|
3051
|
+
if (hasBody) {
|
|
3052
|
+
sections.push(` const { data } = await _instance.${method}<${responseName}>(${urlExpr}, body, ${configArg});`);
|
|
3053
|
+
}
|
|
3054
|
+
else if (method === "delete") {
|
|
3055
|
+
sections.push(` const { data } = await _instance.${method}<${responseName}>(${urlExpr}, ${configArg});`);
|
|
3056
|
+
}
|
|
3057
|
+
else {
|
|
3058
|
+
sections.push(` const { data } = await _instance.${method}<${responseName}>(${urlExpr}, ${configArg});`);
|
|
3059
|
+
}
|
|
3060
|
+
sections.push(" return data;");
|
|
3061
|
+
sections.push("}");
|
|
3062
|
+
sections.push("");
|
|
3063
|
+
}
|
|
3064
|
+
return sections.join("\n").trimEnd() + "\n";
|
|
3065
|
+
}
|
|
3066
|
+
// ── Inline annotation generation ──
|
|
3067
|
+
/**
|
|
3068
|
+
* Generate compact inline type annotations for annotating source code.
|
|
3069
|
+
* Unlike generateFunctionTypes which emits full interfaces, this produces
|
|
3070
|
+
* minimal inline annotations suitable for inserting into source files.
|
|
3071
|
+
*/
|
|
3072
|
+
function generateInlineAnnotations(functions, language = "typescript") {
|
|
3073
|
+
const result = {};
|
|
3074
|
+
for (const fn of functions) {
|
|
3075
|
+
const extracted = [];
|
|
3076
|
+
const pyExtracted = [];
|
|
3077
|
+
// Build param types
|
|
3078
|
+
const params = [];
|
|
3079
|
+
if (fn.argsType.kind === "tuple") {
|
|
3080
|
+
for (let i = 0; i < fn.argsType.elements.length; i++) {
|
|
3081
|
+
const el = fn.argsType.elements[i];
|
|
3082
|
+
const typeStr = language === "python"
|
|
3083
|
+
? typeNodeToPythonInline(el)
|
|
3084
|
+
: typeNodeToTSInline(el);
|
|
3085
|
+
params.push({ name: `arg${i}`, type: typeStr });
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
else if (fn.argsType.kind === "object") {
|
|
3089
|
+
for (const key of Object.keys(fn.argsType.properties)) {
|
|
3090
|
+
const typeStr = language === "python"
|
|
3091
|
+
? typeNodeToPythonInline(fn.argsType.properties[key])
|
|
3092
|
+
: typeNodeToTSInline(fn.argsType.properties[key]);
|
|
3093
|
+
params.push({ name: key, type: typeStr });
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
// Build return type
|
|
3097
|
+
const returnTypeStr = language === "python"
|
|
3098
|
+
? typeNodeToPythonInline(fn.returnType)
|
|
3099
|
+
: typeNodeToTSInline(fn.returnType);
|
|
3100
|
+
result[fn.name] = { params, returnType: returnTypeStr };
|
|
3101
|
+
}
|
|
3102
|
+
return result;
|
|
3103
|
+
}
|
|
3104
|
+
/**
|
|
3105
|
+
* Convert a TypeNode to an inline TypeScript type string (no extraction, no interfaces).
|
|
3106
|
+
*/
|
|
3107
|
+
function typeNodeToTSInline(node, depth = 0) {
|
|
3108
|
+
if (depth > 5)
|
|
3109
|
+
return "any";
|
|
3110
|
+
switch (node.kind) {
|
|
3111
|
+
case "primitive":
|
|
3112
|
+
return node.name;
|
|
3113
|
+
case "unknown":
|
|
3114
|
+
return "unknown";
|
|
3115
|
+
case "array": {
|
|
3116
|
+
const inner = typeNodeToTSInline(node.element, depth + 1);
|
|
3117
|
+
return node.element.kind === "union" || node.element.kind === "function"
|
|
3118
|
+
? `Array<${inner}>`
|
|
3119
|
+
: `${inner}[]`;
|
|
3120
|
+
}
|
|
3121
|
+
case "tuple": {
|
|
3122
|
+
const elements = node.elements.map(el => typeNodeToTSInline(el, depth + 1));
|
|
3123
|
+
return `[${elements.join(", ")}]`;
|
|
3124
|
+
}
|
|
3125
|
+
case "union": {
|
|
3126
|
+
const members = node.members.map(m => typeNodeToTSInline(m, depth + 1));
|
|
3127
|
+
return members.join(" | ");
|
|
3128
|
+
}
|
|
3129
|
+
case "map": {
|
|
3130
|
+
const k = typeNodeToTSInline(node.key, depth + 1);
|
|
3131
|
+
const v = typeNodeToTSInline(node.value, depth + 1);
|
|
3132
|
+
return `Map<${k}, ${v}>`;
|
|
3133
|
+
}
|
|
3134
|
+
case "set":
|
|
3135
|
+
return `Set<${typeNodeToTSInline(node.element, depth + 1)}>`;
|
|
3136
|
+
case "promise":
|
|
3137
|
+
return `Promise<${typeNodeToTSInline(node.resolved, depth + 1)}>`;
|
|
3138
|
+
case "function": {
|
|
3139
|
+
const params = node.params.map((p, i) => `arg${i}: ${typeNodeToTSInline(p, depth + 1)}`);
|
|
3140
|
+
return `(${params.join(", ")}) => ${typeNodeToTSInline(node.returnType, depth + 1)}`;
|
|
3141
|
+
}
|
|
3142
|
+
case "object": {
|
|
3143
|
+
const keys = Object.keys(node.properties);
|
|
3144
|
+
if (keys.length === 0)
|
|
3145
|
+
return "Record<string, never>";
|
|
3146
|
+
const entries = keys.map(key => `${key}: ${typeNodeToTSInline(node.properties[key], depth + 1)}`);
|
|
3147
|
+
return `{ ${entries.join("; ")} }`;
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* Convert a TypeNode to an inline Python type string.
|
|
3153
|
+
*/
|
|
3154
|
+
function typeNodeToPythonInline(node, depth = 0) {
|
|
3155
|
+
if (depth > 5)
|
|
3156
|
+
return "Any";
|
|
3157
|
+
switch (node.kind) {
|
|
3158
|
+
case "primitive":
|
|
3159
|
+
switch (node.name) {
|
|
3160
|
+
case "string": return "str";
|
|
3161
|
+
case "number": return "float";
|
|
3162
|
+
case "boolean": return "bool";
|
|
3163
|
+
case "null": return "None";
|
|
3164
|
+
case "undefined": return "None";
|
|
3165
|
+
case "bigint": return "int";
|
|
3166
|
+
case "symbol": return "str";
|
|
3167
|
+
default: return "Any";
|
|
3168
|
+
}
|
|
3169
|
+
case "unknown":
|
|
3170
|
+
return "Any";
|
|
3171
|
+
case "array":
|
|
3172
|
+
return `list[${typeNodeToPythonInline(node.element, depth + 1)}]`;
|
|
3173
|
+
case "tuple": {
|
|
3174
|
+
const elements = node.elements.map(el => typeNodeToPythonInline(el, depth + 1));
|
|
3175
|
+
return `tuple[${elements.join(", ")}]`;
|
|
3176
|
+
}
|
|
3177
|
+
case "union": {
|
|
3178
|
+
const members = node.members.map(m => typeNodeToPythonInline(m, depth + 1));
|
|
3179
|
+
if (members.length === 2 && members.includes("None")) {
|
|
3180
|
+
const nonNone = members.find(m => m !== "None");
|
|
3181
|
+
return `${nonNone} | None`;
|
|
3182
|
+
}
|
|
3183
|
+
return members.join(" | ");
|
|
3184
|
+
}
|
|
3185
|
+
case "map": {
|
|
3186
|
+
const k = typeNodeToPythonInline(node.key, depth + 1);
|
|
3187
|
+
const v = typeNodeToPythonInline(node.value, depth + 1);
|
|
3188
|
+
return `dict[${k}, ${v}]`;
|
|
3189
|
+
}
|
|
3190
|
+
case "set":
|
|
3191
|
+
return `set[${typeNodeToPythonInline(node.element, depth + 1)}]`;
|
|
3192
|
+
case "promise":
|
|
3193
|
+
return typeNodeToPythonInline(node.resolved, depth + 1);
|
|
3194
|
+
case "function": {
|
|
3195
|
+
const params = node.params.map(p => typeNodeToPythonInline(p, depth + 1));
|
|
3196
|
+
const ret = typeNodeToPythonInline(node.returnType, depth + 1);
|
|
3197
|
+
return `Callable[[${params.join(", ")}], ${ret}]`;
|
|
3198
|
+
}
|
|
3199
|
+
case "object": {
|
|
3200
|
+
const keys = Object.keys(node.properties);
|
|
3201
|
+
if (keys.length === 0)
|
|
3202
|
+
return "dict[str, Any]";
|
|
3203
|
+
const entries = keys.map(key => `"${key}": ${typeNodeToPythonInline(node.properties[key], depth + 1)}`);
|
|
3204
|
+
return `TypedDict("_", {${entries.join(", ")}})`;
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
}
|