@skalfa/skalfa-api-core 1.0.7 → 1.0.8
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/commands/cli.js +16 -27
- package/dist/commands/cli.js.map +1 -1
- package/dist/commands/make/resource.js +4 -4
- package/dist/commands/make/resource.js.map +1 -1
- package/dist/commands/make/skalfa-controller.d.ts +2 -2
- package/dist/commands/make/skalfa-controller.js +7 -7
- package/dist/commands/make/skalfa-controller.js.map +1 -1
- package/dist/commands/make/skalfa-model.d.ts +2 -2
- package/dist/commands/make/skalfa-model.js +9 -9
- package/dist/commands/make/skalfa-model.js.map +1 -1
- package/dist/commands/runner/blueprint/controller-generation.js +2 -2
- package/dist/commands/runner/blueprint/controller-generation.js.map +1 -1
- package/dist/commands/runner/blueprint/migration-generation.js +2 -2
- package/dist/commands/runner/blueprint/migration-generation.js.map +1 -1
- package/dist/commands/runner/blueprint/model-generation.js +2 -2
- package/dist/commands/runner/blueprint/model-generation.js.map +1 -1
- package/dist/commands/runner/blueprint/runner.js +5 -6
- package/dist/commands/runner/blueprint/runner.js.map +1 -1
- package/dist/commands/runner/blueprint/seeder-generation.js +2 -2
- package/dist/commands/runner/blueprint/seeder-generation.js.map +1 -1
- package/dist/commands/runner/generate-docs.d.ts +2 -0
- package/dist/commands/runner/generate-docs.js +400 -0
- package/dist/commands/runner/generate-docs.js.map +1 -0
- package/dist/commands/stubs/index.d.ts +4 -4
- package/dist/commands/stubs/index.js +4 -4
- package/dist/commands/stubs/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/cli.ts +17 -29
- package/src/commands/make/basic-controller.ts +1 -1
- package/src/commands/make/resource.ts +4 -4
- package/src/commands/make/skalfa-controller.ts +7 -7
- package/src/commands/make/skalfa-model.ts +9 -9
- package/src/commands/runner/blueprint/controller-generation.ts +2 -2
- package/src/commands/runner/blueprint/migration-generation.ts +2 -2
- package/src/commands/runner/blueprint/model-generation.ts +2 -2
- package/src/commands/runner/blueprint/runner.ts +5 -6
- package/src/commands/runner/blueprint/seeder-generation.ts +2 -2
- package/src/commands/runner/generate-docs.ts +495 -0
- package/src/commands/stubs/index.ts +4 -4
|
@@ -2,23 +2,23 @@ import path from "path";
|
|
|
2
2
|
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { conversion, logger } from "@utils";
|
|
5
|
-
import {
|
|
5
|
+
import { skalfaControllerStub } from "../stubs";
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
// =====================================>
|
|
10
|
-
// ## Command: make:
|
|
10
|
+
// ## Command: make:skafa-controller
|
|
11
11
|
// =====================================>
|
|
12
|
-
export const
|
|
12
|
+
export const makeSkalfaControllerCommand = new Command("make:skalfa-controller")
|
|
13
13
|
.argument("<name>", "Name of controller")
|
|
14
14
|
.option("-m, --model <model>", "Attach model to controller")
|
|
15
|
-
.description("Make the
|
|
15
|
+
.description("Make the Skalfa Controller")
|
|
16
16
|
.action((name, options) => {
|
|
17
|
-
|
|
17
|
+
makeSkalfaController(name, options.model);
|
|
18
18
|
process.exit(0);
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
export const
|
|
21
|
+
export const makeSkalfaController = (controllerName: string, modelName?: string) => {
|
|
22
22
|
const basePath = path.join(process.cwd(), "app", "controllers");
|
|
23
23
|
|
|
24
24
|
if (!controllerName || controllerName.trim() === "") {
|
|
@@ -48,7 +48,7 @@ export const makeLightController = (controllerName: string, modelName?: string)
|
|
|
48
48
|
logger.info(`Create folder ${targetDir}...`);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
let stub =
|
|
51
|
+
let stub = skalfaControllerStub;
|
|
52
52
|
|
|
53
53
|
stub = stub.replace(
|
|
54
54
|
/{{\s*name\s*}}|{{\s*model\s*}}|{{\s*validations\s*}}|{{\s*marker\s*}}/g,
|
|
@@ -2,25 +2,25 @@ import path from "path";
|
|
|
2
2
|
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { conversion, logger } from "@utils";
|
|
5
|
-
import {
|
|
5
|
+
import { makeSkalfaController } from "./skalfa-controller";
|
|
6
6
|
import { makeMigration } from "./basic-migration";
|
|
7
7
|
import { makeSeeder } from "./basic-seeder";
|
|
8
|
-
import {
|
|
8
|
+
import { skalfaModelStub } from "../stubs";
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
// =====================================>
|
|
13
|
-
// ## Command: make:
|
|
13
|
+
// ## Command: make:skalfa-model
|
|
14
14
|
// =====================================>
|
|
15
|
-
export const
|
|
15
|
+
export const makeSkalfaModelCommand = new Command("make:skalfa-model")
|
|
16
16
|
.argument("<name>", "Name of model")
|
|
17
17
|
.option("-r", "Generate all resource (controller, migration, seeder)")
|
|
18
|
-
.description("Make the
|
|
18
|
+
.description("Make the Skalfa Model")
|
|
19
19
|
.action((name, options) => {
|
|
20
|
-
|
|
20
|
+
makeSkalfaModel(name);
|
|
21
21
|
|
|
22
22
|
if (options.r) {
|
|
23
|
-
|
|
23
|
+
makeSkalfaController(name);
|
|
24
24
|
makeMigration("create_" + name, { init: true });
|
|
25
25
|
makeSeeder(name);
|
|
26
26
|
}
|
|
@@ -28,7 +28,7 @@ export const makeLightModelCommand = new Command("make:skalfa-model")
|
|
|
28
28
|
process.exit(0);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
export const
|
|
31
|
+
export const makeSkalfaModel = (modelName: string) => {
|
|
32
32
|
const name = conversion.strPascal(modelName);
|
|
33
33
|
const filename = conversion.strSlug(modelName) + ".model.ts";
|
|
34
34
|
|
|
@@ -45,7 +45,7 @@ export const makeLightModel = (modelName: string) => {
|
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
let stub =
|
|
48
|
+
let stub = skalfaModelStub;
|
|
49
49
|
|
|
50
50
|
stub = stub
|
|
51
51
|
.replace(/{{\s*name\s*}}/g, name)
|
|
@@ -2,7 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { conversion } from "@utils";
|
|
4
4
|
import { resolveBlueprintPath } from "./runner";
|
|
5
|
-
import {
|
|
5
|
+
import { skalfaControllerStub, routeStub } from "../../stubs";
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
@@ -33,7 +33,7 @@ export async function controllerGeneration(
|
|
|
33
33
|
...generateRelationValidations(relations)
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
let stub =
|
|
36
|
+
let stub = skalfaControllerStub;
|
|
37
37
|
|
|
38
38
|
stub = stub
|
|
39
39
|
.replace(/{{\s*marker\s*}}/g, marker)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { conversion, logger } from "@utils";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import {
|
|
4
|
+
import { skalfaMigrationStub } from "../../stubs";
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
|
|
@@ -111,7 +111,7 @@ export async function migrationGeneration(
|
|
|
111
111
|
|
|
112
112
|
const migrationSchema = migrationFields.map((f) => ` ${f}`).join("\n");
|
|
113
113
|
|
|
114
|
-
let stub =
|
|
114
|
+
let stub = skalfaMigrationStub;
|
|
115
115
|
|
|
116
116
|
stub = stub
|
|
117
117
|
.replace(/{{\s*marker\s*}}/g, marker)
|
|
@@ -2,7 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { conversion } from "@utils";
|
|
4
4
|
import { resolveBlueprintPath } from "./runner";
|
|
5
|
-
import {
|
|
5
|
+
import { skalfaModelStub } from "../../stubs";
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
@@ -105,7 +105,7 @@ export async function modelGeneration(
|
|
|
105
105
|
imports.push(`import { ${importRelations.join(", ")} } from '@models'`);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
let stub =
|
|
108
|
+
let stub = skalfaModelStub;
|
|
109
109
|
const strImportUtils = importUtils?.length ? ", " + importUtils.join(", ") : "";
|
|
110
110
|
|
|
111
111
|
stub = stub
|
|
@@ -6,7 +6,6 @@ import { modelGeneration } from "./model-generation";
|
|
|
6
6
|
import { migrationGeneration } from "./migration-generation";
|
|
7
7
|
import { controllerGeneration } from "./controller-generation";
|
|
8
8
|
import { seederGeneration } from "./seeder-generation";
|
|
9
|
-
import { generateDrawioEntityDocumentation, generateMermaidEntityDocumentation, generatePostmanAPIDocumentation } from "./documentation-generation";
|
|
10
9
|
import { exec as execCb } from "child_process";
|
|
11
10
|
import { promisify } from "util";
|
|
12
11
|
|
|
@@ -111,14 +110,14 @@ export async function runBlueprints(options?: { only?: string[] }) {
|
|
|
111
110
|
await seederGeneration(struct.model, schema, seeders, marker);
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
if (struct.mermaid !== false) {
|
|
115
|
-
|
|
116
|
-
}
|
|
113
|
+
// if (struct.mermaid !== false) {
|
|
114
|
+
// await generateMermaidEntityDocumentation(file.file, file.blueprints);
|
|
115
|
+
// }
|
|
117
116
|
}
|
|
118
117
|
}
|
|
119
118
|
|
|
120
|
-
await generateDrawioEntityDocumentation(loaded);
|
|
121
|
-
await generatePostmanAPIDocumentation(postmanSchemas);
|
|
119
|
+
// await generateDrawioEntityDocumentation(loaded);
|
|
120
|
+
// await generatePostmanAPIDocumentation(postmanSchemas);
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { conversion, logger } from "@utils";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import {
|
|
4
|
+
import { skalfaSeederStub } from "../../stubs";
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
|
|
@@ -39,7 +39,7 @@ export async function seederGeneration(
|
|
|
39
39
|
})
|
|
40
40
|
.join(", ")}}`).join(",\n ");
|
|
41
41
|
|
|
42
|
-
let stub =
|
|
42
|
+
let stub = skalfaSeederStub;
|
|
43
43
|
|
|
44
44
|
stub = stub
|
|
45
45
|
.replace(/{{\s*marker\s*}}/g, marker)
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
|
|
5
|
+
// Helper to recursively find files with specific extensions
|
|
6
|
+
function getFiles(dir: string, extList: string[]): string[] {
|
|
7
|
+
let results: string[] = [];
|
|
8
|
+
if (!fs.existsSync(dir)) return results;
|
|
9
|
+
const list = fs.readdirSync(dir);
|
|
10
|
+
for (const file of list) {
|
|
11
|
+
const filePath = path.join(dir, file);
|
|
12
|
+
const stat = fs.statSync(filePath);
|
|
13
|
+
if (stat.isDirectory()) {
|
|
14
|
+
if (file !== "node_modules" && file !== ".next" && file !== ".git") {
|
|
15
|
+
results = results.concat(getFiles(filePath, extList));
|
|
16
|
+
}
|
|
17
|
+
} else {
|
|
18
|
+
if (extList.includes(path.extname(file))) {
|
|
19
|
+
results.push(filePath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Simple parser for object literal string (e.g. c.validation block)
|
|
27
|
+
function parseObjectLiteral(str: string): Record<string, string> {
|
|
28
|
+
const result: Record<string, string> = {};
|
|
29
|
+
// Strip single-line and multi-line comments
|
|
30
|
+
const cleanStr = str
|
|
31
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
32
|
+
.replace(/\/\/.*$/gm, "");
|
|
33
|
+
|
|
34
|
+
// Regex to match key: value
|
|
35
|
+
const regex = /(?:(\w+)|["']([^"']+)["'])\s*:\s*([^,}\n]+)/g;
|
|
36
|
+
let match;
|
|
37
|
+
while ((match = regex.exec(cleanStr)) !== null) {
|
|
38
|
+
const key = match[1] || match[2];
|
|
39
|
+
const val = match[3].trim().replace(/['"\[\]]/g, "").replace(/\s+/g, " ");
|
|
40
|
+
result[key] = val;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RouteInfo {
|
|
46
|
+
method: string;
|
|
47
|
+
path: string;
|
|
48
|
+
controllerName: string;
|
|
49
|
+
methodName: string;
|
|
50
|
+
routesFile: string; // e.g. "base.routes.ts" or "admin.routes.ts"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ModelField {
|
|
54
|
+
name: string;
|
|
55
|
+
type: string;
|
|
56
|
+
decorators: string[]; // e.g. ["fillable", "selectable", "searchable"]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ModelRelation {
|
|
60
|
+
name: string;
|
|
61
|
+
type: string; // e.g. "HasMany", "BelongsTo", "HasOne"
|
|
62
|
+
targetModel: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ModelInfo {
|
|
66
|
+
name: string;
|
|
67
|
+
fields: ModelField[];
|
|
68
|
+
relations: ModelRelation[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ControllerMethodInfo {
|
|
72
|
+
methodName: string;
|
|
73
|
+
isMagicResolve: boolean;
|
|
74
|
+
isMagicPump: boolean;
|
|
75
|
+
validationModel?: string;
|
|
76
|
+
validationRules: Record<string, string>;
|
|
77
|
+
customQueryParams: string[];
|
|
78
|
+
customPayloadFields: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ControllerInfo {
|
|
82
|
+
name: string;
|
|
83
|
+
methods: Record<string, ControllerMethodInfo>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const generateDocsCommand = new Command("generate:docs")
|
|
87
|
+
.description("Generate API Markdown documentation from routes, controllers, and models")
|
|
88
|
+
.option("-p, --path <path>", "Filter by specific route path (e.g. --path=/users)")
|
|
89
|
+
.action(async (options) => {
|
|
90
|
+
const projectRoot = process.cwd();
|
|
91
|
+
const filterPath = options.path;
|
|
92
|
+
|
|
93
|
+
console.log(`🔍 Scanning project in: ${projectRoot}`);
|
|
94
|
+
if (filterPath) {
|
|
95
|
+
console.log(`Filter path: ${filterPath}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 1. Parse Models
|
|
99
|
+
const modelFiles = getFiles(path.join(projectRoot, "app", "models"), [".ts"]);
|
|
100
|
+
const models: Record<string, ModelInfo> = {};
|
|
101
|
+
|
|
102
|
+
for (const file of modelFiles) {
|
|
103
|
+
const content = fs.readFileSync(file, "utf8");
|
|
104
|
+
// Find class User extends Model
|
|
105
|
+
const classMatch = /class\s+(\w+)\s+extends\s+Model/g.exec(content);
|
|
106
|
+
if (!classMatch) continue;
|
|
107
|
+
const modelName = classMatch[1];
|
|
108
|
+
|
|
109
|
+
const fields: ModelField[] = [];
|
|
110
|
+
const relations: ModelRelation[] = [];
|
|
111
|
+
|
|
112
|
+
// Parse fields: @Field(["fillable", "selectable", "searchable"]) name!: string
|
|
113
|
+
const fieldRegex = /@Field\(\s*\[([\s\S]*?)\]\s*\)\s*(\w+)\s*!?:?\s*(\w+)/g;
|
|
114
|
+
let fieldMatch;
|
|
115
|
+
while ((fieldMatch = fieldRegex.exec(content)) !== null) {
|
|
116
|
+
const decorators = fieldMatch[1]
|
|
117
|
+
.split(",")
|
|
118
|
+
.map((d) => d.trim().replace(/['"]/g, ""))
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
fields.push({
|
|
121
|
+
name: fieldMatch[2],
|
|
122
|
+
type: fieldMatch[3],
|
|
123
|
+
decorators,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Parse relations: @HasMany(() => Role, "user_id") roles!: Role[]
|
|
128
|
+
const relationRegex = /@(HasMany|HasOne|BelongsTo)\(\s*\(\s*\)\s*=>\s*(\w+)[^]*?\)\s*(\w+)/g;
|
|
129
|
+
let relationMatch;
|
|
130
|
+
while ((relationMatch = relationRegex.exec(content)) !== null) {
|
|
131
|
+
relations.push({
|
|
132
|
+
type: relationMatch[1],
|
|
133
|
+
targetModel: relationMatch[2],
|
|
134
|
+
name: relationMatch[3],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
models[modelName] = {
|
|
139
|
+
name: modelName,
|
|
140
|
+
fields,
|
|
141
|
+
relations,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 2. Parse Controllers
|
|
146
|
+
const controllerFiles = getFiles(path.join(projectRoot, "app", "controllers"), [".ts"]);
|
|
147
|
+
const controllers: Record<string, ControllerInfo> = {};
|
|
148
|
+
|
|
149
|
+
for (const file of controllerFiles) {
|
|
150
|
+
const content = fs.readFileSync(file, "utf8");
|
|
151
|
+
const classMatch = /class\s+(\w+)/g.exec(content);
|
|
152
|
+
if (!classMatch) continue;
|
|
153
|
+
const controllerName = classMatch[1];
|
|
154
|
+
|
|
155
|
+
const methods: Record<string, ControllerMethodInfo> = {};
|
|
156
|
+
|
|
157
|
+
// Simple method splitter by looking for static async or static methods
|
|
158
|
+
const methodRegex = /static\s+(?:async\s+)?(\w+)\s*\(\s*c\s*:\s*(\w+|any)\s*\)\s*\{([\s\S]*?)(?=\s+static|\s+\/\/|\s*\}\s*$)/g;
|
|
159
|
+
let methodMatch;
|
|
160
|
+
while ((methodMatch = methodRegex.exec(content)) !== null) {
|
|
161
|
+
const methodName = methodMatch[1];
|
|
162
|
+
const body = methodMatch[3];
|
|
163
|
+
|
|
164
|
+
const isMagicResolve = body.includes(".resolve(c)");
|
|
165
|
+
const isMagicPump = body.includes(".pump(");
|
|
166
|
+
|
|
167
|
+
// Parse c.validation<ModelName>({ ... })
|
|
168
|
+
let validationModel: string | undefined;
|
|
169
|
+
let validationRules: Record<string, string> = {};
|
|
170
|
+
const valRegex = /c\.validation(?:<(\w+)>)?\(\s*(\{[\s\S]*?\})\s*\)/g;
|
|
171
|
+
const valMatch = valRegex.exec(body);
|
|
172
|
+
if (valMatch) {
|
|
173
|
+
validationModel = valMatch[1];
|
|
174
|
+
validationRules = parseObjectLiteral(valMatch[2]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Parse custom query parameters: c.getQuery.field, c.query.field, or destructuring
|
|
178
|
+
const customQueryParams: string[] = [];
|
|
179
|
+
const queryFieldRegex = /\bc\.(?:getQuery|query)\.(\w+)\b/g;
|
|
180
|
+
let qfMatch;
|
|
181
|
+
while ((qfMatch = queryFieldRegex.exec(body)) !== null) {
|
|
182
|
+
customQueryParams.push(qfMatch[1]);
|
|
183
|
+
}
|
|
184
|
+
// Destructuring: const { a, b } = c.getQuery
|
|
185
|
+
const queryDestructRegex = /const\s*\{\s*([\w\s,]+)\s*\}\s*=\s*c\.(?:getQuery|query)/g;
|
|
186
|
+
let qdMatch;
|
|
187
|
+
while ((qdMatch = queryDestructRegex.exec(body)) !== null) {
|
|
188
|
+
qdMatch[1].split(",").forEach((p) => {
|
|
189
|
+
const trimmed = p.trim();
|
|
190
|
+
if (trimmed) customQueryParams.push(trimmed);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Parse custom payload fields: c.payload.field, c.body.field, or destructuring
|
|
195
|
+
const customPayloadFields: string[] = [];
|
|
196
|
+
const payloadFieldRegex = /\bc\.(?:payload|body)\.(\w+)\b/g;
|
|
197
|
+
let pfMatch;
|
|
198
|
+
while ((pfMatch = payloadFieldRegex.exec(body)) !== null) {
|
|
199
|
+
customPayloadFields.push(pfMatch[1]);
|
|
200
|
+
}
|
|
201
|
+
const payloadDestructRegex = /const\s*\{\s*([\w\s,]+)\s*\}\s*=\s*c\.(?:payload|body)/g;
|
|
202
|
+
let pdMatch;
|
|
203
|
+
while ((pdMatch = payloadDestructRegex.exec(body)) !== null) {
|
|
204
|
+
pdMatch[1].split(",").forEach((p) => {
|
|
205
|
+
const trimmed = p.trim();
|
|
206
|
+
if (trimmed) customPayloadFields.push(trimmed);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
methods[methodName] = {
|
|
211
|
+
methodName,
|
|
212
|
+
isMagicResolve,
|
|
213
|
+
isMagicPump,
|
|
214
|
+
validationModel,
|
|
215
|
+
validationRules,
|
|
216
|
+
customQueryParams: Array.from(new Set(customQueryParams)),
|
|
217
|
+
customPayloadFields: Array.from(new Set(customPayloadFields)),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
controllers[controllerName] = {
|
|
222
|
+
name: controllerName,
|
|
223
|
+
methods,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 3. Parse Routes
|
|
228
|
+
const routeFiles = getFiles(path.join(projectRoot, "app", "routes"), [".ts"]);
|
|
229
|
+
const routesList: RouteInfo[] = [];
|
|
230
|
+
|
|
231
|
+
for (const file of routeFiles) {
|
|
232
|
+
const content = fs.readFileSync(file, "utf8");
|
|
233
|
+
const filename = path.basename(file);
|
|
234
|
+
|
|
235
|
+
// We want to find Elysia route registrations and api() helper registrations
|
|
236
|
+
// Group: app.group('/api', (route) => { ... })
|
|
237
|
+
// Let's capture the group path
|
|
238
|
+
const groupRegex = /\.group\(\s*['"]([^'"]+)['"]/g;
|
|
239
|
+
let groupPrefix = "";
|
|
240
|
+
const groupMatch = groupRegex.exec(content);
|
|
241
|
+
if (groupMatch) {
|
|
242
|
+
groupPrefix = groupMatch[1]; // e.g. "/api"
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 1. Standard routes: route.get('/path', Controller.method)
|
|
246
|
+
const routeRegex = /route\.(\w+)\(\s*['"]([^'"]+)['"]\s*,\s*(\w+)\.(\w+)\)/g;
|
|
247
|
+
let rMatch;
|
|
248
|
+
while ((rMatch = routeRegex.exec(content)) !== null) {
|
|
249
|
+
routesList.push({
|
|
250
|
+
method: rMatch[1].toUpperCase(),
|
|
251
|
+
path: `${groupPrefix}${rMatch[2]}`.replace(/\/+/g, "/"),
|
|
252
|
+
controllerName: rMatch[3],
|
|
253
|
+
methodName: rMatch[4],
|
|
254
|
+
routesFile: filename,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 2. Resource routes: api(route, "/users", UserController)
|
|
259
|
+
const resourceRegex = /api\(\s*\w+\s*,\s*['"]([^'"]+)['"]\s*,\s*(\w+)\)/g;
|
|
260
|
+
let resMatch;
|
|
261
|
+
while ((resMatch = resourceRegex.exec(content)) !== null) {
|
|
262
|
+
const basePath = `${groupPrefix}${resMatch[1]}`.replace(/\/+/g, "/");
|
|
263
|
+
const controllerName = resMatch[2];
|
|
264
|
+
|
|
265
|
+
// Standard RESTful mappings for api()
|
|
266
|
+
const resourceMethods = [
|
|
267
|
+
{ method: "GET", path: basePath, methodName: "index" },
|
|
268
|
+
{ method: "POST", path: basePath, methodName: "store" },
|
|
269
|
+
{ method: "PUT", path: `${basePath}/:id`.replace(/\/+/g, "/"), methodName: "update" },
|
|
270
|
+
{ method: "DELETE", path: `${basePath}/:id`.replace(/\/+/g, "/"), methodName: "destroy" },
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
for (const rm of resourceMethods) {
|
|
274
|
+
routesList.push({
|
|
275
|
+
method: rm.method,
|
|
276
|
+
path: rm.path,
|
|
277
|
+
controllerName,
|
|
278
|
+
methodName: rm.methodName,
|
|
279
|
+
routesFile: filename,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 4. Generate Markdown Documentation
|
|
286
|
+
let generatedCount = 0;
|
|
287
|
+
const docsDir = path.join(projectRoot, "docs");
|
|
288
|
+
|
|
289
|
+
if (!fs.existsSync(docsDir)) {
|
|
290
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const route of routesList) {
|
|
294
|
+
const fullPath = route.path;
|
|
295
|
+
|
|
296
|
+
// Filter by path if requested
|
|
297
|
+
if (filterPath && !fullPath.startsWith(filterPath)) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const controller = controllers[route.controllerName];
|
|
302
|
+
if (!controller) continue;
|
|
303
|
+
|
|
304
|
+
const methodInfo = controller.methods[route.methodName];
|
|
305
|
+
if (!methodInfo) continue;
|
|
306
|
+
|
|
307
|
+
// Find associated model
|
|
308
|
+
// First check if validation specified a model, otherwise guess from controller name or model usages
|
|
309
|
+
let modelName = methodInfo.validationModel;
|
|
310
|
+
if (!modelName) {
|
|
311
|
+
// Guess from controller name (e.g., UserController -> User)
|
|
312
|
+
const guessed = route.controllerName.replace("Controller", "");
|
|
313
|
+
if (models[guessed]) {
|
|
314
|
+
modelName = guessed;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const model = modelName ? models[modelName] : undefined;
|
|
319
|
+
|
|
320
|
+
// Determine output directory based on routes file and path
|
|
321
|
+
// If routesFile is not "base.routes.ts", prefix folder with routesFile prefix (e.g. admin.routes.ts -> admin)
|
|
322
|
+
let modulePrefix = "";
|
|
323
|
+
if (route.routesFile !== "base.routes.ts") {
|
|
324
|
+
modulePrefix = route.routesFile.replace(".routes.ts", "");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// First path segment after the group/api prefix
|
|
328
|
+
const cleanPath = fullPath.replace(/^\/(api|v1)\//, "").replace(/^\//, "");
|
|
329
|
+
const firstSegment = cleanPath.split("/")[0] || "general";
|
|
330
|
+
|
|
331
|
+
const targetFolder = modulePrefix
|
|
332
|
+
? path.join(docsDir, modulePrefix, firstSegment)
|
|
333
|
+
: path.join(docsDir, firstSegment);
|
|
334
|
+
|
|
335
|
+
if (!fs.existsSync(targetFolder)) {
|
|
336
|
+
fs.mkdirSync(targetFolder, { recursive: true });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// File Naming: [METHOD]_[SAFE_PATH].md
|
|
340
|
+
// Replace :param with [param], and other unsafe chars with _
|
|
341
|
+
const safePathName = fullPath
|
|
342
|
+
.replace(/:(\w+)/g, "[$1]") // replace :id with [id]
|
|
343
|
+
.replace(/^\//, "")
|
|
344
|
+
.replace(/\/+/g, "_"); // replace slashes with underscore
|
|
345
|
+
|
|
346
|
+
const fileName = `${route.method}_${safePathName}.md`;
|
|
347
|
+
const filePath = path.join(targetFolder, fileName);
|
|
348
|
+
|
|
349
|
+
// Start building Markdown content
|
|
350
|
+
let md = `# ${route.method} ${fullPath}\n\n`;
|
|
351
|
+
md += `* **Controller**: \`${route.controllerName}.${route.methodName}\`\n`;
|
|
352
|
+
if (model) {
|
|
353
|
+
md += `* **Model**: \`${model.name}\`\n`;
|
|
354
|
+
}
|
|
355
|
+
md += `* **Features**: ${
|
|
356
|
+
[
|
|
357
|
+
methodInfo.isMagicResolve ? "**[Magic Resolve]**" : "",
|
|
358
|
+
methodInfo.isMagicPump ? "**[Magic Pump]**" : "",
|
|
359
|
+
]
|
|
360
|
+
.filter(Boolean)
|
|
361
|
+
.join(", ") || "None"
|
|
362
|
+
}\n\n`;
|
|
363
|
+
|
|
364
|
+
// 1. Headers Table (Only if GET and supports option mode, or custom headers)
|
|
365
|
+
if (route.method === "GET" && methodInfo.isMagicResolve) {
|
|
366
|
+
md += `## Request Headers (Optional)\n\n`;
|
|
367
|
+
md += `| Key | Example Value |\n`;
|
|
368
|
+
md += `| :--- | :--- |\n`;
|
|
369
|
+
md += `| \`x-option\` | \`"true"\` |\n\n`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 2. Query Parameters Table
|
|
373
|
+
const queryParams: Array<{ key: string; example: string; desc: string }> = [];
|
|
374
|
+
if (route.method === "GET" && methodInfo.isMagicResolve) {
|
|
375
|
+
queryParams.push(
|
|
376
|
+
{ key: "isOption", example: "true", desc: "Alternative to activate **Dropdown/Option Mode**." },
|
|
377
|
+
{ key: "option[]", example: '["id", "name"]', desc: "Customizes the columns mapped to `value` and `label` in Option Mode." },
|
|
378
|
+
{ key: "page", example: "1", desc: "The page number for pagination." },
|
|
379
|
+
{ key: "paginate", example: "15", desc: "Number of records per page." },
|
|
380
|
+
{ key: "search", example: '"john"', desc: "Performs a global search across `searchable` fields in the model." },
|
|
381
|
+
{ key: "filter[column]", example: '"active"', desc: "Filters records by a specific column value." }
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// Add expandable relations
|
|
385
|
+
if (model && model.relations.length > 0) {
|
|
386
|
+
const relationNames = model.relations.map((r) => `\`"${r.name}"\``).join(", ");
|
|
387
|
+
queryParams.push({
|
|
388
|
+
key: "expand",
|
|
389
|
+
example: `"${model.relations[0].name}"`,
|
|
390
|
+
desc: `Eager-loads related models. Available relations: ${relationNames}. Can also be passed as \`expand[]\`.`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
queryParams.push({ key: "sort", example: '"-created_at"', desc: "Sorts by a column. Prefix with `-` for descending order." });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Add custom query parameters found in controller
|
|
398
|
+
for (const customQ of methodInfo.customQueryParams) {
|
|
399
|
+
// Skip if already in standard params
|
|
400
|
+
if (["page", "paginate", "search", "filter", "expand", "sort", "isOption", "option"].includes(customQ)) continue;
|
|
401
|
+
queryParams.push({
|
|
402
|
+
key: customQ,
|
|
403
|
+
example: `"value"`,
|
|
404
|
+
desc: "Custom query parameter extracted from controller logic.",
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (queryParams.length > 0) {
|
|
409
|
+
md += `## Query Parameters\n\n`;
|
|
410
|
+
md += `| Key | Example Value |\n`;
|
|
411
|
+
md += `| :--- | :--- |\n`;
|
|
412
|
+
for (const q of queryParams) {
|
|
413
|
+
md += `| \`${q.key}\` | \`${q.example}\` |\n`;
|
|
414
|
+
}
|
|
415
|
+
md += `\n`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 3. Payload Table (For POST/PUT/PATCH)
|
|
419
|
+
if (["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
420
|
+
md += `## Payload (JSON Body)\n\n`;
|
|
421
|
+
md += `| Key | Example Value | Required | Validation |\n`;
|
|
422
|
+
md += `| :--- | :--- | :--- | :--- |\n`;
|
|
423
|
+
|
|
424
|
+
const payloadFieldsSet = new Set<string>();
|
|
425
|
+
|
|
426
|
+
// First add fields from validation rules
|
|
427
|
+
for (const [field, rule] of Object.entries(methodInfo.validationRules)) {
|
|
428
|
+
payloadFieldsSet.add(field);
|
|
429
|
+
const isRequired = rule.includes("required") ? "Yes" : "No";
|
|
430
|
+
md += `| \`${field}\` | \`""\` | ${isRequired} | \`${rule}\` |\n`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Add other fillable fields from Model if not already in validation
|
|
434
|
+
if (model) {
|
|
435
|
+
for (const f of model.fields) {
|
|
436
|
+
if (f.decorators.includes("fillable") && !payloadFieldsSet.has(f.name)) {
|
|
437
|
+
payloadFieldsSet.add(f.name);
|
|
438
|
+
md += `| \`${f.name}\` | \`""\` | No | \`None\` |\n`;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Add pumpable relations
|
|
443
|
+
if (methodInfo.isMagicPump) {
|
|
444
|
+
for (const r of model.relations) {
|
|
445
|
+
if (!payloadFieldsSet.has(r.name)) {
|
|
446
|
+
payloadFieldsSet.add(r.name);
|
|
447
|
+
const isArray = r.type === "HasMany" ? "[]" : "";
|
|
448
|
+
md += `| \`${r.name}\` | \`${r.type === "HasMany" ? "[]" : "{}"}\` | No | \`Relation (${r.type})\` |\n`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Add custom payload fields found in controller
|
|
455
|
+
for (const customP of methodInfo.customPayloadFields) {
|
|
456
|
+
if (!payloadFieldsSet.has(customP)) {
|
|
457
|
+
payloadFieldsSet.add(customP);
|
|
458
|
+
md += `| \`${customP}\` | \`""\` | No | \`Custom\` |\n`;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
md += `\n`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 4. Response Table
|
|
465
|
+
md += `## Response\n\n`;
|
|
466
|
+
md += `| Key | Example Result |\n`;
|
|
467
|
+
md += `| :--- | :--- |\n`;
|
|
468
|
+
|
|
469
|
+
if (route.method === "GET" && methodInfo.isMagicResolve) {
|
|
470
|
+
md += `| \`data\` | \`[]\` |\n`;
|
|
471
|
+
md += `| \`total\` | \`0\` |\n`;
|
|
472
|
+
} else if (model) {
|
|
473
|
+
// Show selectable fields of the model
|
|
474
|
+
for (const f of model.fields) {
|
|
475
|
+
if (f.decorators.includes("selectable")) {
|
|
476
|
+
let exampleVal = `""`;
|
|
477
|
+
if (f.type === "number") exampleVal = `0`;
|
|
478
|
+
else if (f.type === "boolean") exampleVal = `true`;
|
|
479
|
+
else if (f.type === "Date") exampleVal = `"${new Date().toISOString()}"`;
|
|
480
|
+
|
|
481
|
+
md += `| \`${f.name}\` | \`${exampleVal}\` |\n`;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
} else {
|
|
485
|
+
md += `| \`success\` | \`true\` |\n`;
|
|
486
|
+
}
|
|
487
|
+
md += `\n`;
|
|
488
|
+
|
|
489
|
+
fs.writeFileSync(filePath, md, "utf8");
|
|
490
|
+
console.log(`Generated: docs/${path.relative(docsDir, filePath)}`);
|
|
491
|
+
generatedCount++;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
console.log(`🎉 API Documentation generated successfully! Total files: ${generatedCount}`);
|
|
495
|
+
});
|