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 ADDED
@@ -0,0 +1,142 @@
1
+ # Spring API Scanner Implementation Plan
2
+
3
+ ## Goal
4
+ Build a standalone CLI tool that scans a Spring Boot Kotlin codebase and outputs:
5
+ - OpenAPI 3.0 JSON (`openapi.json`)
6
+ - A clean, minimal, detailed UI for human-readable API browsing
7
+
8
+ No dependency changes are required in the target Spring service.
9
+
10
+ ## One-Shot Usage
11
+ - Serve UI + OpenAPI:
12
+ - `npx spring-api-scanner /path/to/service --serve`
13
+ - Export static docs only:
14
+ - `npx spring-api-scanner /path/to/service --no-serve --output ./api-docs`
15
+
16
+ ## Scope
17
+ Extract and visualize per endpoint:
18
+ - Endpoint name (controller method name)
19
+ - HTTP method + full path
20
+ - Path variables
21
+ - Query params
22
+ - Request headers
23
+ - Request body schema
24
+ - Response body schema
25
+
26
+ ## Architecture
27
+ - **CLI**: Node.js + TypeScript
28
+ - **Kotlin parsing**: `tree-sitter-kotlin`
29
+ - **Spec generation**: OpenAPI 3.0 builder
30
+ - **UI**: static SPA (search/filter/expandable details)
31
+
32
+ Pipeline:
33
+ 1. Scan Kotlin files
34
+ 2. Parse Spring annotations and signatures
35
+ 3. Resolve DTO schemas
36
+ 4. Generate OpenAPI JSON
37
+ 5. Render UI data model
38
+ 6. Serve or export static output
39
+
40
+ ## Tasks
41
+
42
+ ### 1) CLI Skeleton
43
+ - Implement command:
44
+ - `spring-api-scanner <projectPath> [--port] [--output] [--serve] [--title] [--version]`
45
+ - Validate input path and print scan summary.
46
+
47
+ Acceptance:
48
+ - CLI runs and produces placeholder output.
49
+
50
+ ### 2) Endpoint Extraction (Spring Kotlin)
51
+ - Scan `src/main/kotlin/**/*.kt`.
52
+ - Parse:
53
+ - `@RestController`
54
+ - class-level `@RequestMapping`
55
+ - method mappings: `@GetMapping`, `@PostMapping`, `@PutMapping`, `@DeleteMapping`, `@PatchMapping`, `@RequestMapping(method=...)`
56
+ - Extract:
57
+ - method, path, operation name
58
+ - `@PathVariable`, `@RequestParam`, `@RequestHeader`, `@RequestBody`
59
+ - return type
60
+
61
+ Acceptance:
62
+ - Real fixture project produces accurate endpoint list.
63
+
64
+ ### 3) DTO & Type Resolver
65
+ - Parse Kotlin `data class` definitions.
66
+ - Resolve nested DTOs recursively.
67
+ - Nullability (`?`) => optional fields.
68
+ - Unwrap wrappers (`ResponseEntity<T>`, `List<T>`, `Page<T>`).
69
+ - Type mapping examples:
70
+ - `String -> string`
71
+ - `Long -> integer (int64)`
72
+ - `Int -> integer (int32)`
73
+ - `Boolean -> boolean`
74
+ - `Double -> number (double)`
75
+ - `Instant -> string (date-time)`
76
+
77
+ Acceptance:
78
+ - Request/response schemas include concrete fields where resolvable.
79
+
80
+ ### 4) OpenAPI 3.0 Generator
81
+ - Build `openapi.json` with:
82
+ - `openapi`, `info`, `paths`, `components.schemas`
83
+ - For each operation include:
84
+ - `operationId`, `tags`, `parameters`, `requestBody`, `responses`
85
+ - Emit header params as `in: header`.
86
+
87
+ Acceptance:
88
+ - Spec validates in Swagger Editor.
89
+
90
+ ### 5) Human-Readable UI
91
+ - Clean minimal detailed UI with:
92
+ - Search by endpoint/path
93
+ - Filter by method/controller
94
+ - Expandable endpoint cards
95
+ - Sections: path vars, query params, headers, request body, response body
96
+ - Download OpenAPI JSON
97
+ - Copy curl
98
+ - Responsive for desktop/mobile.
99
+
100
+ Acceptance:
101
+ - No missing endpoint metadata for supported annotation patterns.
102
+
103
+ ### 6) One-Shot Orchestration
104
+ - End-to-end command flow:
105
+ - scan -> resolve -> generate -> render -> serve/export
106
+ - Modes:
107
+ - `--serve`: host UI + `/openapi.json`
108
+ - `--no-serve --output`: write static output
109
+
110
+ Acceptance:
111
+ - One command consistently produces both machine + human outputs.
112
+
113
+ ### 7) Edge Cases & Robustness
114
+ - Support annotation forms:
115
+ - positional value, `value=`, `path=`, arrays
116
+ - Handle unresolved DTOs as warnings (no crash).
117
+ - Skip non-controller files safely.
118
+
119
+ Acceptance:
120
+ - Tool is stable on mixed real-world projects.
121
+
122
+ ### 8) Testing Strategy
123
+ - Unit tests:
124
+ - parser
125
+ - type mapping
126
+ - OpenAPI generation
127
+ - Integration tests with realistic Kotlin fixtures.
128
+ - Golden-file test for deterministic `openapi.json` output.
129
+
130
+ Acceptance:
131
+ - CI-friendly deterministic test suite passes.
132
+
133
+ ## Deliverables
134
+ - `spring-api-scanner` CLI
135
+ - Generated `openapi.json`
136
+ - Generated/served API catalog UI
137
+ - README with quickstart and examples
138
+
139
+ ## Definition of Done
140
+ - One command produces valid OpenAPI JSON and complete UI docs.
141
+ - Endpoint cards include: name, path vars, params, headers, request/response bodies.
142
+ - Works without any dependency/code changes in the target Spring service.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # spring-api-scanner
2
+
3
+ Scan a Spring Boot Kotlin service and generate:
4
+
5
+ - `openapi.json` (OpenAPI 3.0)
6
+ - A static API catalog UI (`index.html` + `ui-data.json`)
7
+
8
+ No code/dependency changes are required in the target Spring service.
9
+
10
+ ## Quickstart
11
+
12
+ ```bash
13
+ npm install
14
+ npm run build
15
+ ```
16
+
17
+ Serve generated docs:
18
+
19
+ ```bash
20
+ node dist/index.js /path/to/spring-service --serve --port 3000
21
+ ```
22
+
23
+ Export static docs only:
24
+
25
+ ```bash
26
+ node dist/index.js /path/to/spring-service --no-serve --output ./api-docs
27
+ ```
28
+
29
+ ## CLI
30
+
31
+ ```text
32
+ spring-api-scanner <projectPath> [--port] [--output] [--serve] [--no-serve] [--title] [--version]
33
+ ```
34
+
35
+ ## What It Extracts
36
+
37
+ - `@RestController`
38
+ - Class-level `@RequestMapping`
39
+ - Method mappings:
40
+ - `@GetMapping`, `@PostMapping`, `@PutMapping`, `@DeleteMapping`, `@PatchMapping`
41
+ - `@RequestMapping(method = ...)`
42
+ - Parameters:
43
+ - `@PathVariable`, `@RequestParam`, `@RequestHeader`, `@RequestBody`
44
+ - Return type and DTO schemas from Kotlin `data class`
45
+
46
+ ## Naming Strategy Notes
47
+
48
+ Schema field names follow these rules:
49
+
50
+ - Default DTO naming: `snake_case`
51
+ - If class has `@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy::class)`, fields stay `camelCase`
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ npm test
57
+ npm run build
58
+ ```
59
+
60
+ Integration + golden-file tests are under `tests/integration.test.ts` and `tests/golden/openapi.sample-service.json`.
package/dist/args.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export interface CliOptions {
2
+ projectPath: string;
3
+ port: number;
4
+ output: string;
5
+ serve: boolean;
6
+ title: string;
7
+ version: string;
8
+ }
9
+ export declare function parseCliArgs(argv: string[]): CliOptions;
package/dist/args.js ADDED
@@ -0,0 +1,57 @@
1
+ const DEFAULT_PORT = 3000;
2
+ const DEFAULT_OUTPUT = "./api-docs";
3
+ const DEFAULT_TITLE = "Spring API Docs";
4
+ const DEFAULT_VERSION = "1.0.0";
5
+ export function parseCliArgs(argv) {
6
+ if (argv.length === 0 || argv[0].startsWith("--")) {
7
+ throw new Error("A project path is required: spring-api-scanner <projectPath>");
8
+ }
9
+ const projectPath = argv[0];
10
+ const options = {
11
+ projectPath,
12
+ port: DEFAULT_PORT,
13
+ output: DEFAULT_OUTPUT,
14
+ serve: false,
15
+ title: DEFAULT_TITLE,
16
+ version: DEFAULT_VERSION
17
+ };
18
+ for (let i = 1; i < argv.length; i += 1) {
19
+ const token = argv[i];
20
+ switch (token) {
21
+ case "--serve":
22
+ options.serve = true;
23
+ break;
24
+ case "--no-serve":
25
+ options.serve = false;
26
+ break;
27
+ case "--port":
28
+ options.port = parseIntegerFlag("--port", argv[++i]);
29
+ break;
30
+ case "--output":
31
+ options.output = parseStringFlag("--output", argv[++i]);
32
+ break;
33
+ case "--title":
34
+ options.title = parseStringFlag("--title", argv[++i]);
35
+ break;
36
+ case "--version":
37
+ options.version = parseStringFlag("--version", argv[++i]);
38
+ break;
39
+ default:
40
+ throw new Error(`Unknown argument: ${token}`);
41
+ }
42
+ }
43
+ return options;
44
+ }
45
+ function parseStringFlag(flagName, rawValue) {
46
+ if (!rawValue || rawValue.startsWith("--")) {
47
+ throw new Error(`Missing value for ${flagName}`);
48
+ }
49
+ return rawValue;
50
+ }
51
+ function parseIntegerFlag(flagName, rawValue) {
52
+ const parsed = Number.parseInt(parseStringFlag(flagName, rawValue), 10);
53
+ if (!Number.isFinite(parsed) || parsed <= 0) {
54
+ throw new Error(`${flagName} must be a positive integer`);
55
+ }
56
+ return parsed;
57
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { stat } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+ import { readFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { parseCliArgs } from "./args.js";
7
+ import { writePlaceholderArtifacts } from "./output.js";
8
+ import { scanDataClasses } from "./resolver.js";
9
+ import { scanSpringProject } from "./scanner.js";
10
+ async function main() {
11
+ const options = parseCliArgs(process.argv.slice(2));
12
+ await validateProjectPath(options.projectPath);
13
+ const endpoints = await scanSpringProject(options.projectPath);
14
+ const types = await scanDataClasses(options.projectPath);
15
+ const output = await writePlaceholderArtifacts(options, endpoints, types);
16
+ printSummary(options, endpoints.length, output.warnings);
17
+ if (options.serve) {
18
+ await serveOutput(options.output, options.port);
19
+ }
20
+ }
21
+ async function validateProjectPath(projectPath) {
22
+ const result = await stat(projectPath).catch(() => null);
23
+ if (!result || !result.isDirectory()) {
24
+ throw new Error(`Project path not found or not a directory: ${projectPath}`);
25
+ }
26
+ }
27
+ function printSummary(options, endpointCount, warnings) {
28
+ console.log("Spring API Scanner");
29
+ console.log(`- Project: ${path.resolve(options.projectPath)}`);
30
+ console.log(`- Output: ${path.resolve(options.output)}`);
31
+ console.log(`- Endpoints discovered: ${endpointCount}`);
32
+ console.log("- OpenAPI: openapi.json written");
33
+ console.log(`- Warnings: ${warnings.length}`);
34
+ for (const warning of warnings.slice(0, 5)) {
35
+ console.log(` - ${warning}`);
36
+ }
37
+ console.log(`- Serve mode: ${options.serve ? "enabled" : "disabled"}`);
38
+ }
39
+ async function serveOutput(outputDir, port) {
40
+ const root = path.resolve(outputDir);
41
+ const server = createServer(async (req, res) => {
42
+ const pathname = req.url === "/openapi.json"
43
+ ? "openapi.json"
44
+ : req.url === "/ui-data.json"
45
+ ? "ui-data.json"
46
+ : "index.html";
47
+ const fullPath = path.join(root, pathname);
48
+ try {
49
+ const contents = await readFile(fullPath);
50
+ const contentType = pathname === "index.html" ? "text/html; charset=utf-8" : "application/json; charset=utf-8";
51
+ res.writeHead(200, { "content-type": contentType });
52
+ res.end(contents);
53
+ }
54
+ catch {
55
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
56
+ res.end("Not found");
57
+ }
58
+ });
59
+ server.listen(port, () => {
60
+ console.log(`Serving docs at http://localhost:${port}`);
61
+ });
62
+ }
63
+ main().catch((error) => {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ console.error(`Error: ${message}`);
66
+ process.exitCode = 1;
67
+ });
@@ -0,0 +1,67 @@
1
+ import type { ExtractedEndpoint } from "./scanner.js";
2
+ import { type KotlinTypeRegistry } from "./resolver.js";
3
+ export interface OpenApiSchema {
4
+ type?: string;
5
+ format?: string;
6
+ items?: OpenApiSchema;
7
+ properties?: Record<string, OpenApiSchema>;
8
+ required?: string[];
9
+ $ref?: string;
10
+ }
11
+ export interface OpenApiParameter {
12
+ in: "path" | "query" | "header";
13
+ name: string;
14
+ required: boolean;
15
+ schema: OpenApiSchema;
16
+ }
17
+ export interface OpenApiResponse {
18
+ description: string;
19
+ content: {
20
+ "application/json": {
21
+ schema: OpenApiSchema;
22
+ };
23
+ };
24
+ }
25
+ export interface OpenApiOperation {
26
+ operationId: string;
27
+ tags: string[];
28
+ parameters: OpenApiParameter[];
29
+ requestBody?: {
30
+ required: boolean;
31
+ content: {
32
+ "application/json": {
33
+ schema: OpenApiSchema;
34
+ };
35
+ };
36
+ };
37
+ responses: Record<string, OpenApiResponse | {
38
+ description: string;
39
+ }>;
40
+ }
41
+ export interface OpenApiDocument {
42
+ openapi: string;
43
+ info: {
44
+ title: string;
45
+ version: string;
46
+ };
47
+ paths: Record<string, Record<string, OpenApiOperation>>;
48
+ components: {
49
+ schemas: Record<string, OpenApiSchema>;
50
+ };
51
+ }
52
+ export interface OpenApiArtifacts {
53
+ document: OpenApiDocument;
54
+ warnings: string[];
55
+ }
56
+ export declare function buildOpenApiDocument(input: {
57
+ title: string;
58
+ version: string;
59
+ endpoints: ExtractedEndpoint[];
60
+ types: KotlinTypeRegistry;
61
+ }): OpenApiDocument;
62
+ export declare function buildOpenApiArtifacts(input: {
63
+ title: string;
64
+ version: string;
65
+ endpoints: ExtractedEndpoint[];
66
+ types: KotlinTypeRegistry;
67
+ }): OpenApiArtifacts;
@@ -0,0 +1,94 @@
1
+ import { resolveSchemaForType } from "./resolver.js";
2
+ export function buildOpenApiDocument(input) {
3
+ return buildOpenApiArtifacts(input).document;
4
+ }
5
+ export function buildOpenApiArtifacts(input) {
6
+ const schemas = {};
7
+ const paths = {};
8
+ const context = { warnings: new Set() };
9
+ for (const endpoint of input.endpoints) {
10
+ const method = endpoint.httpMethod.toLowerCase();
11
+ const parameters = [
12
+ ...endpoint.pathVariables.map((item) => ({
13
+ in: "path",
14
+ name: item.name,
15
+ required: true,
16
+ schema: resolveSchemaForType(item.type, input.types, schemas, new Set(), context)
17
+ })),
18
+ ...endpoint.queryParams.map((item) => ({
19
+ in: "query",
20
+ name: item.name,
21
+ required: item.required ?? false,
22
+ schema: resolveSchemaForType(item.type, input.types, schemas, new Set(), context)
23
+ })),
24
+ ...endpoint.headers.map((item) => ({
25
+ in: "header",
26
+ name: item.name,
27
+ required: item.required ?? false,
28
+ schema: resolveSchemaForType(item.type, input.types, schemas, new Set(), context)
29
+ }))
30
+ ];
31
+ const requestBody = endpoint.requestBody
32
+ ? {
33
+ required: endpoint.requestBody.required,
34
+ content: {
35
+ "application/json": {
36
+ schema: resolveSchemaForType(endpoint.requestBody.type, input.types, schemas, new Set(), context)
37
+ }
38
+ }
39
+ }
40
+ : undefined;
41
+ const response = buildOperationResponse(endpoint, input.types, schemas, context);
42
+ const operation = {
43
+ operationId: endpoint.operationName,
44
+ tags: [guessTag(endpoint.sourceFile)],
45
+ parameters,
46
+ ...(requestBody ? { requestBody } : {}),
47
+ responses: response
48
+ };
49
+ if (!paths[endpoint.fullPath]) {
50
+ paths[endpoint.fullPath] = {};
51
+ }
52
+ paths[endpoint.fullPath][method] = operation;
53
+ }
54
+ return {
55
+ document: {
56
+ openapi: "3.0.3",
57
+ info: {
58
+ title: input.title,
59
+ version: input.version
60
+ },
61
+ paths,
62
+ components: {
63
+ schemas
64
+ }
65
+ },
66
+ warnings: [...context.warnings]
67
+ };
68
+ }
69
+ function guessTag(sourceFile) {
70
+ const file = sourceFile.replaceAll("\\", "/").split("/").at(-1) ?? sourceFile;
71
+ return file.endsWith(".kt") ? file.slice(0, -3) : file;
72
+ }
73
+ function buildOperationResponse(endpoint, types, schemas, context) {
74
+ const normalizedReturn = endpoint.returnType.trim();
75
+ const isNoContent = normalizedReturn === "Unit" || normalizedReturn === "Void";
76
+ if (isNoContent) {
77
+ return {
78
+ "204": {
79
+ description: "No Content"
80
+ }
81
+ };
82
+ }
83
+ const statusCode = endpoint.httpMethod === "POST" ? "201" : "200";
84
+ return {
85
+ [statusCode]: {
86
+ description: statusCode === "201" ? "Created" : "OK",
87
+ content: {
88
+ "application/json": {
89
+ schema: resolveSchemaForType(endpoint.returnType, types, schemas, new Set(), context)
90
+ }
91
+ }
92
+ }
93
+ };
94
+ }
@@ -0,0 +1,6 @@
1
+ import type { CliOptions } from "./args.js";
2
+ import type { KotlinTypeRegistry } from "./resolver.js";
3
+ import type { ExtractedEndpoint } from "./scanner.js";
4
+ export declare function writePlaceholderArtifacts(options: CliOptions, endpoints: ExtractedEndpoint[], types: KotlinTypeRegistry): Promise<{
5
+ warnings: string[];
6
+ }>;
package/dist/output.js ADDED
@@ -0,0 +1,25 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { buildOpenApiArtifacts } from "./openapi.js";
4
+ import { buildUiModel, renderUiHtml } from "./ui.js";
5
+ export async function writePlaceholderArtifacts(options, endpoints, types) {
6
+ const outputDir = path.resolve(options.output);
7
+ const openApiPath = path.join(outputDir, "openapi.json");
8
+ const uiDataPath = path.join(outputDir, "ui-data.json");
9
+ const indexPath = path.join(outputDir, "index.html");
10
+ const artifacts = buildOpenApiArtifacts({
11
+ title: options.title,
12
+ version: options.version,
13
+ endpoints,
14
+ types
15
+ });
16
+ const uiModel = buildUiModel({
17
+ endpoints,
18
+ openapi: artifacts.document
19
+ });
20
+ await mkdir(outputDir, { recursive: true });
21
+ await writeFile(openApiPath, JSON.stringify(artifacts.document, null, 2), "utf8");
22
+ await writeFile(uiDataPath, JSON.stringify(uiModel, null, 2), "utf8");
23
+ await writeFile(indexPath, renderUiHtml(options.title, uiModel), "utf8");
24
+ return { warnings: artifacts.warnings };
25
+ }
@@ -0,0 +1,17 @@
1
+ export interface KotlinProperty {
2
+ name: string;
3
+ type: string;
4
+ nullable: boolean;
5
+ }
6
+ export interface KotlinDataClass {
7
+ name: string;
8
+ namingStrategy: "snake_case" | "camelCase";
9
+ properties: KotlinProperty[];
10
+ }
11
+ export type KotlinTypeRegistry = Record<string, KotlinDataClass>;
12
+ export interface ResolutionContext {
13
+ warnings: Set<string>;
14
+ }
15
+ export declare function scanDataClasses(projectPath: string): Promise<KotlinTypeRegistry>;
16
+ export declare function parseDataClassesFromSource(source: string): KotlinTypeRegistry;
17
+ export declare function resolveSchemaForType(rawType: string, registry: KotlinTypeRegistry, components: Record<string, unknown>, seen?: Set<string>, context?: ResolutionContext): Record<string, unknown>;