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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
{
|
|
2
|
+
"openapi": "3.0.3",
|
|
3
|
+
"info": {
|
|
4
|
+
"title": "Sample Service",
|
|
5
|
+
"version": "1.0.0"
|
|
6
|
+
},
|
|
7
|
+
"paths": {
|
|
8
|
+
"/api/users": {
|
|
9
|
+
"post": {
|
|
10
|
+
"operationId": "createUser",
|
|
11
|
+
"tags": [
|
|
12
|
+
"UserController"
|
|
13
|
+
],
|
|
14
|
+
"parameters": [],
|
|
15
|
+
"requestBody": {
|
|
16
|
+
"required": true,
|
|
17
|
+
"content": {
|
|
18
|
+
"application/json": {
|
|
19
|
+
"schema": {
|
|
20
|
+
"$ref": "#/components/schemas/CreateUserRequest"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"responses": {
|
|
26
|
+
"201": {
|
|
27
|
+
"description": "Created",
|
|
28
|
+
"content": {
|
|
29
|
+
"application/json": {
|
|
30
|
+
"schema": {
|
|
31
|
+
"$ref": "#/components/schemas/UserResponse"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"/api/users/{id}": {
|
|
40
|
+
"delete": {
|
|
41
|
+
"operationId": "deleteUser",
|
|
42
|
+
"tags": [
|
|
43
|
+
"UserController"
|
|
44
|
+
],
|
|
45
|
+
"parameters": [
|
|
46
|
+
{
|
|
47
|
+
"in": "path",
|
|
48
|
+
"name": "id",
|
|
49
|
+
"required": true,
|
|
50
|
+
"schema": {
|
|
51
|
+
"type": "integer",
|
|
52
|
+
"format": "int64"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"responses": {
|
|
57
|
+
"204": {
|
|
58
|
+
"description": "No Content"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"get": {
|
|
63
|
+
"operationId": "getUser",
|
|
64
|
+
"tags": [
|
|
65
|
+
"UserController"
|
|
66
|
+
],
|
|
67
|
+
"parameters": [
|
|
68
|
+
{
|
|
69
|
+
"in": "path",
|
|
70
|
+
"name": "id",
|
|
71
|
+
"required": true,
|
|
72
|
+
"schema": {
|
|
73
|
+
"type": "integer",
|
|
74
|
+
"format": "int64"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"in": "query",
|
|
79
|
+
"name": "include_posts",
|
|
80
|
+
"required": false,
|
|
81
|
+
"schema": {
|
|
82
|
+
"type": "boolean"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"in": "header",
|
|
87
|
+
"name": "X-Trace-Id",
|
|
88
|
+
"required": true,
|
|
89
|
+
"schema": {
|
|
90
|
+
"type": "string"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
"responses": {
|
|
95
|
+
"200": {
|
|
96
|
+
"description": "OK",
|
|
97
|
+
"content": {
|
|
98
|
+
"application/json": {
|
|
99
|
+
"schema": {
|
|
100
|
+
"$ref": "#/components/schemas/UserResponse"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"components": {
|
|
110
|
+
"schemas": {
|
|
111
|
+
"CreateUserRequest": {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"properties": {
|
|
114
|
+
"first_name": {
|
|
115
|
+
"type": "string"
|
|
116
|
+
},
|
|
117
|
+
"created_at": {
|
|
118
|
+
"type": "string",
|
|
119
|
+
"format": "date-time"
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
"required": [
|
|
123
|
+
"first_name",
|
|
124
|
+
"created_at"
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
"UserProfile": {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"properties": {
|
|
130
|
+
"avatar_url": {
|
|
131
|
+
"type": "string"
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"required": [
|
|
135
|
+
"avatar_url"
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
"UserResponse": {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {
|
|
141
|
+
"userId": {
|
|
142
|
+
"type": "integer",
|
|
143
|
+
"format": "int64"
|
|
144
|
+
},
|
|
145
|
+
"firstName": {
|
|
146
|
+
"type": "string"
|
|
147
|
+
},
|
|
148
|
+
"createdAt": {
|
|
149
|
+
"type": "string",
|
|
150
|
+
"format": "date-time"
|
|
151
|
+
},
|
|
152
|
+
"profile": {
|
|
153
|
+
"$ref": "#/components/schemas/UserProfile"
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
"required": [
|
|
157
|
+
"userId",
|
|
158
|
+
"firstName",
|
|
159
|
+
"createdAt"
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { buildOpenApiDocument } from "../src/openapi.js";
|
|
6
|
+
import { scanDataClasses } from "../src/resolver.js";
|
|
7
|
+
import { scanSpringProject } from "../src/scanner.js";
|
|
8
|
+
|
|
9
|
+
describe("integration fixture", () => {
|
|
10
|
+
it("matches golden OpenAPI output deterministically", async () => {
|
|
11
|
+
const fixtureRoot = path.join(os.tmpdir(), `spring-api-scanner-fixture-${Date.now()}`);
|
|
12
|
+
const kotlinDir = path.join(fixtureRoot, "src", "main", "kotlin", "demo");
|
|
13
|
+
await mkdir(kotlinDir, { recursive: true });
|
|
14
|
+
|
|
15
|
+
await writeFile(
|
|
16
|
+
path.join(kotlinDir, "UserController.kt"),
|
|
17
|
+
`
|
|
18
|
+
package demo
|
|
19
|
+
|
|
20
|
+
import org.springframework.web.bind.annotation.DeleteMapping
|
|
21
|
+
import org.springframework.web.bind.annotation.GetMapping
|
|
22
|
+
import org.springframework.web.bind.annotation.PathVariable
|
|
23
|
+
import org.springframework.web.bind.annotation.PostMapping
|
|
24
|
+
import org.springframework.web.bind.annotation.RequestBody
|
|
25
|
+
import org.springframework.web.bind.annotation.RequestHeader
|
|
26
|
+
import org.springframework.web.bind.annotation.RequestMapping
|
|
27
|
+
import org.springframework.web.bind.annotation.RequestParam
|
|
28
|
+
import org.springframework.web.bind.annotation.RestController
|
|
29
|
+
|
|
30
|
+
@RestController
|
|
31
|
+
@RequestMapping("/api/users")
|
|
32
|
+
class UserController {
|
|
33
|
+
@GetMapping("/{id}")
|
|
34
|
+
fun getUser(
|
|
35
|
+
@PathVariable id: Long,
|
|
36
|
+
@RequestParam(required = false) includePosts: Boolean?,
|
|
37
|
+
@RequestHeader("X-Trace-Id") traceId: String
|
|
38
|
+
): UserResponse {
|
|
39
|
+
TODO()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@PostMapping
|
|
43
|
+
fun createUser(@RequestBody body: CreateUserRequest): UserResponse {
|
|
44
|
+
TODO()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@DeleteMapping("/{id}")
|
|
48
|
+
fun deleteUser(@PathVariable id: Long): Unit {
|
|
49
|
+
TODO()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
`,
|
|
53
|
+
"utf8"
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
await writeFile(
|
|
57
|
+
path.join(kotlinDir, "UserDtos.kt"),
|
|
58
|
+
`
|
|
59
|
+
package demo
|
|
60
|
+
|
|
61
|
+
import com.fasterxml.jackson.databind.PropertyNamingStrategies
|
|
62
|
+
import com.fasterxml.jackson.databind.annotation.JsonNaming
|
|
63
|
+
import java.time.Instant
|
|
64
|
+
|
|
65
|
+
data class CreateUserRequest(
|
|
66
|
+
val firstName: String,
|
|
67
|
+
val createdAt: Instant
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy::class)
|
|
71
|
+
data class UserResponse(
|
|
72
|
+
val userId: Long,
|
|
73
|
+
val firstName: String,
|
|
74
|
+
val createdAt: Instant,
|
|
75
|
+
val profile: UserProfile?
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
data class UserProfile(
|
|
79
|
+
val avatarUrl: String
|
|
80
|
+
)
|
|
81
|
+
`,
|
|
82
|
+
"utf8"
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const endpoints = await scanSpringProject(fixtureRoot);
|
|
86
|
+
const types = await scanDataClasses(fixtureRoot);
|
|
87
|
+
|
|
88
|
+
const document = buildOpenApiDocument({
|
|
89
|
+
title: "Sample Service",
|
|
90
|
+
version: "1.0.0",
|
|
91
|
+
endpoints,
|
|
92
|
+
types
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const expected = await readFile(path.resolve("tests/golden/openapi.sample-service.json"), "utf8");
|
|
96
|
+
expect(JSON.stringify(document, null, 2)).toBe(expected.trim());
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildOpenApiArtifacts, buildOpenApiDocument } from "../src/openapi.js";
|
|
3
|
+
|
|
4
|
+
describe("buildOpenApiDocument", () => {
|
|
5
|
+
it("creates openapi with endpoint params and concrete schemas", () => {
|
|
6
|
+
const spec = buildOpenApiDocument({
|
|
7
|
+
title: "Catalog API",
|
|
8
|
+
version: "0.2.0",
|
|
9
|
+
endpoints: [
|
|
10
|
+
{
|
|
11
|
+
sourceFile: "UserController.kt",
|
|
12
|
+
operationName: "createUser",
|
|
13
|
+
httpMethod: "POST",
|
|
14
|
+
fullPath: "/users",
|
|
15
|
+
returnType: "ResponseEntity<UserDto>",
|
|
16
|
+
pathVariables: [],
|
|
17
|
+
queryParams: [{ name: "include_posts", type: "Boolean?", required: false }],
|
|
18
|
+
headers: [{ name: "X-Trace-Id", type: "String", required: true }],
|
|
19
|
+
requestBody: { type: "CreateUserRequest", required: true }
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
types: {
|
|
23
|
+
CreateUserRequest: {
|
|
24
|
+
name: "CreateUserRequest",
|
|
25
|
+
namingStrategy: "snake_case",
|
|
26
|
+
properties: [
|
|
27
|
+
{ name: "name", type: "String", nullable: false },
|
|
28
|
+
{ name: "age", type: "Int?", nullable: true }
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
UserDto: {
|
|
32
|
+
name: "UserDto",
|
|
33
|
+
namingStrategy: "snake_case",
|
|
34
|
+
properties: [
|
|
35
|
+
{ name: "id", type: "Long", nullable: false },
|
|
36
|
+
{ name: "name", type: "String", nullable: false }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(spec.openapi).toBe("3.0.3");
|
|
43
|
+
expect(spec.info.title).toBe("Catalog API");
|
|
44
|
+
expect(spec.info.version).toBe("0.2.0");
|
|
45
|
+
expect(spec.paths["/users"].post.operationId).toBe("createUser");
|
|
46
|
+
expect(spec.paths["/users"].post.parameters).toEqual([
|
|
47
|
+
{
|
|
48
|
+
in: "query",
|
|
49
|
+
name: "include_posts",
|
|
50
|
+
required: false,
|
|
51
|
+
schema: { type: "boolean" }
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
in: "header",
|
|
55
|
+
name: "X-Trace-Id",
|
|
56
|
+
required: true,
|
|
57
|
+
schema: { type: "string" }
|
|
58
|
+
}
|
|
59
|
+
]);
|
|
60
|
+
expect(spec.paths["/users"].post.requestBody).toEqual({
|
|
61
|
+
required: true,
|
|
62
|
+
content: {
|
|
63
|
+
"application/json": {
|
|
64
|
+
schema: { $ref: "#/components/schemas/CreateUserRequest" }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
expect(spec.paths["/users"].post.responses["201"].content["application/json"].schema).toEqual({
|
|
69
|
+
$ref: "#/components/schemas/UserDto"
|
|
70
|
+
});
|
|
71
|
+
expect(spec.paths["/users"].post.responses["200"]).toBeUndefined();
|
|
72
|
+
expect(spec.components.schemas.CreateUserRequest.required).toEqual(["name"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("uses 204 and no content for Unit returns", () => {
|
|
76
|
+
const spec = buildOpenApiDocument({
|
|
77
|
+
title: "Catalog API",
|
|
78
|
+
version: "0.2.0",
|
|
79
|
+
endpoints: [
|
|
80
|
+
{
|
|
81
|
+
sourceFile: "UserController.kt",
|
|
82
|
+
operationName: "deleteUser",
|
|
83
|
+
httpMethod: "DELETE",
|
|
84
|
+
fullPath: "/users/{id}",
|
|
85
|
+
returnType: "Unit",
|
|
86
|
+
pathVariables: [{ name: "id", type: "Long" }],
|
|
87
|
+
queryParams: [],
|
|
88
|
+
headers: []
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
types: {}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(spec.paths["/users/{id}"].delete.responses["204"]).toEqual({
|
|
95
|
+
description: "No Content"
|
|
96
|
+
});
|
|
97
|
+
expect(spec.paths["/users/{id}"].delete.responses["200"]).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("buildOpenApiArtifacts", () => {
|
|
102
|
+
it("collects unresolved type warnings without crashing", () => {
|
|
103
|
+
const result = buildOpenApiArtifacts({
|
|
104
|
+
title: "Catalog API",
|
|
105
|
+
version: "0.2.0",
|
|
106
|
+
endpoints: [
|
|
107
|
+
{
|
|
108
|
+
sourceFile: "UserController.kt",
|
|
109
|
+
operationName: "getUnknown",
|
|
110
|
+
httpMethod: "GET",
|
|
111
|
+
fullPath: "/unknown",
|
|
112
|
+
returnType: "MissingDto",
|
|
113
|
+
pathVariables: [],
|
|
114
|
+
queryParams: [],
|
|
115
|
+
headers: []
|
|
116
|
+
}
|
|
117
|
+
],
|
|
118
|
+
types: {}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
122
|
+
expect(result.warnings[0]).toMatch(/MissingDto/);
|
|
123
|
+
expect(
|
|
124
|
+
result.document.paths["/unknown"].get.responses["200"].content["application/json"].schema
|
|
125
|
+
).toEqual({ type: "object" });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { writePlaceholderArtifacts } from "../src/output.js";
|
|
6
|
+
import type { CliOptions } from "../src/args.js";
|
|
7
|
+
import type { ExtractedEndpoint } from "../src/scanner.js";
|
|
8
|
+
|
|
9
|
+
describe("writePlaceholderArtifacts", () => {
|
|
10
|
+
it("writes openapi, ui-data, and index html", async () => {
|
|
11
|
+
const root = path.join(os.tmpdir(), `scanner-out-${Date.now()}`);
|
|
12
|
+
await mkdir(root, { recursive: true });
|
|
13
|
+
|
|
14
|
+
const options: CliOptions = {
|
|
15
|
+
projectPath: root,
|
|
16
|
+
output: root,
|
|
17
|
+
serve: false,
|
|
18
|
+
port: 3000,
|
|
19
|
+
title: "Demo Docs",
|
|
20
|
+
version: "1.0.0"
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const endpoints: ExtractedEndpoint[] = [
|
|
24
|
+
{
|
|
25
|
+
sourceFile: "UserController.kt",
|
|
26
|
+
operationName: "getUser",
|
|
27
|
+
httpMethod: "GET",
|
|
28
|
+
fullPath: "/users/{id}",
|
|
29
|
+
returnType: "UserDto",
|
|
30
|
+
pathVariables: [{ name: "id", type: "Long", required: true }],
|
|
31
|
+
queryParams: [],
|
|
32
|
+
headers: []
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const result = await writePlaceholderArtifacts(options, endpoints, {
|
|
37
|
+
UserDto: {
|
|
38
|
+
name: "UserDto",
|
|
39
|
+
namingStrategy: "snake_case",
|
|
40
|
+
properties: [{ name: "id", type: "Long", nullable: false }]
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result.warnings).toEqual([]);
|
|
45
|
+
|
|
46
|
+
const openapi = JSON.parse(await readFile(path.join(root, "openapi.json"), "utf8"));
|
|
47
|
+
const uiData = JSON.parse(await readFile(path.join(root, "ui-data.json"), "utf8"));
|
|
48
|
+
const html = await readFile(path.join(root, "index.html"), "utf8");
|
|
49
|
+
|
|
50
|
+
expect(openapi.paths["/users/{id}"]).toBeDefined();
|
|
51
|
+
expect(uiData.endpoints).toHaveLength(1);
|
|
52
|
+
expect(html).toContain("/ui-data.json");
|
|
53
|
+
expect(html).toContain("Copy curl");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseDataClassesFromSource,
|
|
4
|
+
resolveSchemaForType,
|
|
5
|
+
type KotlinTypeRegistry
|
|
6
|
+
} from "../src/resolver.js";
|
|
7
|
+
|
|
8
|
+
describe("parseDataClassesFromSource", () => {
|
|
9
|
+
it("extracts dto properties and nullability", () => {
|
|
10
|
+
const source = `
|
|
11
|
+
data class CreateUserRequest(
|
|
12
|
+
val name: String,
|
|
13
|
+
val age: Int?,
|
|
14
|
+
val profile: UserProfile
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
data class UserProfile(
|
|
18
|
+
val createdAt: Instant
|
|
19
|
+
)
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const registry = parseDataClassesFromSource(source);
|
|
23
|
+
expect(registry.CreateUserRequest).toBeDefined();
|
|
24
|
+
expect(registry.CreateUserRequest.properties).toEqual([
|
|
25
|
+
{ name: "name", type: "String", nullable: false },
|
|
26
|
+
{ name: "age", type: "Int?", nullable: true },
|
|
27
|
+
{ name: "profile", type: "UserProfile", nullable: false }
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("uses snake_case by default and camelCase for JsonNaming lower camel", () => {
|
|
32
|
+
const source = `
|
|
33
|
+
data class DefaultNaming(
|
|
34
|
+
val firstName: String,
|
|
35
|
+
val createdAt: Instant
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy::class)
|
|
39
|
+
data class CamelNaming(
|
|
40
|
+
val firstName: String,
|
|
41
|
+
val createdAt: Instant
|
|
42
|
+
)
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const registry = parseDataClassesFromSource(source);
|
|
46
|
+
const components: Record<string, unknown> = {};
|
|
47
|
+
resolveSchemaForType("DefaultNaming", registry, components);
|
|
48
|
+
resolveSchemaForType("CamelNaming", registry, components);
|
|
49
|
+
|
|
50
|
+
expect(components.DefaultNaming).toEqual({
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
first_name: { type: "string" },
|
|
54
|
+
created_at: { type: "string", format: "date-time" }
|
|
55
|
+
},
|
|
56
|
+
required: ["first_name", "created_at"]
|
|
57
|
+
});
|
|
58
|
+
expect(components.CamelNaming).toEqual({
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
firstName: { type: "string" },
|
|
62
|
+
createdAt: { type: "string", format: "date-time" }
|
|
63
|
+
},
|
|
64
|
+
required: ["firstName", "createdAt"]
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("resolveSchemaForType", () => {
|
|
70
|
+
it("unwraps wrappers and resolves nested dto schemas", () => {
|
|
71
|
+
const registry: KotlinTypeRegistry = {
|
|
72
|
+
UserDto: {
|
|
73
|
+
name: "UserDto",
|
|
74
|
+
namingStrategy: "snake_case",
|
|
75
|
+
properties: [
|
|
76
|
+
{ name: "id", type: "Long", nullable: false },
|
|
77
|
+
{ name: "name", type: "String", nullable: false }
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const components: Record<string, unknown> = {};
|
|
83
|
+
const schema = resolveSchemaForType("ResponseEntity<List<UserDto>>", registry, components);
|
|
84
|
+
|
|
85
|
+
expect(schema).toEqual({
|
|
86
|
+
type: "array",
|
|
87
|
+
items: { $ref: "#/components/schemas/UserDto" }
|
|
88
|
+
});
|
|
89
|
+
expect(components.UserDto).toEqual({
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: {
|
|
92
|
+
id: { type: "integer", format: "int64" },
|
|
93
|
+
name: { type: "string" }
|
|
94
|
+
},
|
|
95
|
+
required: ["id", "name"]
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { parseKotlinControllerFile, scanSpringProject } from "../src/scanner.js";
|
|
6
|
+
|
|
7
|
+
describe("parseKotlinControllerFile", () => {
|
|
8
|
+
it("extracts controller prefix, mapping method, and request metadata", () => {
|
|
9
|
+
const source = `
|
|
10
|
+
package demo
|
|
11
|
+
|
|
12
|
+
@RestController
|
|
13
|
+
@RequestMapping("/api/v1/users")
|
|
14
|
+
class UserController {
|
|
15
|
+
|
|
16
|
+
@GetMapping("/{id}")
|
|
17
|
+
fun getUser(
|
|
18
|
+
@PathVariable id: Long,
|
|
19
|
+
@RequestParam(required = false) includePosts: Boolean?,
|
|
20
|
+
@RequestHeader("X-Trace-Id") traceId: String
|
|
21
|
+
): UserDto {
|
|
22
|
+
TODO()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@PostMapping(path = ["", "/"])
|
|
26
|
+
fun createUser(@RequestBody body: CreateUserRequest): ResponseEntity<UserDto> {
|
|
27
|
+
TODO()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
const endpoints = parseKotlinControllerFile(source, "UserController.kt");
|
|
33
|
+
|
|
34
|
+
expect(endpoints).toHaveLength(3);
|
|
35
|
+
expect(endpoints[0]).toMatchObject({
|
|
36
|
+
operationName: "getUser",
|
|
37
|
+
httpMethod: "GET",
|
|
38
|
+
fullPath: "/api/v1/users/{id}",
|
|
39
|
+
returnType: "UserDto",
|
|
40
|
+
pathVariables: [{ name: "id", type: "Long" }],
|
|
41
|
+
queryParams: [{ name: "include_posts", type: "Boolean?", required: false }],
|
|
42
|
+
headers: [{ name: "X-Trace-Id", type: "String" }]
|
|
43
|
+
});
|
|
44
|
+
expect(endpoints[1]).toMatchObject({
|
|
45
|
+
operationName: "createUser",
|
|
46
|
+
httpMethod: "POST",
|
|
47
|
+
fullPath: "/api/v1/users",
|
|
48
|
+
requestBody: { type: "CreateUserRequest", required: true },
|
|
49
|
+
returnType: "ResponseEntity<UserDto>"
|
|
50
|
+
});
|
|
51
|
+
expect(endpoints[2]).toMatchObject({
|
|
52
|
+
operationName: "createUser",
|
|
53
|
+
httpMethod: "POST",
|
|
54
|
+
fullPath: "/api/v1/users/"
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("supports RequestMapping value/path arrays and parameter alias keys", () => {
|
|
59
|
+
const source = `
|
|
60
|
+
@RestController
|
|
61
|
+
@RequestMapping(path = ["/v2", "/v2-alt"])
|
|
62
|
+
class MixedController {
|
|
63
|
+
|
|
64
|
+
@RequestMapping(
|
|
65
|
+
value = ["/items", "/things"],
|
|
66
|
+
method = [RequestMethod.GET, RequestMethod.POST]
|
|
67
|
+
)
|
|
68
|
+
fun list(
|
|
69
|
+
@PathVariable(name = "item_id") id: Long,
|
|
70
|
+
@RequestParam(name = "page_no", required = false) page: Int?,
|
|
71
|
+
@RequestHeader(value = "x-token") token: String
|
|
72
|
+
): ItemResponse {
|
|
73
|
+
TODO()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
const endpoints = parseKotlinControllerFile(source, "MixedController.kt");
|
|
79
|
+
|
|
80
|
+
expect(endpoints).toHaveLength(8);
|
|
81
|
+
const getPaths = endpoints
|
|
82
|
+
.filter((e) => e.httpMethod === "GET")
|
|
83
|
+
.map((e) => e.fullPath)
|
|
84
|
+
.sort();
|
|
85
|
+
expect(getPaths).toEqual([
|
|
86
|
+
"/v2-alt/items",
|
|
87
|
+
"/v2-alt/things",
|
|
88
|
+
"/v2/items",
|
|
89
|
+
"/v2/things"
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
expect(endpoints[0].pathVariables[0].name).toBe("item_id");
|
|
93
|
+
expect(endpoints[0].queryParams[0]).toMatchObject({ name: "page_no", required: false });
|
|
94
|
+
expect(endpoints[0].headers[0].name).toBe("x-token");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("scanSpringProject", () => {
|
|
99
|
+
it("scans src/main/kotlin/**/*.kt and skips non-controller files", async () => {
|
|
100
|
+
const rootPath = path.join(os.tmpdir(), `scanner-${Date.now()}`);
|
|
101
|
+
const created = await mkdir(rootPath, { recursive: true });
|
|
102
|
+
const root = created ?? rootPath;
|
|
103
|
+
const kotlinDir = path.join(root, "src", "main", "kotlin", "demo");
|
|
104
|
+
await mkdir(kotlinDir, { recursive: true });
|
|
105
|
+
|
|
106
|
+
await writeFile(
|
|
107
|
+
path.join(kotlinDir, "OrderController.kt"),
|
|
108
|
+
`
|
|
109
|
+
@RestController
|
|
110
|
+
@RequestMapping("/orders")
|
|
111
|
+
class OrderController {
|
|
112
|
+
@DeleteMapping("/{id}")
|
|
113
|
+
fun delete(@PathVariable id: Long): Unit = TODO()
|
|
114
|
+
}
|
|
115
|
+
`,
|
|
116
|
+
"utf8"
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
await writeFile(
|
|
120
|
+
path.join(kotlinDir, "OrderService.kt"),
|
|
121
|
+
`
|
|
122
|
+
class OrderService {
|
|
123
|
+
fun run(): String = "ok"
|
|
124
|
+
}
|
|
125
|
+
`,
|
|
126
|
+
"utf8"
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const result = await scanSpringProject(root);
|
|
130
|
+
expect(result).toHaveLength(1);
|
|
131
|
+
expect(result[0]).toMatchObject({
|
|
132
|
+
operationName: "delete",
|
|
133
|
+
httpMethod: "DELETE",
|
|
134
|
+
fullPath: "/orders/{id}",
|
|
135
|
+
sourceFile: path.join(kotlinDir, "OrderController.kt")
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|