spring-api-scanner 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/PLAN.md +142 -0
- package/README.md +60 -0
- package/dist/args.d.ts +9 -0
- package/dist/args.js +57 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +67 -0
- package/dist/openapi.d.ts +67 -0
- package/dist/openapi.js +94 -0
- package/dist/output.d.ts +6 -0
- package/dist/output.js +25 -0
- package/dist/resolver.d.ts +17 -0
- package/dist/resolver.js +206 -0
- package/dist/scanner.d.ts +22 -0
- package/dist/scanner.js +321 -0
- package/dist/ui.d.ts +36 -0
- package/dist/ui.js +407 -0
- package/docs/plans/2026-02-07-complete-remaining-tasks.md +61 -0
- package/package.json +23 -0
- package/spring-api-scanner-0.1.0.tgz +0 -0
- package/src/args.ts +76 -0
- package/src/index.ts +81 -0
- package/src/openapi.ts +191 -0
- package/src/output.ts +36 -0
- package/src/resolver.ts +274 -0
- package/src/scanner.ts +409 -0
- package/src/ui.ts +454 -0
- package/tests/args.test.d.ts +1 -0
- package/tests/args.test.ts +45 -0
- package/tests/fixtures/sample-service/src/main/kotlin/demo/UserController.kt +34 -0
- package/tests/fixtures/sample-service/src/main/kotlin/demo/UserDtos.kt +22 -0
- package/tests/golden/openapi.sample-service.json +164 -0
- package/tests/integration.test.ts +98 -0
- package/tests/openapi.test.d.ts +1 -0
- package/tests/openapi.test.ts +127 -0
- package/tests/output.test.ts +55 -0
- package/tests/resolver.test.ts +98 -0
- package/tests/scanner.test.ts +138 -0
- package/tests/ui.test.ts +85 -0
- package/tsconfig.json +15 -0
package/dist/resolver.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export async function scanDataClasses(projectPath) {
|
|
4
|
+
const kotlinRoot = path.join(projectPath, "src", "main", "kotlin");
|
|
5
|
+
const files = (await listKotlinFiles(kotlinRoot)).sort();
|
|
6
|
+
const registry = {};
|
|
7
|
+
for (const filePath of files) {
|
|
8
|
+
const source = await readFile(filePath, "utf8");
|
|
9
|
+
Object.assign(registry, parseDataClassesFromSource(source));
|
|
10
|
+
}
|
|
11
|
+
return registry;
|
|
12
|
+
}
|
|
13
|
+
export function parseDataClassesFromSource(source) {
|
|
14
|
+
const registry = {};
|
|
15
|
+
const matches = source.matchAll(/((?:\s*@[^\n]+\n)*)\s*data\s+class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)/g);
|
|
16
|
+
for (const match of matches) {
|
|
17
|
+
const annotationBlock = match[1] ?? "";
|
|
18
|
+
const name = match[2] ?? "";
|
|
19
|
+
const rawProperties = match[3] ?? "";
|
|
20
|
+
const properties = splitTopLevel(rawProperties)
|
|
21
|
+
.map((value) => value.trim())
|
|
22
|
+
.filter((value) => value.length > 0)
|
|
23
|
+
.map(parseProperty)
|
|
24
|
+
.filter((value) => value !== null);
|
|
25
|
+
registry[name] = {
|
|
26
|
+
name,
|
|
27
|
+
namingStrategy: parseNamingStrategy(annotationBlock),
|
|
28
|
+
properties
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return registry;
|
|
32
|
+
}
|
|
33
|
+
export function resolveSchemaForType(rawType, registry, components, seen = new Set(), context) {
|
|
34
|
+
const typeName = cleanType(rawType);
|
|
35
|
+
const unwrapped = normalizeNullableType(unwrapEntity(typeName));
|
|
36
|
+
if (isListLike(unwrapped)) {
|
|
37
|
+
const inner = extractSingleGeneric(unwrapped);
|
|
38
|
+
return {
|
|
39
|
+
type: "array",
|
|
40
|
+
items: resolveSchemaForType(inner, registry, components, seen, context)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const primitive = mapPrimitive(unwrapped);
|
|
44
|
+
if (primitive) {
|
|
45
|
+
return primitive;
|
|
46
|
+
}
|
|
47
|
+
if (registry[unwrapped]) {
|
|
48
|
+
ensureComponent(unwrapped, registry, components, seen, context);
|
|
49
|
+
return { $ref: `#/components/schemas/${unwrapped}` };
|
|
50
|
+
}
|
|
51
|
+
if (context) {
|
|
52
|
+
context.warnings.add(`Unresolved type: ${unwrapped}`);
|
|
53
|
+
}
|
|
54
|
+
return { type: "object" };
|
|
55
|
+
}
|
|
56
|
+
function ensureComponent(typeName, registry, components, seen, context) {
|
|
57
|
+
if (components[typeName] || seen.has(typeName)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
seen.add(typeName);
|
|
61
|
+
const dto = registry[typeName];
|
|
62
|
+
if (!dto) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const properties = {};
|
|
66
|
+
const required = [];
|
|
67
|
+
for (const property of dto.properties) {
|
|
68
|
+
const clean = cleanType(property.type);
|
|
69
|
+
const serializedName = serializePropertyName(dto.namingStrategy, property.name);
|
|
70
|
+
properties[serializedName] = resolveSchemaForType(clean, registry, components, seen, context);
|
|
71
|
+
if (!property.nullable) {
|
|
72
|
+
required.push(serializedName);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
components[typeName] = {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties,
|
|
78
|
+
required
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function parseProperty(raw) {
|
|
82
|
+
const parsed = raw.match(/(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^=]+?)(?:\s*=.*)?$/);
|
|
83
|
+
if (!parsed) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const name = parsed[1] ?? "";
|
|
87
|
+
const type = cleanType(parsed[2] ?? "Any");
|
|
88
|
+
return { name, type, nullable: type.endsWith("?") };
|
|
89
|
+
}
|
|
90
|
+
function cleanType(rawType) {
|
|
91
|
+
return rawType.trim();
|
|
92
|
+
}
|
|
93
|
+
function normalizeNullableType(typeName) {
|
|
94
|
+
return typeName.endsWith("?") ? typeName.slice(0, -1) : typeName;
|
|
95
|
+
}
|
|
96
|
+
function unwrapEntity(typeName) {
|
|
97
|
+
const outer = outerType(typeName);
|
|
98
|
+
if (outer === "ResponseEntity") {
|
|
99
|
+
return unwrapEntity(extractSingleGeneric(typeName));
|
|
100
|
+
}
|
|
101
|
+
return typeName;
|
|
102
|
+
}
|
|
103
|
+
function isListLike(typeName) {
|
|
104
|
+
const outer = outerType(typeName);
|
|
105
|
+
return outer === "List" || outer === "MutableList" || outer === "Page";
|
|
106
|
+
}
|
|
107
|
+
function outerType(typeName) {
|
|
108
|
+
const idx = typeName.indexOf("<");
|
|
109
|
+
return idx < 0 ? typeName.replace("?", "") : typeName.slice(0, idx).trim();
|
|
110
|
+
}
|
|
111
|
+
function extractSingleGeneric(typeName) {
|
|
112
|
+
const start = typeName.indexOf("<");
|
|
113
|
+
const end = typeName.lastIndexOf(">");
|
|
114
|
+
if (start < 0 || end < 0 || end <= start + 1) {
|
|
115
|
+
return "Any";
|
|
116
|
+
}
|
|
117
|
+
return typeName.slice(start + 1, end).trim();
|
|
118
|
+
}
|
|
119
|
+
function mapPrimitive(typeName) {
|
|
120
|
+
const withoutNullable = typeName.endsWith("?") ? typeName.slice(0, -1) : typeName;
|
|
121
|
+
switch (withoutNullable) {
|
|
122
|
+
case "String":
|
|
123
|
+
return { type: "string" };
|
|
124
|
+
case "Long":
|
|
125
|
+
return { type: "integer", format: "int64" };
|
|
126
|
+
case "Int":
|
|
127
|
+
return { type: "integer", format: "int32" };
|
|
128
|
+
case "Boolean":
|
|
129
|
+
return { type: "boolean" };
|
|
130
|
+
case "Double":
|
|
131
|
+
return { type: "number", format: "double" };
|
|
132
|
+
case "Float":
|
|
133
|
+
return { type: "number", format: "float" };
|
|
134
|
+
case "Instant":
|
|
135
|
+
return { type: "string", format: "date-time" };
|
|
136
|
+
default:
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function listKotlinFiles(root) {
|
|
141
|
+
const entries = (await readdir(root, { withFileTypes: true }).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
|
|
142
|
+
const files = [];
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
const fullPath = path.join(root, entry.name);
|
|
145
|
+
if (entry.isDirectory()) {
|
|
146
|
+
files.push(...(await listKotlinFiles(fullPath)));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (entry.isFile() && fullPath.endsWith(".kt")) {
|
|
150
|
+
files.push(fullPath);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return files;
|
|
154
|
+
}
|
|
155
|
+
function splitTopLevel(input) {
|
|
156
|
+
const parts = [];
|
|
157
|
+
let current = "";
|
|
158
|
+
let depthParen = 0;
|
|
159
|
+
let depthAngle = 0;
|
|
160
|
+
let depthSquare = 0;
|
|
161
|
+
for (const char of input) {
|
|
162
|
+
if (char === "(")
|
|
163
|
+
depthParen += 1;
|
|
164
|
+
if (char === ")")
|
|
165
|
+
depthParen -= 1;
|
|
166
|
+
if (char === "<")
|
|
167
|
+
depthAngle += 1;
|
|
168
|
+
if (char === ">")
|
|
169
|
+
depthAngle -= 1;
|
|
170
|
+
if (char === "[")
|
|
171
|
+
depthSquare += 1;
|
|
172
|
+
if (char === "]")
|
|
173
|
+
depthSquare -= 1;
|
|
174
|
+
if (char === "," && depthParen === 0 && depthAngle === 0 && depthSquare === 0) {
|
|
175
|
+
parts.push(current);
|
|
176
|
+
current = "";
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
current += char;
|
|
180
|
+
}
|
|
181
|
+
if (current.trim().length > 0) {
|
|
182
|
+
parts.push(current);
|
|
183
|
+
}
|
|
184
|
+
return parts;
|
|
185
|
+
}
|
|
186
|
+
function parseNamingStrategy(annotationBlock) {
|
|
187
|
+
if (!/@JsonNaming\s*\(/.test(annotationBlock)) {
|
|
188
|
+
return "snake_case";
|
|
189
|
+
}
|
|
190
|
+
if (/LowerCamelCaseStrategy/.test(annotationBlock)) {
|
|
191
|
+
return "camelCase";
|
|
192
|
+
}
|
|
193
|
+
if (/SnakeCaseStrategy/.test(annotationBlock)) {
|
|
194
|
+
return "snake_case";
|
|
195
|
+
}
|
|
196
|
+
return "snake_case";
|
|
197
|
+
}
|
|
198
|
+
function serializePropertyName(strategy, name) {
|
|
199
|
+
if (strategy === "camelCase") {
|
|
200
|
+
return name;
|
|
201
|
+
}
|
|
202
|
+
return name
|
|
203
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
204
|
+
.replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
|
|
205
|
+
.toLowerCase();
|
|
206
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface EndpointField {
|
|
2
|
+
name: string;
|
|
3
|
+
type: string;
|
|
4
|
+
required?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface EndpointBody {
|
|
7
|
+
type: string;
|
|
8
|
+
required: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ExtractedEndpoint {
|
|
11
|
+
sourceFile: string;
|
|
12
|
+
operationName: string;
|
|
13
|
+
httpMethod: string;
|
|
14
|
+
fullPath: string;
|
|
15
|
+
returnType: string;
|
|
16
|
+
pathVariables: EndpointField[];
|
|
17
|
+
queryParams: EndpointField[];
|
|
18
|
+
headers: EndpointField[];
|
|
19
|
+
requestBody?: EndpointBody;
|
|
20
|
+
}
|
|
21
|
+
export declare function scanSpringProject(projectPath: string): Promise<ExtractedEndpoint[]>;
|
|
22
|
+
export declare function parseKotlinControllerFile(source: string, sourceFile: string): ExtractedEndpoint[];
|
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export async function scanSpringProject(projectPath) {
|
|
4
|
+
const kotlinRoot = path.join(projectPath, "src", "main", "kotlin");
|
|
5
|
+
const files = (await listKotlinFiles(kotlinRoot)).sort();
|
|
6
|
+
const endpoints = [];
|
|
7
|
+
for (const filePath of files) {
|
|
8
|
+
const source = await readFile(filePath, "utf8");
|
|
9
|
+
endpoints.push(...parseKotlinControllerFile(source, filePath));
|
|
10
|
+
}
|
|
11
|
+
return endpoints.sort((a, b) => {
|
|
12
|
+
const byPath = a.fullPath.localeCompare(b.fullPath);
|
|
13
|
+
if (byPath !== 0)
|
|
14
|
+
return byPath;
|
|
15
|
+
const byMethod = a.httpMethod.localeCompare(b.httpMethod);
|
|
16
|
+
if (byMethod !== 0)
|
|
17
|
+
return byMethod;
|
|
18
|
+
return a.operationName.localeCompare(b.operationName);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function parseKotlinControllerFile(source, sourceFile) {
|
|
22
|
+
if (!/@RestController\b/.test(source)) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const classPrefixes = extractClassPrefixes(source);
|
|
26
|
+
const classRequestMappingArgs = extractClassRequestMappingArgs(source);
|
|
27
|
+
const methods = source.matchAll(/((?:\s*@[A-Za-z_][A-Za-z0-9_]*(?:\((?:[\s\S]*?)\))?\s*)+)fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*:\s*([^\n{=]+)/g);
|
|
28
|
+
const endpoints = [];
|
|
29
|
+
for (const match of methods) {
|
|
30
|
+
const annotationBlock = match[1] ?? "";
|
|
31
|
+
const operationName = (match[2] ?? "").trim();
|
|
32
|
+
const rawParams = match[3] ?? "";
|
|
33
|
+
const returnType = (match[4] ?? "Unit").trim();
|
|
34
|
+
const mappings = resolveMappings(annotationBlock, classRequestMappingArgs);
|
|
35
|
+
if (mappings.length === 0) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const parsedParams = parseFunctionParameters(rawParams);
|
|
39
|
+
for (const classPrefix of classPrefixes) {
|
|
40
|
+
for (const mapping of mappings) {
|
|
41
|
+
endpoints.push({
|
|
42
|
+
sourceFile,
|
|
43
|
+
operationName,
|
|
44
|
+
httpMethod: mapping.method,
|
|
45
|
+
fullPath: applyPathVariableRenames(joinPaths(classPrefix, mapping.path), parsedParams.pathVariableRenames),
|
|
46
|
+
returnType,
|
|
47
|
+
pathVariables: parsedParams.pathVariables,
|
|
48
|
+
queryParams: parsedParams.queryParams,
|
|
49
|
+
headers: parsedParams.headers,
|
|
50
|
+
requestBody: parsedParams.requestBody
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return endpoints;
|
|
56
|
+
}
|
|
57
|
+
async function listKotlinFiles(root) {
|
|
58
|
+
const entries = (await readdir(root, { withFileTypes: true }).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
|
|
59
|
+
const files = [];
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const fullPath = path.join(root, entry.name);
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
files.push(...(await listKotlinFiles(fullPath)));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (entry.isFile() && fullPath.endsWith(".kt")) {
|
|
67
|
+
files.push(fullPath);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return files;
|
|
71
|
+
}
|
|
72
|
+
function extractClassPrefixes(source) {
|
|
73
|
+
const classPrefix = source.match(/@RequestMapping\s*\(([^)]*)\)\s*class\s+/s)?.[1] ?? "";
|
|
74
|
+
const prefixes = extractAnnotationPaths(classPrefix);
|
|
75
|
+
return prefixes.length > 0 ? prefixes.map((prefix) => normalizePath(prefix)) : ["/"];
|
|
76
|
+
}
|
|
77
|
+
function resolveMappings(annotationBlock, classRequestMappingArgs) {
|
|
78
|
+
const mappings = [];
|
|
79
|
+
const annotations = annotationBlock.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)(?:\((?:([\s\S]*?))\))?/g);
|
|
80
|
+
for (const annotation of annotations) {
|
|
81
|
+
const name = annotation[1] ?? "";
|
|
82
|
+
const args = annotation[2];
|
|
83
|
+
if (name === "GetMapping") {
|
|
84
|
+
mappings.push(...extractAnnotationPaths(args).map((pathValue) => ({ method: "GET", path: pathValue })));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (name === "PostMapping") {
|
|
88
|
+
mappings.push(...extractAnnotationPaths(args).map((pathValue) => ({ method: "POST", path: pathValue })));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (name === "PutMapping") {
|
|
92
|
+
mappings.push(...extractAnnotationPaths(args).map((pathValue) => ({ method: "PUT", path: pathValue })));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (name === "DeleteMapping") {
|
|
96
|
+
mappings.push(...extractAnnotationPaths(args).map((pathValue) => ({ method: "DELETE", path: pathValue })));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (name === "PatchMapping") {
|
|
100
|
+
mappings.push(...extractAnnotationPaths(args).map((pathValue) => ({ method: "PATCH", path: pathValue })));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (name === "RequestMapping") {
|
|
104
|
+
const normalizedArgs = normalizeAnnotationArgs(args ?? "");
|
|
105
|
+
if (classRequestMappingArgs.has(normalizedArgs)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const methods = extractRequestMethods(args);
|
|
109
|
+
const paths = extractAnnotationPaths(args);
|
|
110
|
+
for (const method of methods) {
|
|
111
|
+
for (const pathValue of paths) {
|
|
112
|
+
mappings.push({ method, path: pathValue });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return mappings;
|
|
118
|
+
}
|
|
119
|
+
function extractClassRequestMappingArgs(source) {
|
|
120
|
+
const result = new Set();
|
|
121
|
+
const classMatch = source.match(/@RequestMapping\s*\(([^)]*)\)\s*class\s+/s);
|
|
122
|
+
if (classMatch?.[1]) {
|
|
123
|
+
result.add(normalizeAnnotationArgs(classMatch[1]));
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
function normalizeAnnotationArgs(value) {
|
|
128
|
+
return value.replace(/\s+/g, "").trim();
|
|
129
|
+
}
|
|
130
|
+
function extractAnnotationPaths(rawArgs) {
|
|
131
|
+
const args = rawArgs ?? "";
|
|
132
|
+
const arrayMatch = args.match(/(?:value|path)\s*=\s*\[([^\]]*)\]/);
|
|
133
|
+
if (arrayMatch) {
|
|
134
|
+
const values = Array.from(arrayMatch[1].matchAll(/"([^"]*)"/g), (m) => m[1] ?? "");
|
|
135
|
+
return values.length > 0 ? values : [""];
|
|
136
|
+
}
|
|
137
|
+
const keyMatch = args.match(/(?:value|path)\s*=\s*"([^"]*)"/);
|
|
138
|
+
if (keyMatch) {
|
|
139
|
+
return [keyMatch[1] ?? ""];
|
|
140
|
+
}
|
|
141
|
+
const positional = args.match(/^\s*"([^"]*)"\s*$/);
|
|
142
|
+
if (positional) {
|
|
143
|
+
return [positional[1] ?? ""];
|
|
144
|
+
}
|
|
145
|
+
return [""];
|
|
146
|
+
}
|
|
147
|
+
function extractRequestMethods(rawArgs) {
|
|
148
|
+
const args = rawArgs ?? "";
|
|
149
|
+
const arrayMatch = args.match(/method\s*=\s*\[([^\]]*)\]/);
|
|
150
|
+
if (arrayMatch) {
|
|
151
|
+
const methods = Array.from(arrayMatch[1].matchAll(/RequestMethod\.([A-Z]+)/g), (m) => m[1] ?? "GET");
|
|
152
|
+
return methods.length > 0 ? methods : ["GET"];
|
|
153
|
+
}
|
|
154
|
+
const single = args.match(/method\s*=\s*RequestMethod\.([A-Z]+)/);
|
|
155
|
+
if (single) {
|
|
156
|
+
return [single[1] ?? "GET"];
|
|
157
|
+
}
|
|
158
|
+
return ["GET"];
|
|
159
|
+
}
|
|
160
|
+
function parseFunctionParameters(rawParams) {
|
|
161
|
+
const chunks = splitTopLevel(rawParams)
|
|
162
|
+
.map((chunk) => chunk.trim())
|
|
163
|
+
.filter((chunk) => chunk.length > 0);
|
|
164
|
+
const pathVariables = [];
|
|
165
|
+
const queryParams = [];
|
|
166
|
+
const headers = [];
|
|
167
|
+
const pathVariableRenames = [];
|
|
168
|
+
let requestBody;
|
|
169
|
+
for (const chunk of chunks) {
|
|
170
|
+
const parsed = parseAnnotatedParameter(chunk);
|
|
171
|
+
if (!parsed) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (parsed.kind === "path") {
|
|
175
|
+
pathVariables.push({ name: parsed.name, type: parsed.type, required: true });
|
|
176
|
+
if (parsed.sourceName !== parsed.name) {
|
|
177
|
+
pathVariableRenames.push({ from: parsed.sourceName, to: parsed.name });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else if (parsed.kind === "query") {
|
|
181
|
+
queryParams.push({ name: parsed.name, type: parsed.type, required: parsed.required });
|
|
182
|
+
}
|
|
183
|
+
else if (parsed.kind === "header") {
|
|
184
|
+
headers.push({ name: parsed.name, type: parsed.type, required: parsed.required });
|
|
185
|
+
}
|
|
186
|
+
else if (parsed.kind === "body") {
|
|
187
|
+
requestBody = { type: parsed.type, required: parsed.required };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { pathVariables, queryParams, headers, pathVariableRenames, requestBody };
|
|
191
|
+
}
|
|
192
|
+
function parseAnnotatedParameter(chunk) {
|
|
193
|
+
const shape = chunk.match(/@([A-Za-z]+)(?:\(([^)]*)\))?\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^=]+?)(?:\s*=.*)?$/);
|
|
194
|
+
if (!shape) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const annotation = shape[1] ?? "";
|
|
198
|
+
const annotationArgs = shape[2] ?? "";
|
|
199
|
+
const variableName = shape[3] ?? "";
|
|
200
|
+
const type = (shape[4] ?? "Any").trim();
|
|
201
|
+
const alias = parseAlias(annotationArgs);
|
|
202
|
+
const requiredFlag = parseRequiredFlag(annotationArgs);
|
|
203
|
+
const isNullable = type.endsWith("?");
|
|
204
|
+
const hasExplicitAlias = alias !== undefined;
|
|
205
|
+
if (annotation === "PathVariable") {
|
|
206
|
+
return {
|
|
207
|
+
kind: "path",
|
|
208
|
+
name: alias ?? toSnakeCase(variableName),
|
|
209
|
+
sourceName: variableName,
|
|
210
|
+
type
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (annotation === "RequestParam") {
|
|
214
|
+
return {
|
|
215
|
+
kind: "query",
|
|
216
|
+
name: hasExplicitAlias ? alias : toSnakeCase(variableName),
|
|
217
|
+
type,
|
|
218
|
+
required: requiredFlag ?? !isNullable
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (annotation === "RequestHeader") {
|
|
222
|
+
return {
|
|
223
|
+
kind: "header",
|
|
224
|
+
name: hasExplicitAlias ? alias : toSnakeCase(variableName),
|
|
225
|
+
type,
|
|
226
|
+
required: requiredFlag ?? !isNullable
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (annotation === "RequestBody") {
|
|
230
|
+
return {
|
|
231
|
+
kind: "body",
|
|
232
|
+
type,
|
|
233
|
+
required: requiredFlag ?? !isNullable
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
function parseRequiredFlag(annotationArgs) {
|
|
239
|
+
const match = annotationArgs.match(/required\s*=\s*(true|false)/);
|
|
240
|
+
if (!match) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return match[1] === "true";
|
|
244
|
+
}
|
|
245
|
+
function parseAlias(annotationArgs) {
|
|
246
|
+
const keyed = annotationArgs.match(/(?:name|value)\s*=\s*"([^"]+)"/)?.[1];
|
|
247
|
+
if (keyed) {
|
|
248
|
+
return keyed;
|
|
249
|
+
}
|
|
250
|
+
return annotationArgs.match(/"([^"]+)"/)?.[1];
|
|
251
|
+
}
|
|
252
|
+
function splitTopLevel(input) {
|
|
253
|
+
const parts = [];
|
|
254
|
+
let current = "";
|
|
255
|
+
let depthParen = 0;
|
|
256
|
+
let depthAngle = 0;
|
|
257
|
+
let depthSquare = 0;
|
|
258
|
+
for (const char of input) {
|
|
259
|
+
if (char === "(")
|
|
260
|
+
depthParen += 1;
|
|
261
|
+
if (char === ")")
|
|
262
|
+
depthParen -= 1;
|
|
263
|
+
if (char === "<")
|
|
264
|
+
depthAngle += 1;
|
|
265
|
+
if (char === ">")
|
|
266
|
+
depthAngle -= 1;
|
|
267
|
+
if (char === "[")
|
|
268
|
+
depthSquare += 1;
|
|
269
|
+
if (char === "]")
|
|
270
|
+
depthSquare -= 1;
|
|
271
|
+
if (char === "," && depthParen === 0 && depthAngle === 0 && depthSquare === 0) {
|
|
272
|
+
parts.push(current);
|
|
273
|
+
current = "";
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
current += char;
|
|
277
|
+
}
|
|
278
|
+
if (current.trim().length > 0) {
|
|
279
|
+
parts.push(current);
|
|
280
|
+
}
|
|
281
|
+
return parts;
|
|
282
|
+
}
|
|
283
|
+
function joinPaths(prefix, leaf) {
|
|
284
|
+
const normalizedPrefix = normalizePath(prefix);
|
|
285
|
+
const rawLeaf = leaf.trim();
|
|
286
|
+
if (normalizedPrefix === "/" && rawLeaf === "/") {
|
|
287
|
+
return "/";
|
|
288
|
+
}
|
|
289
|
+
if (rawLeaf === "") {
|
|
290
|
+
return normalizedPrefix;
|
|
291
|
+
}
|
|
292
|
+
if (normalizedPrefix === "/") {
|
|
293
|
+
return normalizePath(rawLeaf);
|
|
294
|
+
}
|
|
295
|
+
if (rawLeaf === "/") {
|
|
296
|
+
return normalizedPrefix.endsWith("/") ? normalizedPrefix : `${normalizedPrefix}/`;
|
|
297
|
+
}
|
|
298
|
+
return `${normalizedPrefix.replace(/\/$/, "")}/${rawLeaf.replace(/^\//, "")}`;
|
|
299
|
+
}
|
|
300
|
+
function normalizePath(raw) {
|
|
301
|
+
if (!raw) {
|
|
302
|
+
return "/";
|
|
303
|
+
}
|
|
304
|
+
if (raw === "/") {
|
|
305
|
+
return "/";
|
|
306
|
+
}
|
|
307
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
308
|
+
}
|
|
309
|
+
function applyPathVariableRenames(fullPath, renames) {
|
|
310
|
+
let updated = fullPath;
|
|
311
|
+
for (const rename of renames) {
|
|
312
|
+
updated = updated.replaceAll(`{${rename.from}}`, `{${rename.to}}`);
|
|
313
|
+
}
|
|
314
|
+
return updated;
|
|
315
|
+
}
|
|
316
|
+
function toSnakeCase(value) {
|
|
317
|
+
return value
|
|
318
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
319
|
+
.replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
|
|
320
|
+
.toLowerCase();
|
|
321
|
+
}
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { OpenApiDocument, OpenApiSchema } from "./openapi.js";
|
|
2
|
+
import type { ExtractedEndpoint } from "./scanner.js";
|
|
3
|
+
export interface UiEndpointCard {
|
|
4
|
+
id: string;
|
|
5
|
+
operationName: string;
|
|
6
|
+
method: string;
|
|
7
|
+
path: string;
|
|
8
|
+
controller: string;
|
|
9
|
+
pathVariables: Array<{
|
|
10
|
+
name: string;
|
|
11
|
+
type: string;
|
|
12
|
+
required: boolean;
|
|
13
|
+
}>;
|
|
14
|
+
queryParams: Array<{
|
|
15
|
+
name: string;
|
|
16
|
+
type: string;
|
|
17
|
+
required: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
headers: Array<{
|
|
20
|
+
name: string;
|
|
21
|
+
type: string;
|
|
22
|
+
required: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
requestSchema?: OpenApiSchema;
|
|
25
|
+
responseSchema?: OpenApiSchema;
|
|
26
|
+
curl: string;
|
|
27
|
+
}
|
|
28
|
+
export interface UiModel {
|
|
29
|
+
generatedAt: string;
|
|
30
|
+
endpoints: UiEndpointCard[];
|
|
31
|
+
}
|
|
32
|
+
export declare function buildUiModel(input: {
|
|
33
|
+
endpoints: ExtractedEndpoint[];
|
|
34
|
+
openapi: OpenApiDocument;
|
|
35
|
+
}): UiModel;
|
|
36
|
+
export declare function renderUiHtml(title: string, embeddedModel?: UiModel): string;
|