shokupan 0.0.1 → 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/README.md +12 -12
- package/dist/analysis/openapi-analyzer.d.ts +142 -0
- package/dist/cli.cjs +62 -2
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +62 -2
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts +48 -3
- package/dist/decorators.d.ts +5 -1
- package/dist/index.cjs +1032 -337
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1032 -337
- package/dist/index.js.map +1 -1
- package/dist/openapi-analyzer-CFqgSLNK.cjs +769 -0
- package/dist/openapi-analyzer-CFqgSLNK.cjs.map +1 -0
- package/dist/openapi-analyzer-cjdGeQ5a.js +769 -0
- package/dist/openapi-analyzer-cjdGeQ5a.js.map +1 -0
- package/dist/plugins/openapi.d.ts +10 -0
- package/dist/plugins/scalar.d.ts +3 -1
- package/dist/plugins/serve-static.d.ts +3 -0
- package/dist/plugins/server-adapter.d.ts +13 -0
- package/dist/router.d.ts +41 -15
- package/dist/shokupan.d.ts +14 -7
- package/dist/symbol.d.ts +2 -0
- package/dist/types.d.ts +108 -1
- package/package.json +5 -2
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const ts = require("typescript");
|
|
6
|
+
class OpenAPIAnalyzer {
|
|
7
|
+
constructor(rootDir, entrypoint) {
|
|
8
|
+
this.rootDir = rootDir;
|
|
9
|
+
if (entrypoint) {
|
|
10
|
+
this.entrypoint = path.resolve(entrypoint);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
files = [];
|
|
14
|
+
applications = [];
|
|
15
|
+
program;
|
|
16
|
+
entrypoint;
|
|
17
|
+
/**
|
|
18
|
+
* Main analysis entry point
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Main analysis entry point
|
|
22
|
+
*/
|
|
23
|
+
async analyze() {
|
|
24
|
+
await this.parseTypeScriptFiles();
|
|
25
|
+
await this.processSourceMaps();
|
|
26
|
+
await this.findApplications();
|
|
27
|
+
await this.extractRoutes();
|
|
28
|
+
return { applications: this.applications };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Recursively scan directory for TypeScript/JavaScript files
|
|
32
|
+
*/
|
|
33
|
+
async scanDirectory(dir) {
|
|
34
|
+
try {
|
|
35
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const fullPath = path.join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
await this.scanDirectory(fullPath);
|
|
43
|
+
} else {
|
|
44
|
+
const ext = path.extname(entry.name);
|
|
45
|
+
if (ext === ".ts") {
|
|
46
|
+
this.files.push({ path: fullPath, type: "ts" });
|
|
47
|
+
} else if (ext === ".js") {
|
|
48
|
+
this.files.push({ path: fullPath, type: "js" });
|
|
49
|
+
} else if (ext === ".map") {
|
|
50
|
+
this.files.push({ path: fullPath, type: "map" });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code !== "ENOENT" && error.code !== "EACCES") {
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Process source maps to reconstruct TypeScript
|
|
62
|
+
*/
|
|
63
|
+
async processSourceMaps() {
|
|
64
|
+
const jsFiles = this.files.filter((f) => f.type === "js");
|
|
65
|
+
const mapFiles = this.files.filter((f) => f.type === "map");
|
|
66
|
+
for (const jsFile of jsFiles) {
|
|
67
|
+
const mapFile = mapFiles.find((m) => m.path === jsFile.path + ".map");
|
|
68
|
+
if (mapFile && !this.files.some((f) => f.path === jsFile.path.replace(/\.js$/, ".ts"))) {
|
|
69
|
+
console.log(`Note: Found ${jsFile.path} with source map but no .ts file. Will parse JS directly.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Parse TypeScript files and create AST
|
|
75
|
+
*/
|
|
76
|
+
async parseTypeScriptFiles() {
|
|
77
|
+
let fileNames = [];
|
|
78
|
+
if (this.entrypoint) {
|
|
79
|
+
fileNames = [this.entrypoint];
|
|
80
|
+
} else {
|
|
81
|
+
await this.scanDirectory(this.rootDir);
|
|
82
|
+
const tsFiles = this.files.filter((f) => f.type === "ts" || f.type === "js");
|
|
83
|
+
fileNames = tsFiles.map((f) => f.path);
|
|
84
|
+
}
|
|
85
|
+
this.program = ts.createProgram(fileNames, {
|
|
86
|
+
target: ts.ScriptTarget.ESNext,
|
|
87
|
+
module: ts.ModuleKind.ESNext,
|
|
88
|
+
allowJs: true,
|
|
89
|
+
moduleResolution: ts.ModuleResolutionKind.Node10,
|
|
90
|
+
rootDir: this.rootDir,
|
|
91
|
+
skipLibCheck: true,
|
|
92
|
+
skipDefaultLibCheck: true
|
|
93
|
+
});
|
|
94
|
+
if (this.entrypoint) {
|
|
95
|
+
this.files = this.program.getSourceFiles().filter((sf) => !sf.fileName.includes("node_modules")).map((sf) => ({ path: sf.fileName, type: sf.fileName.endsWith(".js") ? "js" : "ts" }));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Find all Shokupan/ShokupanRouter instances
|
|
100
|
+
*/
|
|
101
|
+
async findApplications() {
|
|
102
|
+
if (!this.program) return;
|
|
103
|
+
const typeChecker = this.program.getTypeChecker();
|
|
104
|
+
for (const sourceFile of this.program.getSourceFiles()) {
|
|
105
|
+
if (sourceFile.fileName.includes("node_modules")) continue;
|
|
106
|
+
if (sourceFile.isDeclarationFile) continue;
|
|
107
|
+
if (sourceFile.fileName.includes(".test.ts") || sourceFile.fileName.includes(".spec.ts")) continue;
|
|
108
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
109
|
+
this.visitNode(node, sourceFile, typeChecker);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Visit AST node to find application instances
|
|
115
|
+
*/
|
|
116
|
+
visitNode(node, sourceFile, typeChecker) {
|
|
117
|
+
if (ts.isClassDeclaration(node)) {
|
|
118
|
+
let isController = false;
|
|
119
|
+
let className = node.name?.getText(sourceFile);
|
|
120
|
+
if (node.decorators) {
|
|
121
|
+
const controllerDecorator = node.decorators.find((d) => {
|
|
122
|
+
const expr = d.expression;
|
|
123
|
+
if (ts.isCallExpression(expr)) {
|
|
124
|
+
const identifier = expr.expression.getText(sourceFile);
|
|
125
|
+
return identifier === "Controller";
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
});
|
|
129
|
+
if (controllerDecorator) isController = true;
|
|
130
|
+
}
|
|
131
|
+
if (!isController) {
|
|
132
|
+
const hasRouteDecorators = node.members.some((m) => {
|
|
133
|
+
if (ts.isMethodDeclaration(m) || m.kind === 175 || m.kind === 170 || m.kind === 171) {
|
|
134
|
+
const decs = m.decorators || m.modifiers?.filter((mod) => ts.isDecorator(mod));
|
|
135
|
+
if (decs) {
|
|
136
|
+
return decs.some((d) => {
|
|
137
|
+
const expr = d.expression;
|
|
138
|
+
if (ts.isCallExpression(expr)) {
|
|
139
|
+
const identifier = expr.expression.getText(sourceFile);
|
|
140
|
+
return ["Get", "Post", "Put", "Delete", "Patch", "Options", "Head"].includes(identifier);
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
});
|
|
148
|
+
if (hasRouteDecorators) {
|
|
149
|
+
isController = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (isController && className) {
|
|
153
|
+
this.applications.push({
|
|
154
|
+
name: className,
|
|
155
|
+
filePath: sourceFile.fileName,
|
|
156
|
+
className: "Controller",
|
|
157
|
+
routes: [],
|
|
158
|
+
mounted: []
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
163
|
+
if (ts.isNewExpression(node.initializer)) {
|
|
164
|
+
const expr = node.initializer;
|
|
165
|
+
const className = expr.expression.getText(sourceFile);
|
|
166
|
+
if (className === "Shokupan" || className === "ShokupanRouter") {
|
|
167
|
+
const varName = node.name.getText(sourceFile);
|
|
168
|
+
this.applications.push({
|
|
169
|
+
name: varName,
|
|
170
|
+
filePath: sourceFile.fileName,
|
|
171
|
+
className,
|
|
172
|
+
routes: [],
|
|
173
|
+
mounted: []
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
ts.forEachChild(node, (child) => this.visitNode(child, sourceFile, typeChecker));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Extract route information from applications
|
|
182
|
+
*/
|
|
183
|
+
async extractRoutes() {
|
|
184
|
+
if (!this.program) return;
|
|
185
|
+
for (const app of this.applications) {
|
|
186
|
+
const sourceFile = this.program.getSourceFile(app.filePath);
|
|
187
|
+
if (!sourceFile) continue;
|
|
188
|
+
this.extractRoutesFromFile(app, sourceFile);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Extract routes from a Controller class
|
|
193
|
+
*/
|
|
194
|
+
extractRoutesFromController(app, classNode, sourceFile) {
|
|
195
|
+
const methods = classNode.members.filter((m) => ts.isMethodDeclaration(m) || m.kind === 175);
|
|
196
|
+
for (const method of methods) {
|
|
197
|
+
const methodNode = method;
|
|
198
|
+
if (!methodNode.decorators && !methodNode.modifiers) continue;
|
|
199
|
+
const decorators = methodNode.decorators || methodNode.modifiers?.filter((m) => ts.isDecorator(m));
|
|
200
|
+
if (!decorators) continue;
|
|
201
|
+
const routeDecorator = decorators.find((d) => {
|
|
202
|
+
const expr = d.expression;
|
|
203
|
+
if (ts.isCallExpression(expr)) {
|
|
204
|
+
const identifier = expr.expression.getText(sourceFile);
|
|
205
|
+
return ["Get", "Post", "Put", "Delete", "Patch", "Options", "Head"].includes(identifier);
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
});
|
|
209
|
+
if (routeDecorator && ts.isCallExpression(routeDecorator.expression)) {
|
|
210
|
+
const decoratorName = routeDecorator.expression.expression.getText(sourceFile);
|
|
211
|
+
const httpMethod = decoratorName.toUpperCase();
|
|
212
|
+
let routePath = "/";
|
|
213
|
+
const pathArg = routeDecorator.expression.arguments[0];
|
|
214
|
+
if (pathArg && ts.isStringLiteral(pathArg)) {
|
|
215
|
+
routePath = pathArg.text;
|
|
216
|
+
}
|
|
217
|
+
routePath = routePath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
218
|
+
const handlerName = `${app.name}.${methodNode.name.getText(sourceFile)}`;
|
|
219
|
+
const analysis = this.analyzeHandler(methodNode, sourceFile);
|
|
220
|
+
app.routes.push({
|
|
221
|
+
method: httpMethod,
|
|
222
|
+
path: routePath,
|
|
223
|
+
handlerName,
|
|
224
|
+
handlerSource: methodNode.getText(sourceFile),
|
|
225
|
+
requestTypes: analysis.requestTypes,
|
|
226
|
+
responseType: analysis.responseType,
|
|
227
|
+
responseSchema: analysis.responseSchema
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Extract routes from a specific file
|
|
234
|
+
*/
|
|
235
|
+
extractRoutesFromFile(app, sourceFile) {
|
|
236
|
+
if (app.className === "Controller") {
|
|
237
|
+
const classNode = sourceFile.statements.find((s) => ts.isClassDeclaration(s) && s.name?.getText(sourceFile) === app.name);
|
|
238
|
+
if (classNode) {
|
|
239
|
+
this.extractRoutesFromController(app, classNode, sourceFile);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
const visit = (node) => {
|
|
243
|
+
if (ts.isCallExpression(node)) {
|
|
244
|
+
const expr = node.expression;
|
|
245
|
+
if (ts.isPropertyAccessExpression(expr)) {
|
|
246
|
+
const objName = expr.expression.getText(sourceFile);
|
|
247
|
+
const methodName = expr.name.getText(sourceFile);
|
|
248
|
+
if (objName === app.name) {
|
|
249
|
+
if (["get", "post", "put", "delete", "patch", "options", "head"].includes(methodName)) {
|
|
250
|
+
const route = this.extractRouteFromCall(node, sourceFile, methodName.toUpperCase());
|
|
251
|
+
if (route) {
|
|
252
|
+
app.routes.push(route);
|
|
253
|
+
}
|
|
254
|
+
} else if (methodName === "mount") {
|
|
255
|
+
const mount = this.extractMountFromCall(node, sourceFile);
|
|
256
|
+
if (mount) {
|
|
257
|
+
app.mounted.push(mount);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
ts.forEachChild(node, visit);
|
|
264
|
+
};
|
|
265
|
+
ts.forEachChild(sourceFile, visit);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Extract route information from a route call (e.g., app.get('/path', handler))
|
|
270
|
+
*/
|
|
271
|
+
extractRouteFromCall(node, sourceFile, method) {
|
|
272
|
+
const args = node.arguments;
|
|
273
|
+
if (args.length < 2) return null;
|
|
274
|
+
const pathArg = args[0];
|
|
275
|
+
let routePath = "/";
|
|
276
|
+
if (ts.isStringLiteral(pathArg)) {
|
|
277
|
+
routePath = pathArg.text;
|
|
278
|
+
}
|
|
279
|
+
const normalizedPath = routePath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
280
|
+
let metadata = {};
|
|
281
|
+
if (args.length >= 3 && ts.isObjectLiteralExpression(args[1])) {
|
|
282
|
+
const metaObj = args[1];
|
|
283
|
+
this.convertExpressionToSchema(metaObj, sourceFile, /* @__PURE__ */ new Map());
|
|
284
|
+
for (const prop of metaObj.properties) {
|
|
285
|
+
if (ts.isPropertyAssignment(prop) && prop.name) {
|
|
286
|
+
const name = prop.name.getText(sourceFile);
|
|
287
|
+
const val = prop.initializer;
|
|
288
|
+
if (ts.isStringLiteral(val)) {
|
|
289
|
+
metadata[name] = val.text;
|
|
290
|
+
} else if (ts.isArrayLiteralExpression(val) && name === "tags") {
|
|
291
|
+
metadata.tags = val.elements.filter((e) => ts.isStringLiteral(e)).map((e) => e.text);
|
|
292
|
+
} else if (name === "operationId" && ts.isStringLiteral(val)) {
|
|
293
|
+
metadata.operationId = val.text;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const handlerArg = args[args.length - 1];
|
|
299
|
+
const handlerInfo = this.analyzeHandler(handlerArg, sourceFile);
|
|
300
|
+
return {
|
|
301
|
+
method,
|
|
302
|
+
path: normalizedPath,
|
|
303
|
+
handlerName: handlerArg.getText(sourceFile).substring(0, 50),
|
|
304
|
+
// Truncate for display
|
|
305
|
+
handlerSource: handlerArg.getText(sourceFile),
|
|
306
|
+
requestTypes: handlerInfo.requestTypes,
|
|
307
|
+
responseType: handlerInfo.responseType,
|
|
308
|
+
responseSchema: handlerInfo.responseSchema,
|
|
309
|
+
...metadata
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Analyze a route handler to extract type information
|
|
314
|
+
*/
|
|
315
|
+
analyzeHandler(handler, sourceFile) {
|
|
316
|
+
const requestTypes = {};
|
|
317
|
+
let responseType;
|
|
318
|
+
let responseSchema;
|
|
319
|
+
const scope = /* @__PURE__ */ new Map();
|
|
320
|
+
if (ts.isFunctionLike(handler)) {
|
|
321
|
+
handler.parameters.forEach((param) => {
|
|
322
|
+
if (ts.isIdentifier(param.name) && param.type) {
|
|
323
|
+
const paramName = param.name.getText(sourceFile);
|
|
324
|
+
const paramType = this.convertTypeNodeToSchema(param.type, sourceFile);
|
|
325
|
+
if (paramType) {
|
|
326
|
+
scope.set(paramName, paramType);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
const analyzeReturnExpression = (expr) => {
|
|
332
|
+
let node = expr;
|
|
333
|
+
if (ts.isAwaitExpression(node)) {
|
|
334
|
+
node = node.expression;
|
|
335
|
+
}
|
|
336
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
337
|
+
const callObj = node.expression.expression.getText(sourceFile);
|
|
338
|
+
const callProp = node.expression.name.getText(sourceFile);
|
|
339
|
+
if ((callObj === "ctx" || callObj.endsWith(".ctx")) && callProp === "json") {
|
|
340
|
+
if (node.arguments.length > 0) {
|
|
341
|
+
responseSchema = this.convertExpressionToSchema(node.arguments[0], sourceFile, scope);
|
|
342
|
+
responseType = "object";
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (!responseSchema || responseSchema.type === "object") {
|
|
348
|
+
const schema = this.convertExpressionToSchema(node, sourceFile, scope);
|
|
349
|
+
if (schema && (schema.type !== "object" || Object.keys(schema.properties || {}).length > 0)) {
|
|
350
|
+
responseSchema = schema;
|
|
351
|
+
responseType = schema.type;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (!responseSchema && !responseType) {
|
|
355
|
+
const returnText = node.getText(sourceFile);
|
|
356
|
+
if (returnText.startsWith("{")) {
|
|
357
|
+
responseType = "object";
|
|
358
|
+
} else if (returnText.startsWith("[")) {
|
|
359
|
+
responseType = "array";
|
|
360
|
+
} else if (returnText.startsWith('"') || returnText.startsWith("'")) {
|
|
361
|
+
responseType = "string";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
let body;
|
|
366
|
+
if (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler) || ts.isMethodDeclaration(handler) || handler.kind === 175) {
|
|
367
|
+
body = handler.body;
|
|
368
|
+
const visit = (node) => {
|
|
369
|
+
if (ts.isAsExpression(node)) {
|
|
370
|
+
if (this.isCtxBodyCall(node.expression, sourceFile)) {
|
|
371
|
+
const schema = this.convertTypeNodeToSchema(node.type, sourceFile);
|
|
372
|
+
if (schema) {
|
|
373
|
+
requestTypes.body = schema;
|
|
374
|
+
if (ts.isVariableDeclaration(node.parent)) {
|
|
375
|
+
const varName = node.parent.name.getText(sourceFile);
|
|
376
|
+
scope.set(varName, schema);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
382
|
+
const objText = node.expression.getText(sourceFile);
|
|
383
|
+
const propText = node.name.getText(sourceFile);
|
|
384
|
+
if (objText === "ctx" || objText.endsWith(".ctx")) {
|
|
385
|
+
if (propText === "body") {
|
|
386
|
+
if (!requestTypes.body) {
|
|
387
|
+
requestTypes.body = { type: "object" };
|
|
388
|
+
}
|
|
389
|
+
} else if (propText === "query") {
|
|
390
|
+
if (!requestTypes.query) requestTypes.query = {};
|
|
391
|
+
} else if (propText === "params") {
|
|
392
|
+
if (!requestTypes.params) requestTypes.params = {};
|
|
393
|
+
} else if (propText === "headers") {
|
|
394
|
+
if (!requestTypes.headers) requestTypes.headers = {};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (ts.isReturnStatement(node) && node.expression) {
|
|
399
|
+
analyzeReturnExpression(node.expression);
|
|
400
|
+
}
|
|
401
|
+
if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) {
|
|
402
|
+
analyzeReturnExpression(node.body);
|
|
403
|
+
}
|
|
404
|
+
ts.forEachChild(node, visit);
|
|
405
|
+
};
|
|
406
|
+
if (ts.isBlock(body)) {
|
|
407
|
+
ts.forEachChild(body, visit);
|
|
408
|
+
} else {
|
|
409
|
+
analyzeReturnExpression(body);
|
|
410
|
+
ts.forEachChild(body, visit);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { requestTypes, responseType, responseSchema };
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Convert an Expression node to an OpenAPI schema (best effort)
|
|
417
|
+
*/
|
|
418
|
+
convertExpressionToSchema(node, sourceFile, scope) {
|
|
419
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
420
|
+
const schema = {
|
|
421
|
+
type: "object",
|
|
422
|
+
properties: {},
|
|
423
|
+
required: []
|
|
424
|
+
};
|
|
425
|
+
for (const prop of node.properties) {
|
|
426
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
427
|
+
const name = prop.name.getText(sourceFile);
|
|
428
|
+
const valueSchema = this.convertExpressionToSchema(prop.initializer, sourceFile, scope);
|
|
429
|
+
schema.properties[name] = valueSchema;
|
|
430
|
+
schema.required.push(name);
|
|
431
|
+
} else if (ts.isShorthandPropertyAssignment(prop)) {
|
|
432
|
+
const name = prop.name.getText(sourceFile);
|
|
433
|
+
const scopedSchema = scope.get(name);
|
|
434
|
+
schema.properties[name] = scopedSchema || { type: "object" };
|
|
435
|
+
schema.required.push(name);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (schema.required.length === 0) {
|
|
439
|
+
delete schema.required;
|
|
440
|
+
}
|
|
441
|
+
return schema;
|
|
442
|
+
}
|
|
443
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
444
|
+
const schema = { type: "array" };
|
|
445
|
+
if (node.elements.length > 0) {
|
|
446
|
+
schema.items = this.convertExpressionToSchema(node.elements[0], sourceFile, scope);
|
|
447
|
+
} else {
|
|
448
|
+
schema.items = {};
|
|
449
|
+
}
|
|
450
|
+
return schema;
|
|
451
|
+
}
|
|
452
|
+
if (ts.isConditionalExpression(node)) {
|
|
453
|
+
const trueSchema = this.convertExpressionToSchema(node.whenTrue, sourceFile, scope);
|
|
454
|
+
return trueSchema;
|
|
455
|
+
}
|
|
456
|
+
if (ts.isTemplateExpression(node)) {
|
|
457
|
+
return { type: "string" };
|
|
458
|
+
}
|
|
459
|
+
if (ts.isIdentifier(node)) {
|
|
460
|
+
const name = node.getText(sourceFile);
|
|
461
|
+
const scopedSchema = scope.get(name);
|
|
462
|
+
if (scopedSchema) return scopedSchema;
|
|
463
|
+
return { type: "object" };
|
|
464
|
+
}
|
|
465
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return { type: "string" };
|
|
466
|
+
if (ts.isNumericLiteral(node)) return { type: "number" };
|
|
467
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return { type: "boolean" };
|
|
468
|
+
return { type: "object" };
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Check if an expression is a call to ctx.body()
|
|
472
|
+
*/
|
|
473
|
+
isCtxBodyCall(node, sourceFile) {
|
|
474
|
+
if (ts.isAwaitExpression(node)) {
|
|
475
|
+
return this.isCtxBodyCall(node.expression, sourceFile);
|
|
476
|
+
}
|
|
477
|
+
if (ts.isCallExpression(node)) {
|
|
478
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
479
|
+
const objText = node.expression.expression.getText(sourceFile);
|
|
480
|
+
const propText = node.expression.name.getText(sourceFile);
|
|
481
|
+
return (objText === "ctx" || objText.endsWith(".ctx")) && propText === "body";
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Convert a TypeScript TypeNode to an OpenAPI schema
|
|
488
|
+
*/
|
|
489
|
+
convertTypeNodeToSchema(typeNode, sourceFile) {
|
|
490
|
+
switch (typeNode.kind) {
|
|
491
|
+
case ts.SyntaxKind.StringKeyword:
|
|
492
|
+
return { type: "string" };
|
|
493
|
+
case ts.SyntaxKind.NumberKeyword:
|
|
494
|
+
return { type: "number" };
|
|
495
|
+
case ts.SyntaxKind.BooleanKeyword:
|
|
496
|
+
return { type: "boolean" };
|
|
497
|
+
case ts.SyntaxKind.AnyKeyword:
|
|
498
|
+
case ts.SyntaxKind.UnknownKeyword:
|
|
499
|
+
return {};
|
|
500
|
+
// Any/Unknown -> empty schema (accepts anything)
|
|
501
|
+
case ts.SyntaxKind.TypeLiteral: {
|
|
502
|
+
const literal = typeNode;
|
|
503
|
+
const schema = {
|
|
504
|
+
type: "object",
|
|
505
|
+
properties: {},
|
|
506
|
+
required: []
|
|
507
|
+
};
|
|
508
|
+
for (const member of literal.members) {
|
|
509
|
+
if (ts.isPropertySignature(member) && member.type) {
|
|
510
|
+
const name = member.name.getText(sourceFile);
|
|
511
|
+
const propSchema = this.convertTypeNodeToSchema(member.type, sourceFile);
|
|
512
|
+
schema.properties[name] = propSchema;
|
|
513
|
+
if (!member.questionToken) {
|
|
514
|
+
schema.required.push(name);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (schema.required.length === 0) {
|
|
519
|
+
delete schema.required;
|
|
520
|
+
}
|
|
521
|
+
return schema;
|
|
522
|
+
}
|
|
523
|
+
case ts.SyntaxKind.ArrayType: {
|
|
524
|
+
const arrayType = typeNode;
|
|
525
|
+
return {
|
|
526
|
+
type: "array",
|
|
527
|
+
items: this.convertTypeNodeToSchema(arrayType.elementType, sourceFile)
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
// Handle Type References (e.g. Array<string>)
|
|
531
|
+
case ts.SyntaxKind.TypeReference: {
|
|
532
|
+
const typeRef = typeNode;
|
|
533
|
+
const typeName = typeRef.typeName.getText(sourceFile);
|
|
534
|
+
if (typeName === "Array" && typeRef.typeArguments && typeRef.typeArguments.length > 0) {
|
|
535
|
+
return {
|
|
536
|
+
type: "array",
|
|
537
|
+
items: this.convertTypeNodeToSchema(typeRef.typeArguments[0], sourceFile)
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
return { type: "object", description: `Ref: ${typeName}` };
|
|
541
|
+
}
|
|
542
|
+
default:
|
|
543
|
+
return { type: "object" };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Extract mount information from mount call
|
|
548
|
+
*/
|
|
549
|
+
extractMountFromCall(node, sourceFile) {
|
|
550
|
+
const args = node.arguments;
|
|
551
|
+
if (args.length < 2) return null;
|
|
552
|
+
const pathArg = args[0];
|
|
553
|
+
const targetArg = args[1];
|
|
554
|
+
let prefix = "/";
|
|
555
|
+
if (ts.isStringLiteral(pathArg)) {
|
|
556
|
+
prefix = pathArg.text;
|
|
557
|
+
}
|
|
558
|
+
const target = targetArg.getText(sourceFile);
|
|
559
|
+
const dependency = this.checkIfExternalDependency(target, sourceFile);
|
|
560
|
+
return {
|
|
561
|
+
prefix,
|
|
562
|
+
target,
|
|
563
|
+
dependency
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Check if a reference is to an external dependency
|
|
568
|
+
*/
|
|
569
|
+
checkIfExternalDependency(identifier, sourceFile) {
|
|
570
|
+
const imports = [];
|
|
571
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
572
|
+
if (ts.isImportDeclaration(node)) {
|
|
573
|
+
imports.push(node);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
for (const imp of imports) {
|
|
577
|
+
const moduleSpecifier = imp.moduleSpecifier;
|
|
578
|
+
if (ts.isStringLiteral(moduleSpecifier)) {
|
|
579
|
+
const modulePath = moduleSpecifier.text;
|
|
580
|
+
if (!modulePath.startsWith(".") && !modulePath.startsWith("/")) {
|
|
581
|
+
const namedBindings = imp.importClause?.namedBindings;
|
|
582
|
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
|
583
|
+
for (const element of namedBindings.elements) {
|
|
584
|
+
if (element.name.text === identifier) {
|
|
585
|
+
const version = this.getPackageVersion(modulePath);
|
|
586
|
+
return {
|
|
587
|
+
packageName: modulePath,
|
|
588
|
+
version,
|
|
589
|
+
importPath: modulePath,
|
|
590
|
+
isExternal: true
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return void 0;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Get package version from package.json
|
|
602
|
+
*/
|
|
603
|
+
getPackageVersion(packageName) {
|
|
604
|
+
try {
|
|
605
|
+
const packageJsonPath = path.join(this.rootDir, "node_modules", packageName, "package.json");
|
|
606
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
607
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
608
|
+
return packageJson.version;
|
|
609
|
+
}
|
|
610
|
+
} catch (e) {
|
|
611
|
+
}
|
|
612
|
+
return void 0;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Generate OpenAPI specification
|
|
616
|
+
*/
|
|
617
|
+
generateOpenAPISpec() {
|
|
618
|
+
const paths = {};
|
|
619
|
+
const collectRoutes = (app, prefix = "") => {
|
|
620
|
+
for (const route of app.routes) {
|
|
621
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
622
|
+
const cleanPath = route.path.startsWith("/") ? route.path : "/" + route.path;
|
|
623
|
+
const fullPath = cleanPrefix + cleanPath || "/";
|
|
624
|
+
const pathKey = fullPath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
625
|
+
if (!paths[pathKey]) {
|
|
626
|
+
paths[pathKey] = {};
|
|
627
|
+
}
|
|
628
|
+
const method = route.method.toLowerCase();
|
|
629
|
+
const operation = {
|
|
630
|
+
summary: route.summary || `${route.method.toUpperCase()} ${pathKey}`,
|
|
631
|
+
description: route.description,
|
|
632
|
+
tags: route.tags,
|
|
633
|
+
operationId: route.operationId,
|
|
634
|
+
responses: {
|
|
635
|
+
"200": {
|
|
636
|
+
description: "Successful response"
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
if (!operation.description) delete operation.description;
|
|
641
|
+
if (!operation.tags) delete operation.tags;
|
|
642
|
+
if (!operation.operationId) delete operation.operationId;
|
|
643
|
+
if (route.responseSchema) {
|
|
644
|
+
operation.responses["200"].content = {
|
|
645
|
+
"application/json": {
|
|
646
|
+
schema: route.responseSchema
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
} else if (route.responseType) {
|
|
650
|
+
const contentType = route.responseType === "string" ? "text/plain" : "application/json";
|
|
651
|
+
operation.responses["200"].content = {
|
|
652
|
+
[contentType]: {
|
|
653
|
+
schema: { type: route.responseType }
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
} else {
|
|
657
|
+
operation.responses["200"].content = {
|
|
658
|
+
"application/json": {
|
|
659
|
+
schema: { type: "object" }
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (route.requestTypes?.body) {
|
|
664
|
+
operation.requestBody = {
|
|
665
|
+
content: {
|
|
666
|
+
"application/json": {
|
|
667
|
+
schema: route.requestTypes.body
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
const parameters = [];
|
|
673
|
+
if (route.requestTypes?.query) {
|
|
674
|
+
for (const [key] of Object.entries(route.requestTypes.query)) {
|
|
675
|
+
parameters.push({
|
|
676
|
+
name: key,
|
|
677
|
+
in: "query",
|
|
678
|
+
schema: { type: "string" }
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (route.requestTypes?.params) {
|
|
683
|
+
for (const [key] of Object.entries(route.requestTypes.params)) {
|
|
684
|
+
parameters.push({
|
|
685
|
+
name: key,
|
|
686
|
+
in: "path",
|
|
687
|
+
required: true,
|
|
688
|
+
schema: { type: "string" }
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const pathParams = pathKey.match(/{([^}]+)}/g);
|
|
693
|
+
if (pathParams) {
|
|
694
|
+
pathParams.forEach((p) => {
|
|
695
|
+
const name = p.slice(1, -1);
|
|
696
|
+
if (!parameters.some((param) => param.name === name && param.in === "path")) {
|
|
697
|
+
parameters.push({
|
|
698
|
+
name,
|
|
699
|
+
in: "path",
|
|
700
|
+
required: true,
|
|
701
|
+
schema: { type: "string" }
|
|
702
|
+
// Default to string
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
if (parameters.length > 0) {
|
|
708
|
+
operation.parameters = parameters;
|
|
709
|
+
}
|
|
710
|
+
paths[pathKey][method] = operation;
|
|
711
|
+
}
|
|
712
|
+
for (const mount of app.mounted) {
|
|
713
|
+
const mountedApp = this.applications.find((a) => a.name === mount.target || a.className === mount.target);
|
|
714
|
+
if (mountedApp) {
|
|
715
|
+
if (mountedApp === app) continue;
|
|
716
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
717
|
+
const mountPrefix = mount.prefix.startsWith("/") ? mount.prefix : "/" + mount.prefix;
|
|
718
|
+
const nextPrefix = cleanPrefix + mountPrefix;
|
|
719
|
+
collectRoutes(mountedApp, nextPrefix);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
for (const app of this.applications) {
|
|
724
|
+
const isMounted = this.applications.some(
|
|
725
|
+
(parent) => parent.mounted.some((m) => m.target === app.name || m.target === app.className)
|
|
726
|
+
);
|
|
727
|
+
if (!isMounted) {
|
|
728
|
+
collectRoutes(app);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
openapi: "3.1.0",
|
|
733
|
+
info: {
|
|
734
|
+
title: "Shokupan API",
|
|
735
|
+
version: "1.0.0",
|
|
736
|
+
description: "Auto-generated from Shokupan application analysis"
|
|
737
|
+
},
|
|
738
|
+
paths,
|
|
739
|
+
components: {
|
|
740
|
+
schemas: {}
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Convert a type string to an OpenAPI schema
|
|
746
|
+
*/
|
|
747
|
+
typeToSchema(type) {
|
|
748
|
+
switch (type) {
|
|
749
|
+
case "string":
|
|
750
|
+
return { type: "string" };
|
|
751
|
+
case "number":
|
|
752
|
+
return { type: "number" };
|
|
753
|
+
case "boolean":
|
|
754
|
+
return { type: "boolean" };
|
|
755
|
+
case "array":
|
|
756
|
+
return { type: "array", items: {} };
|
|
757
|
+
case "object":
|
|
758
|
+
default:
|
|
759
|
+
return { type: "object" };
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
async function analyzeDirectory(directory) {
|
|
764
|
+
const analyzer = new OpenAPIAnalyzer(directory);
|
|
765
|
+
return await analyzer.analyze();
|
|
766
|
+
}
|
|
767
|
+
exports.OpenAPIAnalyzer = OpenAPIAnalyzer;
|
|
768
|
+
exports.analyzeDirectory = analyzeDirectory;
|
|
769
|
+
//# sourceMappingURL=openapi-analyzer-CFqgSLNK.cjs.map
|