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/src/openapi.ts ADDED
@@ -0,0 +1,191 @@
1
+ import type { ExtractedEndpoint } from "./scanner.js";
2
+ import { resolveSchemaForType, type KotlinTypeRegistry, type ResolutionContext } from "./resolver.js";
3
+
4
+ export interface OpenApiSchema {
5
+ type?: string;
6
+ format?: string;
7
+ items?: OpenApiSchema;
8
+ properties?: Record<string, OpenApiSchema>;
9
+ required?: string[];
10
+ $ref?: string;
11
+ }
12
+
13
+ export interface OpenApiParameter {
14
+ in: "path" | "query" | "header";
15
+ name: string;
16
+ required: boolean;
17
+ schema: OpenApiSchema;
18
+ }
19
+
20
+ export interface OpenApiResponse {
21
+ description: string;
22
+ content: {
23
+ "application/json": {
24
+ schema: OpenApiSchema;
25
+ };
26
+ };
27
+ }
28
+
29
+ export interface OpenApiOperation {
30
+ operationId: string;
31
+ tags: string[];
32
+ parameters: OpenApiParameter[];
33
+ requestBody?: {
34
+ required: boolean;
35
+ content: {
36
+ "application/json": {
37
+ schema: OpenApiSchema;
38
+ };
39
+ };
40
+ };
41
+ responses: Record<string, OpenApiResponse | { description: string }>;
42
+ }
43
+
44
+ export interface OpenApiDocument {
45
+ openapi: string;
46
+ info: {
47
+ title: string;
48
+ version: string;
49
+ };
50
+ paths: Record<string, Record<string, OpenApiOperation>>;
51
+ components: {
52
+ schemas: Record<string, OpenApiSchema>;
53
+ };
54
+ }
55
+
56
+ export interface OpenApiArtifacts {
57
+ document: OpenApiDocument;
58
+ warnings: string[];
59
+ }
60
+
61
+ export function buildOpenApiDocument(input: {
62
+ title: string;
63
+ version: string;
64
+ endpoints: ExtractedEndpoint[];
65
+ types: KotlinTypeRegistry;
66
+ }): OpenApiDocument {
67
+ return buildOpenApiArtifacts(input).document;
68
+ }
69
+
70
+ export function buildOpenApiArtifacts(input: {
71
+ title: string;
72
+ version: string;
73
+ endpoints: ExtractedEndpoint[];
74
+ types: KotlinTypeRegistry;
75
+ }): OpenApiArtifacts {
76
+ const schemas: Record<string, OpenApiSchema> = {};
77
+ const paths: Record<string, Record<string, OpenApiOperation>> = {};
78
+ const context: ResolutionContext = { warnings: new Set() };
79
+
80
+ for (const endpoint of input.endpoints) {
81
+ const method = endpoint.httpMethod.toLowerCase();
82
+ const parameters: OpenApiParameter[] = [
83
+ ...endpoint.pathVariables.map((item): OpenApiParameter => ({
84
+ in: "path",
85
+ name: item.name,
86
+ required: true,
87
+ schema: resolveSchemaForType(item.type, input.types, schemas, new Set(), context) as OpenApiSchema
88
+ })),
89
+ ...endpoint.queryParams.map((item): OpenApiParameter => ({
90
+ in: "query",
91
+ name: item.name,
92
+ required: item.required ?? false,
93
+ schema: resolveSchemaForType(item.type, input.types, schemas, new Set(), context) as OpenApiSchema
94
+ })),
95
+ ...endpoint.headers.map((item): OpenApiParameter => ({
96
+ in: "header",
97
+ name: item.name,
98
+ required: item.required ?? false,
99
+ schema: resolveSchemaForType(item.type, input.types, schemas, new Set(), context) as OpenApiSchema
100
+ }))
101
+ ];
102
+
103
+ const requestBody = endpoint.requestBody
104
+ ? {
105
+ required: endpoint.requestBody.required,
106
+ content: {
107
+ "application/json": {
108
+ schema: resolveSchemaForType(
109
+ endpoint.requestBody.type,
110
+ input.types,
111
+ schemas,
112
+ new Set(),
113
+ context
114
+ ) as OpenApiSchema
115
+ }
116
+ }
117
+ }
118
+ : undefined;
119
+
120
+ const response = buildOperationResponse(endpoint, input.types, schemas, context);
121
+
122
+ const operation: OpenApiOperation = {
123
+ operationId: endpoint.operationName,
124
+ tags: [guessTag(endpoint.sourceFile)],
125
+ parameters,
126
+ ...(requestBody ? { requestBody } : {}),
127
+ responses: response
128
+ };
129
+
130
+ if (!paths[endpoint.fullPath]) {
131
+ paths[endpoint.fullPath] = {};
132
+ }
133
+ paths[endpoint.fullPath][method] = operation;
134
+ }
135
+
136
+ return {
137
+ document: {
138
+ openapi: "3.0.3",
139
+ info: {
140
+ title: input.title,
141
+ version: input.version
142
+ },
143
+ paths,
144
+ components: {
145
+ schemas
146
+ }
147
+ },
148
+ warnings: [...context.warnings]
149
+ };
150
+ }
151
+
152
+ function guessTag(sourceFile: string): string {
153
+ const file = sourceFile.replaceAll("\\", "/").split("/").at(-1) ?? sourceFile;
154
+ return file.endsWith(".kt") ? file.slice(0, -3) : file;
155
+ }
156
+
157
+ function buildOperationResponse(
158
+ endpoint: ExtractedEndpoint,
159
+ types: KotlinTypeRegistry,
160
+ schemas: Record<string, OpenApiSchema>,
161
+ context: ResolutionContext
162
+ ): Record<string, OpenApiResponse | { description: string }> {
163
+ const normalizedReturn = endpoint.returnType.trim();
164
+ const isNoContent = normalizedReturn === "Unit" || normalizedReturn === "Void";
165
+
166
+ if (isNoContent) {
167
+ return {
168
+ "204": {
169
+ description: "No Content"
170
+ }
171
+ };
172
+ }
173
+
174
+ const statusCode = endpoint.httpMethod === "POST" ? "201" : "200";
175
+ return {
176
+ [statusCode]: {
177
+ description: statusCode === "201" ? "Created" : "OK",
178
+ content: {
179
+ "application/json": {
180
+ schema: resolveSchemaForType(
181
+ endpoint.returnType,
182
+ types,
183
+ schemas,
184
+ new Set(),
185
+ context
186
+ ) as OpenApiSchema
187
+ }
188
+ }
189
+ }
190
+ };
191
+ }
package/src/output.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { CliOptions } from "./args.js";
4
+ import { buildOpenApiArtifacts } from "./openapi.js";
5
+ import type { KotlinTypeRegistry } from "./resolver.js";
6
+ import type { ExtractedEndpoint } from "./scanner.js";
7
+ import { buildUiModel, renderUiHtml } from "./ui.js";
8
+
9
+ export async function writePlaceholderArtifacts(
10
+ options: CliOptions,
11
+ endpoints: ExtractedEndpoint[],
12
+ types: KotlinTypeRegistry
13
+ ): Promise<{ warnings: string[] }> {
14
+ const outputDir = path.resolve(options.output);
15
+ const openApiPath = path.join(outputDir, "openapi.json");
16
+ const uiDataPath = path.join(outputDir, "ui-data.json");
17
+ const indexPath = path.join(outputDir, "index.html");
18
+ const artifacts = buildOpenApiArtifacts({
19
+ title: options.title,
20
+ version: options.version,
21
+ endpoints,
22
+ types
23
+ });
24
+
25
+ const uiModel = buildUiModel({
26
+ endpoints,
27
+ openapi: artifacts.document
28
+ });
29
+
30
+ await mkdir(outputDir, { recursive: true });
31
+ await writeFile(openApiPath, JSON.stringify(artifacts.document, null, 2), "utf8");
32
+ await writeFile(uiDataPath, JSON.stringify(uiModel, null, 2), "utf8");
33
+ await writeFile(indexPath, renderUiHtml(options.title, uiModel), "utf8");
34
+
35
+ return { warnings: artifacts.warnings };
36
+ }
@@ -0,0 +1,274 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface KotlinProperty {
5
+ name: string;
6
+ type: string;
7
+ nullable: boolean;
8
+ }
9
+
10
+ export interface KotlinDataClass {
11
+ name: string;
12
+ namingStrategy: "snake_case" | "camelCase";
13
+ properties: KotlinProperty[];
14
+ }
15
+
16
+ export type KotlinTypeRegistry = Record<string, KotlinDataClass>;
17
+
18
+ export interface ResolutionContext {
19
+ warnings: Set<string>;
20
+ }
21
+
22
+ export async function scanDataClasses(projectPath: string): Promise<KotlinTypeRegistry> {
23
+ const kotlinRoot = path.join(projectPath, "src", "main", "kotlin");
24
+ const files = (await listKotlinFiles(kotlinRoot)).sort();
25
+ const registry: KotlinTypeRegistry = {};
26
+
27
+ for (const filePath of files) {
28
+ const source = await readFile(filePath, "utf8");
29
+ Object.assign(registry, parseDataClassesFromSource(source));
30
+ }
31
+
32
+ return registry;
33
+ }
34
+
35
+ export function parseDataClassesFromSource(source: string): KotlinTypeRegistry {
36
+ const registry: KotlinTypeRegistry = {};
37
+ const matches = source.matchAll(
38
+ /((?:\s*@[^\n]+\n)*)\s*data\s+class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)/g
39
+ );
40
+
41
+ for (const match of matches) {
42
+ const annotationBlock = match[1] ?? "";
43
+ const name = match[2] ?? "";
44
+ const rawProperties = match[3] ?? "";
45
+ const properties = splitTopLevel(rawProperties)
46
+ .map((value) => value.trim())
47
+ .filter((value) => value.length > 0)
48
+ .map(parseProperty)
49
+ .filter((value): value is KotlinProperty => value !== null);
50
+
51
+ registry[name] = {
52
+ name,
53
+ namingStrategy: parseNamingStrategy(annotationBlock),
54
+ properties
55
+ };
56
+ }
57
+
58
+ return registry;
59
+ }
60
+
61
+ export function resolveSchemaForType(
62
+ rawType: string,
63
+ registry: KotlinTypeRegistry,
64
+ components: Record<string, unknown>,
65
+ seen: Set<string> = new Set(),
66
+ context?: ResolutionContext
67
+ ): Record<string, unknown> {
68
+ const typeName = cleanType(rawType);
69
+ const unwrapped = normalizeNullableType(unwrapEntity(typeName));
70
+
71
+ if (isListLike(unwrapped)) {
72
+ const inner = extractSingleGeneric(unwrapped);
73
+ return {
74
+ type: "array",
75
+ items: resolveSchemaForType(inner, registry, components, seen, context)
76
+ };
77
+ }
78
+
79
+ const primitive = mapPrimitive(unwrapped);
80
+ if (primitive) {
81
+ return primitive;
82
+ }
83
+
84
+ if (registry[unwrapped]) {
85
+ ensureComponent(unwrapped, registry, components, seen, context);
86
+ return { $ref: `#/components/schemas/${unwrapped}` };
87
+ }
88
+
89
+ if (context) {
90
+ context.warnings.add(`Unresolved type: ${unwrapped}`);
91
+ }
92
+
93
+ return { type: "object" };
94
+ }
95
+
96
+ function ensureComponent(
97
+ typeName: string,
98
+ registry: KotlinTypeRegistry,
99
+ components: Record<string, unknown>,
100
+ seen: Set<string>,
101
+ context?: ResolutionContext
102
+ ): void {
103
+ if (components[typeName] || seen.has(typeName)) {
104
+ return;
105
+ }
106
+ seen.add(typeName);
107
+
108
+ const dto = registry[typeName];
109
+ if (!dto) {
110
+ return;
111
+ }
112
+
113
+ const properties: Record<string, unknown> = {};
114
+ const required: string[] = [];
115
+ for (const property of dto.properties) {
116
+ const clean = cleanType(property.type);
117
+ const serializedName = serializePropertyName(dto.namingStrategy, property.name);
118
+ properties[serializedName] = resolveSchemaForType(clean, registry, components, seen, context);
119
+ if (!property.nullable) {
120
+ required.push(serializedName);
121
+ }
122
+ }
123
+
124
+ components[typeName] = {
125
+ type: "object",
126
+ properties,
127
+ required
128
+ };
129
+ }
130
+
131
+ function parseProperty(raw: string): KotlinProperty | null {
132
+ const parsed = raw.match(/(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^=]+?)(?:\s*=.*)?$/);
133
+ if (!parsed) {
134
+ return null;
135
+ }
136
+
137
+ const name = parsed[1] ?? "";
138
+ const type = cleanType(parsed[2] ?? "Any");
139
+ return { name, type, nullable: type.endsWith("?") };
140
+ }
141
+
142
+ function cleanType(rawType: string): string {
143
+ return rawType.trim();
144
+ }
145
+
146
+ function normalizeNullableType(typeName: string): string {
147
+ return typeName.endsWith("?") ? typeName.slice(0, -1) : typeName;
148
+ }
149
+
150
+ function unwrapEntity(typeName: string): string {
151
+ const outer = outerType(typeName);
152
+ if (outer === "ResponseEntity") {
153
+ return unwrapEntity(extractSingleGeneric(typeName));
154
+ }
155
+ return typeName;
156
+ }
157
+
158
+ function isListLike(typeName: string): boolean {
159
+ const outer = outerType(typeName);
160
+ return outer === "List" || outer === "MutableList" || outer === "Page";
161
+ }
162
+
163
+ function outerType(typeName: string): string {
164
+ const idx = typeName.indexOf("<");
165
+ return idx < 0 ? typeName.replace("?", "") : typeName.slice(0, idx).trim();
166
+ }
167
+
168
+ function extractSingleGeneric(typeName: string): string {
169
+ const start = typeName.indexOf("<");
170
+ const end = typeName.lastIndexOf(">");
171
+ if (start < 0 || end < 0 || end <= start + 1) {
172
+ return "Any";
173
+ }
174
+ return typeName.slice(start + 1, end).trim();
175
+ }
176
+
177
+ function mapPrimitive(typeName: string): Record<string, unknown> | null {
178
+ const withoutNullable = typeName.endsWith("?") ? typeName.slice(0, -1) : typeName;
179
+ switch (withoutNullable) {
180
+ case "String":
181
+ return { type: "string" };
182
+ case "Long":
183
+ return { type: "integer", format: "int64" };
184
+ case "Int":
185
+ return { type: "integer", format: "int32" };
186
+ case "Boolean":
187
+ return { type: "boolean" };
188
+ case "Double":
189
+ return { type: "number", format: "double" };
190
+ case "Float":
191
+ return { type: "number", format: "float" };
192
+ case "Instant":
193
+ return { type: "string", format: "date-time" };
194
+ default:
195
+ return null;
196
+ }
197
+ }
198
+
199
+ async function listKotlinFiles(root: string): Promise<string[]> {
200
+ const entries = (await readdir(root, { withFileTypes: true }).catch(() => [])).sort((a, b) =>
201
+ a.name.localeCompare(b.name)
202
+ );
203
+ const files: string[] = [];
204
+
205
+ for (const entry of entries) {
206
+ const fullPath = path.join(root, entry.name);
207
+ if (entry.isDirectory()) {
208
+ files.push(...(await listKotlinFiles(fullPath)));
209
+ continue;
210
+ }
211
+ if (entry.isFile() && fullPath.endsWith(".kt")) {
212
+ files.push(fullPath);
213
+ }
214
+ }
215
+
216
+ return files;
217
+ }
218
+
219
+ function splitTopLevel(input: string): string[] {
220
+ const parts: string[] = [];
221
+ let current = "";
222
+ let depthParen = 0;
223
+ let depthAngle = 0;
224
+ let depthSquare = 0;
225
+
226
+ for (const char of input) {
227
+ if (char === "(") depthParen += 1;
228
+ if (char === ")") depthParen -= 1;
229
+ if (char === "<") depthAngle += 1;
230
+ if (char === ">") depthAngle -= 1;
231
+ if (char === "[") depthSquare += 1;
232
+ if (char === "]") depthSquare -= 1;
233
+
234
+ if (char === "," && depthParen === 0 && depthAngle === 0 && depthSquare === 0) {
235
+ parts.push(current);
236
+ current = "";
237
+ continue;
238
+ }
239
+
240
+ current += char;
241
+ }
242
+
243
+ if (current.trim().length > 0) {
244
+ parts.push(current);
245
+ }
246
+
247
+ return parts;
248
+ }
249
+
250
+ function parseNamingStrategy(annotationBlock: string): "snake_case" | "camelCase" {
251
+ if (!/@JsonNaming\s*\(/.test(annotationBlock)) {
252
+ return "snake_case";
253
+ }
254
+
255
+ if (/LowerCamelCaseStrategy/.test(annotationBlock)) {
256
+ return "camelCase";
257
+ }
258
+ if (/SnakeCaseStrategy/.test(annotationBlock)) {
259
+ return "snake_case";
260
+ }
261
+
262
+ return "snake_case";
263
+ }
264
+
265
+ function serializePropertyName(strategy: "snake_case" | "camelCase", name: string): string {
266
+ if (strategy === "camelCase") {
267
+ return name;
268
+ }
269
+
270
+ return name
271
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
272
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
273
+ .toLowerCase();
274
+ }