spring-api-scanner 0.1.0 → 0.2.1

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 CHANGED
@@ -4,6 +4,7 @@ Scan a Spring Boot Kotlin service and generate:
4
4
 
5
5
  - `openapi.json` (OpenAPI 3.0)
6
6
  - A static API catalog UI (`index.html` + `ui-data.json`)
7
+ - Structured warnings (`scan-warnings.json`)
7
8
 
8
9
  No code/dependency changes are required in the target Spring service.
9
10
 
@@ -28,8 +29,24 @@ node dist/index.js /path/to/spring-service --no-serve --output ./api-docs
28
29
 
29
30
  ## CLI
30
31
 
32
+ ```bash
33
+ spring-api-scanner --help
34
+ ```
35
+
31
36
  ```text
32
- spring-api-scanner <projectPath> [--port] [--output] [--serve] [--no-serve] [--title] [--version]
37
+ Usage: spring-api-scanner <projectPath> [options]
38
+
39
+ Scan a Spring Boot Kotlin project and generate OpenAPI documentation and a static UI.
40
+
41
+ Options:
42
+ --serve Start a local server to serve the generated docs
43
+ --no-serve Only generate static files (default)
44
+ --port <number> Port for the server (default: 3000)
45
+ --output <path> Output directory for generated files (default: ./api-docs)
46
+ --title <string> API title for documentation (default: "Spring API Docs")
47
+ --version <string> API version for documentation (default: 1.0.0)
48
+ --strict Exit with non-zero code on unresolved critical issues
49
+ --help Show this help message
33
50
  ```
34
51
 
35
52
  ## What It Extracts
@@ -42,6 +59,18 @@ spring-api-scanner <projectPath> [--port] [--output] [--serve] [--no-serve] [--t
42
59
  - Parameters:
43
60
  - `@PathVariable`, `@RequestParam`, `@RequestHeader`, `@RequestBody`
44
61
  - Return type and DTO schemas from Kotlin `data class`
62
+ - Enum schemas from Kotlin `enum class`
63
+ - Validation hints from common annotations (`@NotNull`, `@Size`)
64
+
65
+ ## UI Features
66
+
67
+ The generated UI includes:
68
+ - **Search** with keyboard shortcut (`/`) - Press `/` to focus the search box
69
+ - **Deep-linking** - Copy and share direct links to endpoints
70
+ - **Filter persistence** - Filter selections are saved to localStorage
71
+ - **Collapsible schemas** - Expand/collapse request/response body schemas
72
+ - **Copy curl** - One-click copy for curl commands
73
+ - **Download** - Export OpenAPI JSON
45
74
 
46
75
  ## Naming Strategy Notes
47
76
 
@@ -58,3 +87,7 @@ npm run build
58
87
  ```
59
88
 
60
89
  Integration + golden-file tests are under `tests/integration.test.ts` and `tests/golden/openapi.sample-service.json`.
90
+
91
+ ## Roadmap
92
+
93
+ See `docs/ROADMAP.md` for phased priorities (`v0.2`, `v0.3`, `v1.0`).
package/dist/args.d.ts CHANGED
@@ -5,5 +5,7 @@ export interface CliOptions {
5
5
  serve: boolean;
6
6
  title: string;
7
7
  version: string;
8
+ strict: boolean;
8
9
  }
10
+ export declare function showHelp(): string;
9
11
  export declare function parseCliArgs(argv: string[]): CliOptions;
package/dist/args.js CHANGED
@@ -2,8 +2,31 @@ const DEFAULT_PORT = 3000;
2
2
  const DEFAULT_OUTPUT = "./api-docs";
3
3
  const DEFAULT_TITLE = "Spring API Docs";
4
4
  const DEFAULT_VERSION = "1.0.0";
5
+ export function showHelp() {
6
+ return `Usage: spring-api-scanner <projectPath> [options]
7
+
8
+ Scan a Spring Boot Kotlin project and generate OpenAPI documentation and a static UI.
9
+
10
+ Options:
11
+ --serve Start a local server to serve the generated docs
12
+ --no-serve Only generate static files (default)
13
+ --port <number> Port for the server (default: ${DEFAULT_PORT})
14
+ --output <path> Output directory for generated files (default: ${DEFAULT_OUTPUT})
15
+ --title <string> API title for documentation (default: "${DEFAULT_TITLE}")
16
+ --version <string> API version for documentation (default: ${DEFAULT_VERSION})
17
+ --strict Exit with non-zero code on unresolved critical issues
18
+ --help Show this help message
19
+
20
+ Examples:
21
+ spring-api-scanner /path/to/project --serve --port 8080
22
+ spring-api-scanner ./my-service --output ./docs --title "My API"
23
+ spring-api-scanner /path/to/project --strict # Fail on warnings`;
24
+ }
5
25
  export function parseCliArgs(argv) {
6
26
  if (argv.length === 0 || argv[0].startsWith("--")) {
27
+ if (argv[0] === "--help") {
28
+ throw new Error(showHelp());
29
+ }
7
30
  throw new Error("A project path is required: spring-api-scanner <projectPath>");
8
31
  }
9
32
  const projectPath = argv[0];
@@ -13,7 +36,8 @@ export function parseCliArgs(argv) {
13
36
  output: DEFAULT_OUTPUT,
14
37
  serve: false,
15
38
  title: DEFAULT_TITLE,
16
- version: DEFAULT_VERSION
39
+ version: DEFAULT_VERSION,
40
+ strict: false
17
41
  };
18
42
  for (let i = 1; i < argv.length; i += 1) {
19
43
  const token = argv[i];
@@ -36,8 +60,13 @@ export function parseCliArgs(argv) {
36
60
  case "--version":
37
61
  options.version = parseStringFlag("--version", argv[++i]);
38
62
  break;
63
+ case "--strict":
64
+ options.strict = true;
65
+ break;
66
+ case "--help":
67
+ throw new Error(showHelp());
39
68
  default:
40
- throw new Error(`Unknown argument: ${token}`);
69
+ throw new Error(`Unknown argument: ${token}\n\n${showHelp()}`);
41
70
  }
42
71
  }
43
72
  return options;
package/dist/index.js CHANGED
@@ -3,20 +3,35 @@ import { stat } from "node:fs/promises";
3
3
  import { createServer } from "node:http";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
- import { parseCliArgs } from "./args.js";
6
+ import { parseCliArgs, showHelp } from "./args.js";
7
7
  import { writePlaceholderArtifacts } from "./output.js";
8
8
  import { scanDataClasses } from "./resolver.js";
9
- import { scanSpringProject } from "./scanner.js";
9
+ import { scanSpringProjectArtifacts } from "./scanner.js";
10
10
  async function main() {
11
- const options = parseCliArgs(process.argv.slice(2));
11
+ const args = process.argv.slice(2);
12
+ // Handle --help before everything else
13
+ if (args.includes("--help") || args.includes("-h")) {
14
+ console.log(showHelp());
15
+ return 0;
16
+ }
17
+ const options = parseCliArgs(args);
12
18
  await validateProjectPath(options.projectPath);
13
- const endpoints = await scanSpringProject(options.projectPath);
19
+ const scan = await scanSpringProjectArtifacts(options.projectPath);
14
20
  const types = await scanDataClasses(options.projectPath);
15
- const output = await writePlaceholderArtifacts(options, endpoints, types);
16
- printSummary(options, endpoints.length, output.warnings);
21
+ const output = await writePlaceholderArtifacts(options, scan.endpoints, types, scan.parserWarnings);
22
+ printSummary(options, scan.endpoints.length, output.warnings);
17
23
  if (options.serve) {
18
24
  await serveOutput(options.output, options.port);
19
25
  }
26
+ // Return non-zero exit code in strict mode if there are warnings
27
+ if (options.strict) {
28
+ const totalWarnings = output.warnings.parser.length + output.warnings.unresolvedTypes.length;
29
+ if (totalWarnings > 0) {
30
+ console.error(`\nExiting with error: ${totalWarnings} warning(s) found in strict mode`);
31
+ return 1;
32
+ }
33
+ }
34
+ return 0;
20
35
  }
21
36
  async function validateProjectPath(projectPath) {
22
37
  const result = await stat(projectPath).catch(() => null);
@@ -25,16 +40,22 @@ async function validateProjectPath(projectPath) {
25
40
  }
26
41
  }
27
42
  function printSummary(options, endpointCount, warnings) {
43
+ const allWarnings = [...warnings.parser, ...warnings.unresolvedTypes];
28
44
  console.log("Spring API Scanner");
29
45
  console.log(`- Project: ${path.resolve(options.projectPath)}`);
30
46
  console.log(`- Output: ${path.resolve(options.output)}`);
31
47
  console.log(`- Endpoints discovered: ${endpointCount}`);
32
48
  console.log("- OpenAPI: openapi.json written");
33
- console.log(`- Warnings: ${warnings.length}`);
34
- for (const warning of warnings.slice(0, 5)) {
49
+ console.log(`- Warnings: ${allWarnings.length}`);
50
+ console.log(` - Parser warnings: ${warnings.parser.length}`);
51
+ console.log(` - Unresolved type warnings: ${warnings.unresolvedTypes.length}`);
52
+ for (const warning of allWarnings.slice(0, 5)) {
35
53
  console.log(` - ${warning}`);
36
54
  }
37
55
  console.log(`- Serve mode: ${options.serve ? "enabled" : "disabled"}`);
56
+ if (options.strict) {
57
+ console.log("- Strict mode: enabled (will exit non-zero on warnings)");
58
+ }
38
59
  }
39
60
  async function serveOutput(outputDir, port) {
40
61
  const root = path.resolve(outputDir);
@@ -60,7 +81,11 @@ async function serveOutput(outputDir, port) {
60
81
  console.log(`Serving docs at http://localhost:${port}`);
61
82
  });
62
83
  }
63
- main().catch((error) => {
84
+ main()
85
+ .then((exitCode) => {
86
+ process.exitCode = exitCode;
87
+ })
88
+ .catch((error) => {
64
89
  const message = error instanceof Error ? error.message : String(error);
65
90
  console.error(`Error: ${message}`);
66
91
  process.exitCode = 1;
package/dist/output.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import type { CliOptions } from "./args.js";
2
2
  import type { KotlinTypeRegistry } from "./resolver.js";
3
3
  import type { ExtractedEndpoint } from "./scanner.js";
4
- export declare function writePlaceholderArtifacts(options: CliOptions, endpoints: ExtractedEndpoint[], types: KotlinTypeRegistry): Promise<{
5
- warnings: string[];
4
+ export interface ScanWarnings {
5
+ parser: string[];
6
+ unresolvedTypes: string[];
7
+ }
8
+ export declare function writePlaceholderArtifacts(options: CliOptions, endpoints: ExtractedEndpoint[], types: KotlinTypeRegistry, parserWarnings?: string[]): Promise<{
9
+ warnings: ScanWarnings;
6
10
  }>;
package/dist/output.js CHANGED
@@ -2,11 +2,12 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { buildOpenApiArtifacts } from "./openapi.js";
4
4
  import { buildUiModel, renderUiHtml } from "./ui.js";
5
- export async function writePlaceholderArtifacts(options, endpoints, types) {
5
+ export async function writePlaceholderArtifacts(options, endpoints, types, parserWarnings = []) {
6
6
  const outputDir = path.resolve(options.output);
7
7
  const openApiPath = path.join(outputDir, "openapi.json");
8
8
  const uiDataPath = path.join(outputDir, "ui-data.json");
9
9
  const indexPath = path.join(outputDir, "index.html");
10
+ const warningsPath = path.join(outputDir, "scan-warnings.json");
10
11
  const artifacts = buildOpenApiArtifacts({
11
12
  title: options.title,
12
13
  version: options.version,
@@ -21,5 +22,11 @@ export async function writePlaceholderArtifacts(options, endpoints, types) {
21
22
  await writeFile(openApiPath, JSON.stringify(artifacts.document, null, 2), "utf8");
22
23
  await writeFile(uiDataPath, JSON.stringify(uiModel, null, 2), "utf8");
23
24
  await writeFile(indexPath, renderUiHtml(options.title, uiModel), "utf8");
24
- return { warnings: artifacts.warnings };
25
+ await writeFile(warningsPath, JSON.stringify({ parser: parserWarnings, unresolvedTypes: artifacts.warnings }, null, 2), "utf8");
26
+ return {
27
+ warnings: {
28
+ parser: parserWarnings,
29
+ unresolvedTypes: artifacts.warnings
30
+ }
31
+ };
25
32
  }
@@ -2,11 +2,18 @@ export interface KotlinProperty {
2
2
  name: string;
3
3
  type: string;
4
4
  nullable: boolean;
5
+ hints?: {
6
+ minLength?: number;
7
+ maxLength?: number;
8
+ minItems?: number;
9
+ maxItems?: number;
10
+ };
5
11
  }
6
12
  export interface KotlinDataClass {
7
13
  name: string;
8
14
  namingStrategy: "snake_case" | "camelCase";
9
15
  properties: KotlinProperty[];
16
+ enumValues?: string[];
10
17
  }
11
18
  export type KotlinTypeRegistry = Record<string, KotlinDataClass>;
12
19
  export interface ResolutionContext {
package/dist/resolver.js CHANGED
@@ -12,11 +12,10 @@ export async function scanDataClasses(projectPath) {
12
12
  }
13
13
  export function parseDataClassesFromSource(source) {
14
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] ?? "";
15
+ for (const declaration of extractDataClassDeclarations(source)) {
16
+ const annotationBlock = declaration.annotationBlock;
17
+ const name = declaration.name;
18
+ const rawProperties = declaration.rawProperties;
20
19
  const properties = splitTopLevel(rawProperties)
21
20
  .map((value) => value.trim())
22
21
  .filter((value) => value.length > 0)
@@ -28,6 +27,24 @@ export function parseDataClassesFromSource(source) {
28
27
  properties
29
28
  };
30
29
  }
30
+ const enumMatches = source.matchAll(/enum\s+class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\}/g);
31
+ for (const match of enumMatches) {
32
+ const name = match[1] ?? "";
33
+ const body = match[2] ?? "";
34
+ const values = body
35
+ .split(",")
36
+ .map((value) => value.replace(/\/\/.*$/g, "").trim())
37
+ .map((value) => value.match(/^([A-Z][A-Z0-9_]*)/)?.[1] ?? "")
38
+ .filter((value) => value.length > 0);
39
+ if (name && values.length > 0) {
40
+ registry[name] = {
41
+ name,
42
+ namingStrategy: "snake_case",
43
+ properties: [],
44
+ enumValues: values
45
+ };
46
+ }
47
+ }
31
48
  return registry;
32
49
  }
33
50
  export function resolveSchemaForType(rawType, registry, components, seen = new Set(), context) {
@@ -40,6 +57,14 @@ export function resolveSchemaForType(rawType, registry, components, seen = new S
40
57
  items: resolveSchemaForType(inner, registry, components, seen, context)
41
58
  };
42
59
  }
60
+ if (isMapLike(unwrapped)) {
61
+ const args = extractGenericArguments(unwrapped);
62
+ const valueType = args[1] ?? "Any";
63
+ return {
64
+ type: "object",
65
+ additionalProperties: resolveSchemaForType(valueType, registry, components, seen, context)
66
+ };
67
+ }
43
68
  const primitive = mapPrimitive(unwrapped);
44
69
  if (primitive) {
45
70
  return primitive;
@@ -62,12 +87,19 @@ function ensureComponent(typeName, registry, components, seen, context) {
62
87
  if (!dto) {
63
88
  return;
64
89
  }
90
+ if (dto.enumValues && dto.enumValues.length > 0) {
91
+ components[typeName] = {
92
+ type: "string",
93
+ enum: dto.enumValues
94
+ };
95
+ return;
96
+ }
65
97
  const properties = {};
66
98
  const required = [];
67
99
  for (const property of dto.properties) {
68
100
  const clean = cleanType(property.type);
69
101
  const serializedName = serializePropertyName(dto.namingStrategy, property.name);
70
- properties[serializedName] = resolveSchemaForType(clean, registry, components, seen, context);
102
+ properties[serializedName] = applyPropertyHints(resolveSchemaForType(clean, registry, components, seen, context), property.hints);
71
103
  if (!property.nullable) {
72
104
  required.push(serializedName);
73
105
  }
@@ -79,13 +111,16 @@ function ensureComponent(typeName, registry, components, seen, context) {
79
111
  };
80
112
  }
81
113
  function parseProperty(raw) {
82
- const parsed = raw.match(/(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^=]+?)(?:\s*=.*)?$/);
114
+ const parsed = raw.match(/([\s\S]*?)(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^=]+?)(?:\s*=.*)?$/);
83
115
  if (!parsed) {
84
116
  return null;
85
117
  }
86
- const name = parsed[1] ?? "";
87
- const type = cleanType(parsed[2] ?? "Any");
88
- return { name, type, nullable: type.endsWith("?") };
118
+ const annotations = parsed[1] ?? "";
119
+ const name = parsed[2] ?? "";
120
+ const type = cleanType(parsed[3] ?? "Any");
121
+ const hints = parseValidationHints(annotations, type);
122
+ const nullable = hints.forceRequired ? false : type.endsWith("?");
123
+ return { name, type, nullable, hints: hints.hints };
89
124
  }
90
125
  function cleanType(rawType) {
91
126
  return rawType.trim();
@@ -104,17 +139,29 @@ function isListLike(typeName) {
104
139
  const outer = outerType(typeName);
105
140
  return outer === "List" || outer === "MutableList" || outer === "Page";
106
141
  }
142
+ function isMapLike(typeName) {
143
+ const outer = outerType(typeName);
144
+ return outer === "Map" || outer === "MutableMap";
145
+ }
107
146
  function outerType(typeName) {
108
147
  const idx = typeName.indexOf("<");
109
148
  return idx < 0 ? typeName.replace("?", "") : typeName.slice(0, idx).trim();
110
149
  }
111
150
  function extractSingleGeneric(typeName) {
151
+ const args = extractGenericArguments(typeName);
152
+ if (args.length === 0) {
153
+ return "Any";
154
+ }
155
+ return args[0] ?? "Any";
156
+ }
157
+ function extractGenericArguments(typeName) {
112
158
  const start = typeName.indexOf("<");
113
159
  const end = typeName.lastIndexOf(">");
114
160
  if (start < 0 || end < 0 || end <= start + 1) {
115
- return "Any";
161
+ return [];
116
162
  }
117
- return typeName.slice(start + 1, end).trim();
163
+ const inner = typeName.slice(start + 1, end).trim();
164
+ return splitTopLevel(inner).map((value) => value.trim()).filter((value) => value.length > 0);
118
165
  }
119
166
  function mapPrimitive(typeName) {
120
167
  const withoutNullable = typeName.endsWith("?") ? typeName.slice(0, -1) : typeName;
@@ -183,6 +230,79 @@ function splitTopLevel(input) {
183
230
  }
184
231
  return parts;
185
232
  }
233
+ function applyPropertyHints(schema, hints) {
234
+ if (!hints || Object.keys(hints).length === 0) {
235
+ return schema;
236
+ }
237
+ if ("$ref" in schema) {
238
+ return schema;
239
+ }
240
+ return { ...schema, ...hints };
241
+ }
242
+ function parseValidationHints(annotationBlock, type) {
243
+ const forceRequired = /@(?:field:)?NotNull\b/.test(annotationBlock);
244
+ const sizeMatch = annotationBlock.match(/@(?:field:)?Size\s*\(([^)]*)\)/);
245
+ if (!sizeMatch) {
246
+ return { forceRequired };
247
+ }
248
+ const args = sizeMatch[1] ?? "";
249
+ const min = parseNumberArg(args, "min");
250
+ const max = parseNumberArg(args, "max");
251
+ const clean = normalizeNullableType(cleanType(type));
252
+ const hints = {};
253
+ if (outerType(clean) === "String") {
254
+ if (min !== undefined)
255
+ hints.minLength = min;
256
+ if (max !== undefined)
257
+ hints.maxLength = max;
258
+ }
259
+ else if (isListLike(clean)) {
260
+ if (min !== undefined)
261
+ hints.minItems = min;
262
+ if (max !== undefined)
263
+ hints.maxItems = max;
264
+ }
265
+ return { forceRequired, hints: Object.keys(hints).length > 0 ? hints : undefined };
266
+ }
267
+ function parseNumberArg(args, key) {
268
+ const match = args.match(new RegExp(`${key}\\s*=\\s*(\\d+)`));
269
+ if (!match?.[1]) {
270
+ return undefined;
271
+ }
272
+ return Number.parseInt(match[1], 10);
273
+ }
274
+ function extractDataClassDeclarations(source) {
275
+ const declarations = [];
276
+ const header = /((?:\s*@[^\n]+\n)*)\s*data\s+class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
277
+ for (const match of source.matchAll(header)) {
278
+ const annotationBlock = match[1] ?? "";
279
+ const name = match[2] ?? "";
280
+ const openParenIndex = (match.index ?? 0) + match[0].length - 1;
281
+ const closeParenIndex = findMatchingParen(source, openParenIndex);
282
+ if (closeParenIndex <= openParenIndex) {
283
+ continue;
284
+ }
285
+ const rawProperties = source.slice(openParenIndex + 1, closeParenIndex);
286
+ declarations.push({ annotationBlock, name, rawProperties });
287
+ }
288
+ return declarations;
289
+ }
290
+ function findMatchingParen(source, openParenIndex) {
291
+ let depth = 0;
292
+ for (let i = openParenIndex; i < source.length; i += 1) {
293
+ const char = source[i];
294
+ if (char === "(") {
295
+ depth += 1;
296
+ }
297
+ else if (char === ")") {
298
+ depth -= 1;
299
+ if (depth === 0) {
300
+ return i;
301
+ }
302
+ }
303
+ }
304
+ return -1;
305
+ }
186
306
  function parseNamingStrategy(annotationBlock) {
187
307
  if (!/@JsonNaming\s*\(/.test(annotationBlock)) {
188
308
  return "snake_case";
package/dist/scanner.d.ts CHANGED
@@ -18,5 +18,14 @@ export interface ExtractedEndpoint {
18
18
  headers: EndpointField[];
19
19
  requestBody?: EndpointBody;
20
20
  }
21
+ export interface ScanArtifacts {
22
+ endpoints: ExtractedEndpoint[];
23
+ parserWarnings: string[];
24
+ }
21
25
  export declare function scanSpringProject(projectPath: string): Promise<ExtractedEndpoint[]>;
26
+ export declare function scanSpringProjectArtifacts(projectPath: string): Promise<ScanArtifacts>;
22
27
  export declare function parseKotlinControllerFile(source: string, sourceFile: string): ExtractedEndpoint[];
28
+ export declare function parseKotlinControllerFileArtifacts(source: string, sourceFile: string): {
29
+ endpoints: ExtractedEndpoint[];
30
+ warnings: string[];
31
+ };
package/dist/scanner.js CHANGED
@@ -1,30 +1,42 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  export async function scanSpringProject(projectPath) {
4
+ return (await scanSpringProjectArtifacts(projectPath)).endpoints;
5
+ }
6
+ export async function scanSpringProjectArtifacts(projectPath) {
4
7
  const kotlinRoot = path.join(projectPath, "src", "main", "kotlin");
5
8
  const files = (await listKotlinFiles(kotlinRoot)).sort();
6
9
  const endpoints = [];
10
+ const parserWarnings = [];
7
11
  for (const filePath of files) {
8
12
  const source = await readFile(filePath, "utf8");
9
- endpoints.push(...parseKotlinControllerFile(source, filePath));
13
+ const artifacts = parseKotlinControllerFileArtifacts(source, filePath);
14
+ endpoints.push(...artifacts.endpoints);
15
+ parserWarnings.push(...artifacts.warnings);
10
16
  }
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
- });
17
+ return {
18
+ endpoints: endpoints.sort((a, b) => {
19
+ const byPath = a.fullPath.localeCompare(b.fullPath);
20
+ if (byPath !== 0)
21
+ return byPath;
22
+ const byMethod = a.httpMethod.localeCompare(b.httpMethod);
23
+ if (byMethod !== 0)
24
+ return byMethod;
25
+ return a.operationName.localeCompare(b.operationName);
26
+ }),
27
+ parserWarnings
28
+ };
20
29
  }
21
30
  export function parseKotlinControllerFile(source, sourceFile) {
31
+ return parseKotlinControllerFileArtifacts(source, sourceFile).endpoints;
32
+ }
33
+ export function parseKotlinControllerFileArtifacts(source, sourceFile) {
22
34
  if (!/@RestController\b/.test(source)) {
23
- return [];
35
+ return { endpoints: [], warnings: [] };
24
36
  }
25
37
  const classPrefixes = extractClassPrefixes(source);
26
38
  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);
39
+ const methods = source.matchAll(/((?:\s*@[A-Za-z_][A-Za-z0-9_]*(?:\((?:[\s\S]*?)\))?\s*)+)(?:suspend\s+)?fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*:\s*([^\n{=]+)/g);
28
40
  const endpoints = [];
29
41
  for (const match of methods) {
30
42
  const annotationBlock = match[1] ?? "";
@@ -52,7 +64,13 @@ export function parseKotlinControllerFile(source, sourceFile) {
52
64
  }
53
65
  }
54
66
  }
55
- return endpoints;
67
+ const hasSpringMapping = /@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping)\b/.test(source);
68
+ const warnings = endpoints.length === 0 && hasSpringMapping
69
+ ? [
70
+ `Parser warning in ${sourceFile}: found Spring mapping annotations but no handler methods were extracted`
71
+ ]
72
+ : [];
73
+ return { endpoints, warnings };
56
74
  }
57
75
  async function listKotlinFiles(root) {
58
76
  const entries = (await readdir(root, { withFileTypes: true }).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
@@ -200,6 +218,7 @@ function parseAnnotatedParameter(chunk) {
200
218
  const type = (shape[4] ?? "Any").trim();
201
219
  const alias = parseAlias(annotationArgs);
202
220
  const requiredFlag = parseRequiredFlag(annotationArgs);
221
+ const hasDefaultValue = /defaultValue\s*=/.test(annotationArgs);
203
222
  const isNullable = type.endsWith("?");
204
223
  const hasExplicitAlias = alias !== undefined;
205
224
  if (annotation === "PathVariable") {
@@ -215,7 +234,7 @@ function parseAnnotatedParameter(chunk) {
215
234
  kind: "query",
216
235
  name: hasExplicitAlias ? alias : toSnakeCase(variableName),
217
236
  type,
218
- required: requiredFlag ?? !isNullable
237
+ required: requiredFlag ?? (!isNullable && !hasDefaultValue)
219
238
  };
220
239
  }
221
240
  if (annotation === "RequestHeader") {
@@ -247,7 +266,10 @@ function parseAlias(annotationArgs) {
247
266
  if (keyed) {
248
267
  return keyed;
249
268
  }
250
- return annotationArgs.match(/"([^"]+)"/)?.[1];
269
+ if (!annotationArgs.includes("=")) {
270
+ return annotationArgs.match(/"([^"]+)"/)?.[1];
271
+ }
272
+ return undefined;
251
273
  }
252
274
  function splitTopLevel(input) {
253
275
  const parts = [];