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 +34 -1
- package/dist/args.d.ts +2 -0
- package/dist/args.js +31 -2
- package/dist/index.js +34 -9
- package/dist/output.d.ts +6 -2
- package/dist/output.js +9 -2
- package/dist/resolver.d.ts +7 -0
- package/dist/resolver.js +132 -12
- package/dist/scanner.d.ts +9 -0
- package/dist/scanner.js +37 -15
- package/dist/ui.js +111 -10
- package/package.json +7 -2
- package/PLAN.md +0 -142
- package/docs/plans/2026-02-07-complete-remaining-tasks.md +0 -61
- package/spring-api-scanner-0.1.0.tgz +0 -0
- package/src/args.ts +0 -76
- package/src/index.ts +0 -81
- package/src/openapi.ts +0 -191
- package/src/output.ts +0 -36
- package/src/resolver.ts +0 -274
- package/src/scanner.ts +0 -409
- package/src/ui.ts +0 -454
- package/tests/args.test.d.ts +0 -1
- package/tests/args.test.ts +0 -45
- package/tests/fixtures/sample-service/src/main/kotlin/demo/UserController.kt +0 -34
- package/tests/fixtures/sample-service/src/main/kotlin/demo/UserDtos.kt +0 -22
- package/tests/golden/openapi.sample-service.json +0 -164
- package/tests/integration.test.ts +0 -98
- package/tests/openapi.test.d.ts +0 -1
- package/tests/openapi.test.ts +0 -127
- package/tests/output.test.ts +0 -55
- package/tests/resolver.test.ts +0 -98
- package/tests/scanner.test.ts +0 -138
- package/tests/ui.test.ts +0 -85
- package/tsconfig.json +0 -15
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> [
|
|
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
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 {
|
|
9
|
+
import { scanSpringProjectArtifacts } from "./scanner.js";
|
|
10
10
|
async function main() {
|
|
11
|
-
const
|
|
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
|
|
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: ${
|
|
34
|
-
|
|
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()
|
|
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
|
|
5
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/resolver.d.ts
CHANGED
|
@@ -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
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
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
|
|
87
|
-
const
|
|
88
|
-
|
|
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
|
|
161
|
+
return [];
|
|
116
162
|
}
|
|
117
|
-
|
|
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
|
-
|
|
13
|
+
const artifacts = parseKotlinControllerFileArtifacts(source, filePath);
|
|
14
|
+
endpoints.push(...artifacts.endpoints);
|
|
15
|
+
parserWarnings.push(...artifacts.warnings);
|
|
10
16
|
}
|
|
11
|
-
return
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
+
if (!annotationArgs.includes("=")) {
|
|
270
|
+
return annotationArgs.match(/"([^"]+)"/)?.[1];
|
|
271
|
+
}
|
|
272
|
+
return undefined;
|
|
251
273
|
}
|
|
252
274
|
function splitTopLevel(input) {
|
|
253
275
|
const parts = [];
|