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/src/scanner.ts ADDED
@@ -0,0 +1,409 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface EndpointField {
5
+ name: string;
6
+ type: string;
7
+ required?: boolean;
8
+ }
9
+
10
+ export interface EndpointBody {
11
+ type: string;
12
+ required: boolean;
13
+ }
14
+
15
+ export interface ExtractedEndpoint {
16
+ sourceFile: string;
17
+ operationName: string;
18
+ httpMethod: string;
19
+ fullPath: string;
20
+ returnType: string;
21
+ pathVariables: EndpointField[];
22
+ queryParams: EndpointField[];
23
+ headers: EndpointField[];
24
+ requestBody?: EndpointBody;
25
+ }
26
+
27
+ export async function scanSpringProject(projectPath: string): Promise<ExtractedEndpoint[]> {
28
+ const kotlinRoot = path.join(projectPath, "src", "main", "kotlin");
29
+ const files = (await listKotlinFiles(kotlinRoot)).sort();
30
+ const endpoints: ExtractedEndpoint[] = [];
31
+
32
+ for (const filePath of files) {
33
+ const source = await readFile(filePath, "utf8");
34
+ endpoints.push(...parseKotlinControllerFile(source, filePath));
35
+ }
36
+
37
+ return endpoints.sort((a, b) => {
38
+ const byPath = a.fullPath.localeCompare(b.fullPath);
39
+ if (byPath !== 0) return byPath;
40
+ const byMethod = a.httpMethod.localeCompare(b.httpMethod);
41
+ if (byMethod !== 0) return byMethod;
42
+ return a.operationName.localeCompare(b.operationName);
43
+ });
44
+ }
45
+
46
+ export function parseKotlinControllerFile(
47
+ source: string,
48
+ sourceFile: string
49
+ ): ExtractedEndpoint[] {
50
+ if (!/@RestController\b/.test(source)) {
51
+ return [];
52
+ }
53
+
54
+ const classPrefixes = extractClassPrefixes(source);
55
+ const classRequestMappingArgs = extractClassRequestMappingArgs(source);
56
+ const methods = source.matchAll(
57
+ /((?:\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
58
+ );
59
+
60
+ const endpoints: ExtractedEndpoint[] = [];
61
+
62
+ for (const match of methods) {
63
+ const annotationBlock = match[1] ?? "";
64
+ const operationName = (match[2] ?? "").trim();
65
+ const rawParams = match[3] ?? "";
66
+ const returnType = (match[4] ?? "Unit").trim();
67
+ const mappings = resolveMappings(annotationBlock, classRequestMappingArgs);
68
+
69
+ if (mappings.length === 0) {
70
+ continue;
71
+ }
72
+
73
+ const parsedParams = parseFunctionParameters(rawParams);
74
+
75
+ for (const classPrefix of classPrefixes) {
76
+ for (const mapping of mappings) {
77
+ endpoints.push({
78
+ sourceFile,
79
+ operationName,
80
+ httpMethod: mapping.method,
81
+ fullPath: applyPathVariableRenames(joinPaths(classPrefix, mapping.path), parsedParams.pathVariableRenames),
82
+ returnType,
83
+ pathVariables: parsedParams.pathVariables,
84
+ queryParams: parsedParams.queryParams,
85
+ headers: parsedParams.headers,
86
+ requestBody: parsedParams.requestBody
87
+ });
88
+ }
89
+ }
90
+ }
91
+
92
+ return endpoints;
93
+ }
94
+
95
+ async function listKotlinFiles(root: string): Promise<string[]> {
96
+ const entries = (await readdir(root, { withFileTypes: true }).catch(() => [])).sort((a, b) =>
97
+ a.name.localeCompare(b.name)
98
+ );
99
+ const files: string[] = [];
100
+
101
+ for (const entry of entries) {
102
+ const fullPath = path.join(root, entry.name);
103
+ if (entry.isDirectory()) {
104
+ files.push(...(await listKotlinFiles(fullPath)));
105
+ continue;
106
+ }
107
+ if (entry.isFile() && fullPath.endsWith(".kt")) {
108
+ files.push(fullPath);
109
+ }
110
+ }
111
+
112
+ return files;
113
+ }
114
+
115
+ function extractClassPrefixes(source: string): string[] {
116
+ const classPrefix = source.match(/@RequestMapping\s*\(([^)]*)\)\s*class\s+/s)?.[1] ?? "";
117
+ const prefixes = extractAnnotationPaths(classPrefix);
118
+ return prefixes.length > 0 ? prefixes.map((prefix) => normalizePath(prefix)) : ["/"];
119
+ }
120
+
121
+ function resolveMappings(
122
+ annotationBlock: string,
123
+ classRequestMappingArgs: Set<string>
124
+ ): Array<{ method: string; path: string }> {
125
+ const mappings: Array<{ method: string; path: string }> = [];
126
+ const annotations = annotationBlock.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)(?:\((?:([\s\S]*?))\))?/g);
127
+
128
+ for (const annotation of annotations) {
129
+ const name = annotation[1] ?? "";
130
+ const args = annotation[2];
131
+
132
+ if (name === "GetMapping") {
133
+ mappings.push(...extractAnnotationPaths(args).map((pathValue) => ({ method: "GET", path: pathValue })));
134
+ continue;
135
+ }
136
+ if (name === "PostMapping") {
137
+ mappings.push(...extractAnnotationPaths(args).map((pathValue) => ({ method: "POST", path: pathValue })));
138
+ continue;
139
+ }
140
+ if (name === "PutMapping") {
141
+ mappings.push(...extractAnnotationPaths(args).map((pathValue) => ({ method: "PUT", path: pathValue })));
142
+ continue;
143
+ }
144
+ if (name === "DeleteMapping") {
145
+ mappings.push(...extractAnnotationPaths(args).map((pathValue) => ({ method: "DELETE", path: pathValue })));
146
+ continue;
147
+ }
148
+ if (name === "PatchMapping") {
149
+ mappings.push(...extractAnnotationPaths(args).map((pathValue) => ({ method: "PATCH", path: pathValue })));
150
+ continue;
151
+ }
152
+ if (name === "RequestMapping") {
153
+ const normalizedArgs = normalizeAnnotationArgs(args ?? "");
154
+ if (classRequestMappingArgs.has(normalizedArgs)) {
155
+ continue;
156
+ }
157
+ const methods = extractRequestMethods(args);
158
+ const paths = extractAnnotationPaths(args);
159
+ for (const method of methods) {
160
+ for (const pathValue of paths) {
161
+ mappings.push({ method, path: pathValue });
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ return mappings;
168
+ }
169
+
170
+ function extractClassRequestMappingArgs(source: string): Set<string> {
171
+ const result = new Set<string>();
172
+ const classMatch = source.match(/@RequestMapping\s*\(([^)]*)\)\s*class\s+/s);
173
+ if (classMatch?.[1]) {
174
+ result.add(normalizeAnnotationArgs(classMatch[1]));
175
+ }
176
+ return result;
177
+ }
178
+
179
+ function normalizeAnnotationArgs(value: string): string {
180
+ return value.replace(/\s+/g, "").trim();
181
+ }
182
+
183
+ function extractAnnotationPaths(rawArgs?: string): string[] {
184
+ const args = rawArgs ?? "";
185
+ const arrayMatch = args.match(/(?:value|path)\s*=\s*\[([^\]]*)\]/);
186
+ if (arrayMatch) {
187
+ const values = Array.from(arrayMatch[1].matchAll(/"([^"]*)"/g), (m) => m[1] ?? "");
188
+ return values.length > 0 ? values : [""];
189
+ }
190
+
191
+ const keyMatch = args.match(/(?:value|path)\s*=\s*"([^"]*)"/);
192
+ if (keyMatch) {
193
+ return [keyMatch[1] ?? ""];
194
+ }
195
+
196
+ const positional = args.match(/^\s*"([^"]*)"\s*$/);
197
+ if (positional) {
198
+ return [positional[1] ?? ""];
199
+ }
200
+
201
+ return [""];
202
+ }
203
+
204
+ function extractRequestMethods(rawArgs?: string): string[] {
205
+ const args = rawArgs ?? "";
206
+ const arrayMatch = args.match(/method\s*=\s*\[([^\]]*)\]/);
207
+ if (arrayMatch) {
208
+ const methods = Array.from(arrayMatch[1].matchAll(/RequestMethod\.([A-Z]+)/g), (m) => m[1] ?? "GET");
209
+ return methods.length > 0 ? methods : ["GET"];
210
+ }
211
+
212
+ const single = args.match(/method\s*=\s*RequestMethod\.([A-Z]+)/);
213
+ if (single) {
214
+ return [single[1] ?? "GET"];
215
+ }
216
+
217
+ return ["GET"];
218
+ }
219
+
220
+ function parseFunctionParameters(rawParams: string): {
221
+ pathVariables: EndpointField[];
222
+ queryParams: EndpointField[];
223
+ headers: EndpointField[];
224
+ pathVariableRenames: Array<{ from: string; to: string }>;
225
+ requestBody?: EndpointBody;
226
+ } {
227
+ const chunks = splitTopLevel(rawParams)
228
+ .map((chunk) => chunk.trim())
229
+ .filter((chunk) => chunk.length > 0);
230
+ const pathVariables: EndpointField[] = [];
231
+ const queryParams: EndpointField[] = [];
232
+ const headers: EndpointField[] = [];
233
+ const pathVariableRenames: Array<{ from: string; to: string }> = [];
234
+ let requestBody: EndpointBody | undefined;
235
+
236
+ for (const chunk of chunks) {
237
+ const parsed = parseAnnotatedParameter(chunk);
238
+ if (!parsed) {
239
+ continue;
240
+ }
241
+
242
+ if (parsed.kind === "path") {
243
+ pathVariables.push({ name: parsed.name, type: parsed.type, required: true });
244
+ if (parsed.sourceName !== parsed.name) {
245
+ pathVariableRenames.push({ from: parsed.sourceName, to: parsed.name });
246
+ }
247
+ } else if (parsed.kind === "query") {
248
+ queryParams.push({ name: parsed.name, type: parsed.type, required: parsed.required });
249
+ } else if (parsed.kind === "header") {
250
+ headers.push({ name: parsed.name, type: parsed.type, required: parsed.required });
251
+ } else if (parsed.kind === "body") {
252
+ requestBody = { type: parsed.type, required: parsed.required };
253
+ }
254
+ }
255
+
256
+ return { pathVariables, queryParams, headers, pathVariableRenames, requestBody };
257
+ }
258
+
259
+ function parseAnnotatedParameter(
260
+ chunk: string
261
+ ):
262
+ | { kind: "path"; name: string; sourceName: string; type: string }
263
+ | { kind: "query"; name: string; type: string; required: boolean }
264
+ | { kind: "header"; name: string; type: string; required: boolean }
265
+ | { kind: "body"; type: string; required: boolean }
266
+ | null {
267
+ const shape = chunk.match(/@([A-Za-z]+)(?:\(([^)]*)\))?\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^=]+?)(?:\s*=.*)?$/);
268
+ if (!shape) {
269
+ return null;
270
+ }
271
+
272
+ const annotation = shape[1] ?? "";
273
+ const annotationArgs = shape[2] ?? "";
274
+ const variableName = shape[3] ?? "";
275
+ const type = (shape[4] ?? "Any").trim();
276
+ const alias = parseAlias(annotationArgs);
277
+ const requiredFlag = parseRequiredFlag(annotationArgs);
278
+ const isNullable = type.endsWith("?");
279
+ const hasExplicitAlias = alias !== undefined;
280
+
281
+ if (annotation === "PathVariable") {
282
+ return {
283
+ kind: "path",
284
+ name: alias ?? toSnakeCase(variableName),
285
+ sourceName: variableName,
286
+ type
287
+ };
288
+ }
289
+ if (annotation === "RequestParam") {
290
+ return {
291
+ kind: "query",
292
+ name: hasExplicitAlias ? (alias as string) : toSnakeCase(variableName),
293
+ type,
294
+ required: requiredFlag ?? !isNullable
295
+ };
296
+ }
297
+ if (annotation === "RequestHeader") {
298
+ return {
299
+ kind: "header",
300
+ name: hasExplicitAlias ? (alias as string) : toSnakeCase(variableName),
301
+ type,
302
+ required: requiredFlag ?? !isNullable
303
+ };
304
+ }
305
+ if (annotation === "RequestBody") {
306
+ return {
307
+ kind: "body",
308
+ type,
309
+ required: requiredFlag ?? !isNullable
310
+ };
311
+ }
312
+
313
+ return null;
314
+ }
315
+
316
+ function parseRequiredFlag(annotationArgs: string): boolean | undefined {
317
+ const match = annotationArgs.match(/required\s*=\s*(true|false)/);
318
+ if (!match) {
319
+ return undefined;
320
+ }
321
+ return match[1] === "true";
322
+ }
323
+
324
+ function parseAlias(annotationArgs: string): string | undefined {
325
+ const keyed = annotationArgs.match(/(?:name|value)\s*=\s*"([^"]+)"/)?.[1];
326
+ if (keyed) {
327
+ return keyed;
328
+ }
329
+ return annotationArgs.match(/"([^"]+)"/)?.[1];
330
+ }
331
+
332
+ function splitTopLevel(input: string): string[] {
333
+ const parts: string[] = [];
334
+ let current = "";
335
+ let depthParen = 0;
336
+ let depthAngle = 0;
337
+ let depthSquare = 0;
338
+
339
+ for (const char of input) {
340
+ if (char === "(") depthParen += 1;
341
+ if (char === ")") depthParen -= 1;
342
+ if (char === "<") depthAngle += 1;
343
+ if (char === ">") depthAngle -= 1;
344
+ if (char === "[") depthSquare += 1;
345
+ if (char === "]") depthSquare -= 1;
346
+
347
+ if (char === "," && depthParen === 0 && depthAngle === 0 && depthSquare === 0) {
348
+ parts.push(current);
349
+ current = "";
350
+ continue;
351
+ }
352
+
353
+ current += char;
354
+ }
355
+
356
+ if (current.trim().length > 0) {
357
+ parts.push(current);
358
+ }
359
+
360
+ return parts;
361
+ }
362
+
363
+ function joinPaths(prefix: string, leaf: string): string {
364
+ const normalizedPrefix = normalizePath(prefix);
365
+ const rawLeaf = leaf.trim();
366
+
367
+ if (normalizedPrefix === "/" && rawLeaf === "/") {
368
+ return "/";
369
+ }
370
+ if (rawLeaf === "") {
371
+ return normalizedPrefix;
372
+ }
373
+ if (normalizedPrefix === "/") {
374
+ return normalizePath(rawLeaf);
375
+ }
376
+ if (rawLeaf === "/") {
377
+ return normalizedPrefix.endsWith("/") ? normalizedPrefix : `${normalizedPrefix}/`;
378
+ }
379
+
380
+ return `${normalizedPrefix.replace(/\/$/, "")}/${rawLeaf.replace(/^\//, "")}`;
381
+ }
382
+
383
+ function normalizePath(raw: string): string {
384
+ if (!raw) {
385
+ return "/";
386
+ }
387
+ if (raw === "/") {
388
+ return "/";
389
+ }
390
+ return raw.startsWith("/") ? raw : `/${raw}`;
391
+ }
392
+
393
+ function applyPathVariableRenames(
394
+ fullPath: string,
395
+ renames: Array<{ from: string; to: string }>
396
+ ): string {
397
+ let updated = fullPath;
398
+ for (const rename of renames) {
399
+ updated = updated.replaceAll(`{${rename.from}}`, `{${rename.to}}`);
400
+ }
401
+ return updated;
402
+ }
403
+
404
+ function toSnakeCase(value: string): string {
405
+ return value
406
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
407
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
408
+ .toLowerCase();
409
+ }