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.
@@ -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
+ });