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
package/dist/ui.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
export function buildUiModel(input) {
|
|
2
|
+
const endpoints = input.endpoints.map((endpoint) => {
|
|
3
|
+
const operation = input.openapi.paths[endpoint.fullPath]?.[endpoint.httpMethod.toLowerCase()];
|
|
4
|
+
const requestSchema = deref(operation?.requestBody?.content?.["application/json"]?.schema, input.openapi);
|
|
5
|
+
const responseSchema = pickResponseSchema(operation?.responses, input.openapi);
|
|
6
|
+
return {
|
|
7
|
+
id: `${endpoint.httpMethod}:${endpoint.fullPath}:${endpoint.operationName}`,
|
|
8
|
+
operationName: endpoint.operationName,
|
|
9
|
+
method: endpoint.httpMethod,
|
|
10
|
+
path: endpoint.fullPath,
|
|
11
|
+
controller: operation?.tags?.[0] ?? guessController(endpoint.sourceFile),
|
|
12
|
+
pathVariables: endpoint.pathVariables.map((v) => ({
|
|
13
|
+
name: v.name,
|
|
14
|
+
type: v.type,
|
|
15
|
+
required: v.required ?? true
|
|
16
|
+
})),
|
|
17
|
+
queryParams: endpoint.queryParams.map((v) => ({
|
|
18
|
+
name: v.name,
|
|
19
|
+
type: v.type,
|
|
20
|
+
required: v.required ?? false
|
|
21
|
+
})),
|
|
22
|
+
headers: endpoint.headers.map((v) => ({
|
|
23
|
+
name: v.name,
|
|
24
|
+
type: v.type,
|
|
25
|
+
required: v.required ?? false
|
|
26
|
+
})),
|
|
27
|
+
requestSchema,
|
|
28
|
+
responseSchema,
|
|
29
|
+
curl: buildCurl(endpoint)
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
generatedAt: new Date().toISOString(),
|
|
34
|
+
endpoints
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function renderUiHtml(title, embeddedModel) {
|
|
38
|
+
const embedded = embeddedModel ? escapeJsonForScriptTag(embeddedModel) : "";
|
|
39
|
+
return `<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8" />
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
44
|
+
<title>${escapeHtml(title)}</title>
|
|
45
|
+
<style>
|
|
46
|
+
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Manrope:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
|
|
47
|
+
:root {
|
|
48
|
+
--bg:#f7efe2;
|
|
49
|
+
--bg-soft:#fbf6ee;
|
|
50
|
+
--card:#fffdfa;
|
|
51
|
+
--ink:#2b2118;
|
|
52
|
+
--muted:#74685a;
|
|
53
|
+
--line:#e6d6bf;
|
|
54
|
+
--line-strong:#d8c1a1;
|
|
55
|
+
--accent:#a14b10;
|
|
56
|
+
--accent-soft:#f4e5d4;
|
|
57
|
+
--radius:16px;
|
|
58
|
+
--shadow:0 16px 38px rgba(94, 60, 21, .13);
|
|
59
|
+
--get:#0f766e;
|
|
60
|
+
--post:#14532d;
|
|
61
|
+
--put:#92400e;
|
|
62
|
+
--patch:#6d28d9;
|
|
63
|
+
--delete:#991b1b;
|
|
64
|
+
}
|
|
65
|
+
* { box-sizing: border-box; }
|
|
66
|
+
body {
|
|
67
|
+
margin:0;
|
|
68
|
+
font-family:"Manrope","Segoe UI",sans-serif;
|
|
69
|
+
color:var(--ink);
|
|
70
|
+
background:
|
|
71
|
+
radial-gradient(circle at 12% 8%, rgba(235, 200, 156, .28), transparent 34%),
|
|
72
|
+
radial-gradient(circle at 84% 0%, rgba(210, 152, 96, .2), transparent 30%),
|
|
73
|
+
linear-gradient(160deg,#f0dfc6 0%,var(--bg) 28%,var(--bg-soft) 100%);
|
|
74
|
+
min-height:100vh;
|
|
75
|
+
}
|
|
76
|
+
.wrap { max-width:1120px; margin:0 auto; padding:28px 24px 40px; }
|
|
77
|
+
h1 {
|
|
78
|
+
margin:0 0 8px;
|
|
79
|
+
font-family:"Fraunces","Georgia",serif;
|
|
80
|
+
font-size:clamp(1.8rem, 4vw, 2.5rem);
|
|
81
|
+
letter-spacing:.01em;
|
|
82
|
+
}
|
|
83
|
+
.sub { margin:0 0 16px; color:var(--muted); max-width:900px; }
|
|
84
|
+
.toolbar {
|
|
85
|
+
position:sticky;
|
|
86
|
+
top:10px;
|
|
87
|
+
z-index:3;
|
|
88
|
+
display:grid;
|
|
89
|
+
grid-template-columns:1.5fr 1fr 1fr auto;
|
|
90
|
+
gap:10px;
|
|
91
|
+
margin:18px 0 18px;
|
|
92
|
+
padding:12px;
|
|
93
|
+
border:1px solid var(--line);
|
|
94
|
+
border-radius:14px;
|
|
95
|
+
background:rgba(255,252,247,.82);
|
|
96
|
+
backdrop-filter: blur(4px);
|
|
97
|
+
box-shadow:0 8px 20px rgba(90,57,20,.07);
|
|
98
|
+
}
|
|
99
|
+
input,select,button {
|
|
100
|
+
border:1px solid var(--line);
|
|
101
|
+
border-radius:11px;
|
|
102
|
+
padding:10px 12px;
|
|
103
|
+
font:inherit;
|
|
104
|
+
background:#fff;
|
|
105
|
+
color:var(--ink);
|
|
106
|
+
}
|
|
107
|
+
input:focus, select:focus, button:focus {
|
|
108
|
+
outline:none;
|
|
109
|
+
border-color:var(--line-strong);
|
|
110
|
+
box-shadow:0 0 0 3px rgba(161,75,16,.12);
|
|
111
|
+
}
|
|
112
|
+
button { cursor:pointer; }
|
|
113
|
+
.btn-primary {
|
|
114
|
+
background:linear-gradient(180deg,#b35a1d 0%, var(--accent) 100%);
|
|
115
|
+
color:#fff;
|
|
116
|
+
border:0;
|
|
117
|
+
font-weight:700;
|
|
118
|
+
letter-spacing:.01em;
|
|
119
|
+
padding-inline:15px;
|
|
120
|
+
}
|
|
121
|
+
.cards { display:grid; gap:12px; }
|
|
122
|
+
.card {
|
|
123
|
+
border:1px solid var(--line);
|
|
124
|
+
border-radius:var(--radius);
|
|
125
|
+
background:var(--card);
|
|
126
|
+
overflow:hidden;
|
|
127
|
+
box-shadow:var(--shadow);
|
|
128
|
+
animation:lift .35s ease both;
|
|
129
|
+
transform-origin:top;
|
|
130
|
+
}
|
|
131
|
+
.head {
|
|
132
|
+
display:grid;
|
|
133
|
+
grid-template-columns:auto 1fr auto;
|
|
134
|
+
gap:12px;
|
|
135
|
+
padding:14px 16px;
|
|
136
|
+
cursor:pointer;
|
|
137
|
+
align-items:center;
|
|
138
|
+
}
|
|
139
|
+
.pill {
|
|
140
|
+
padding:4px 9px;
|
|
141
|
+
border-radius:999px;
|
|
142
|
+
background:var(--accent-soft);
|
|
143
|
+
color:#7d3d00;
|
|
144
|
+
font-size:12px;
|
|
145
|
+
text-transform:uppercase;
|
|
146
|
+
letter-spacing:.05em;
|
|
147
|
+
font-weight:700;
|
|
148
|
+
min-width:68px;
|
|
149
|
+
text-align:center;
|
|
150
|
+
}
|
|
151
|
+
.pill.method-get { background:#dff5f2; color:var(--get); }
|
|
152
|
+
.pill.method-post { background:#dff4e6; color:var(--post); }
|
|
153
|
+
.pill.method-put { background:#ffefd8; color:var(--put); }
|
|
154
|
+
.pill.method-patch { background:#efe5ff; color:var(--patch); }
|
|
155
|
+
.pill.method-delete { background:#fde3e3; color:var(--delete); }
|
|
156
|
+
.path { font-weight:700; font-size:1.03rem; line-height:1.35; }
|
|
157
|
+
.muted { color:var(--muted); font-size:14px; }
|
|
158
|
+
.arrow { font-size:13px; color:var(--muted); transition:transform .16s ease; }
|
|
159
|
+
.card.open .arrow { transform:rotate(180deg); }
|
|
160
|
+
.body {
|
|
161
|
+
display:none;
|
|
162
|
+
border-top:1px dashed var(--line);
|
|
163
|
+
padding:4px 16px 16px;
|
|
164
|
+
background:linear-gradient(180deg, rgba(252,247,239,.6) 0%, rgba(255,255,255,.98) 100%);
|
|
165
|
+
}
|
|
166
|
+
.card.open .body { display:block; animation:reveal .2s ease-out; }
|
|
167
|
+
.sec {
|
|
168
|
+
margin-top:12px;
|
|
169
|
+
border:1px solid #efe5d7;
|
|
170
|
+
border-radius:12px;
|
|
171
|
+
padding:10px;
|
|
172
|
+
background:#fff;
|
|
173
|
+
}
|
|
174
|
+
.sec h4 {
|
|
175
|
+
margin:0 0 7px;
|
|
176
|
+
font-size:14px;
|
|
177
|
+
text-transform:uppercase;
|
|
178
|
+
letter-spacing:.06em;
|
|
179
|
+
color:#7c6247;
|
|
180
|
+
font-weight:700;
|
|
181
|
+
}
|
|
182
|
+
pre {
|
|
183
|
+
margin:0;
|
|
184
|
+
padding:11px;
|
|
185
|
+
border-radius:10px;
|
|
186
|
+
border:1px solid #374151;
|
|
187
|
+
background:linear-gradient(180deg,#1f2937 0%,#111827 100%);
|
|
188
|
+
color:#f9fafb;
|
|
189
|
+
overflow:auto;
|
|
190
|
+
font-size:12px;
|
|
191
|
+
line-height:1.45;
|
|
192
|
+
font-family:"IBM Plex Mono","Consolas",monospace;
|
|
193
|
+
}
|
|
194
|
+
table { width:100%; border-collapse:collapse; }
|
|
195
|
+
th,td { padding:7px 5px; border-bottom:1px solid var(--line); text-align:left; font-size:13px; }
|
|
196
|
+
th { color:#7d6549; font-weight:700; }
|
|
197
|
+
.stats { color:var(--muted); margin:0 0 10px; font-weight:600; }
|
|
198
|
+
@keyframes lift {
|
|
199
|
+
from { opacity:0; transform:translateY(8px); }
|
|
200
|
+
to { opacity:1; transform:translateY(0); }
|
|
201
|
+
}
|
|
202
|
+
@keyframes reveal {
|
|
203
|
+
from { opacity:0; transform:translateY(4px); }
|
|
204
|
+
to { opacity:1; transform:translateY(0); }
|
|
205
|
+
}
|
|
206
|
+
@media (max-width:900px) { .toolbar { grid-template-columns:1fr; position:static; } }
|
|
207
|
+
</style>
|
|
208
|
+
</head>
|
|
209
|
+
<body>
|
|
210
|
+
<div class="wrap">
|
|
211
|
+
<h1>${escapeHtml(title)}</h1>
|
|
212
|
+
<p class="sub">Search and filter endpoints, inspect schemas, and copy curl commands.</p>
|
|
213
|
+
<div class="toolbar">
|
|
214
|
+
<input id="search" placeholder="Search endpoint or path" />
|
|
215
|
+
<select id="method-filter"><option value="all">All methods</option></select>
|
|
216
|
+
<select id="controller-filter"><option value="all">All controllers</option></select>
|
|
217
|
+
<a href="openapi.json" download><button class="btn-primary">Download OpenAPI JSON</button></a>
|
|
218
|
+
</div>
|
|
219
|
+
<p id="stats" class="stats"></p>
|
|
220
|
+
<div id="cards" class="cards"></div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<script id="embedded-ui-data" type="application/json">${embedded}</script>
|
|
224
|
+
|
|
225
|
+
<script>
|
|
226
|
+
var state = { model: null, search: "", method: "all", controller: "all" };
|
|
227
|
+
var cardsRoot = document.getElementById("cards");
|
|
228
|
+
var stats = document.getElementById("stats");
|
|
229
|
+
var methodFilter = document.getElementById("method-filter");
|
|
230
|
+
var controllerFilter = document.getElementById("controller-filter");
|
|
231
|
+
|
|
232
|
+
function normalize(v) { return String(v || "").toLowerCase(); }
|
|
233
|
+
function esc(v) {
|
|
234
|
+
return String(v || "")
|
|
235
|
+
.replace(/&/g, "&")
|
|
236
|
+
.replace(/</g, "<")
|
|
237
|
+
.replace(/>/g, ">")
|
|
238
|
+
.replace(/\"/g, """)
|
|
239
|
+
.replace(/'/g, "'");
|
|
240
|
+
}
|
|
241
|
+
function json(v) { return esc(JSON.stringify(v || {}, null, 2)); }
|
|
242
|
+
|
|
243
|
+
function rows(items) {
|
|
244
|
+
if (!items || items.length === 0) return '<p class="muted">None</p>';
|
|
245
|
+
var body = items.map(function (r) {
|
|
246
|
+
return '<tr><td>' + esc(r.name) + '</td><td>' + esc(r.type) + '</td><td>' + (r.required ? "yes" : "no") + '</td></tr>';
|
|
247
|
+
}).join("");
|
|
248
|
+
return '<table><thead><tr><th>Name</th><th>Type</th><th>Required</th></tr></thead><tbody>' + body + '</tbody></table>';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function filtered(items) {
|
|
252
|
+
return items.filter(function (e) {
|
|
253
|
+
var s = !state.search || normalize(e.operationName).indexOf(normalize(state.search)) >= 0 || normalize(e.path).indexOf(normalize(state.search)) >= 0;
|
|
254
|
+
var m = state.method === "all" || e.method === state.method;
|
|
255
|
+
var c = state.controller === "all" || e.controller === state.controller;
|
|
256
|
+
return s && m && c;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function attach() {
|
|
261
|
+
Array.prototype.forEach.call(document.querySelectorAll('[data-toggle]'), function (el) {
|
|
262
|
+
el.addEventListener('click', function () {
|
|
263
|
+
var root = el.closest('.card');
|
|
264
|
+
if (root) root.classList.toggle('open');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
Array.prototype.forEach.call(document.querySelectorAll('[data-copy]'), function (el) {
|
|
268
|
+
el.addEventListener('click', function (ev) {
|
|
269
|
+
ev.stopPropagation();
|
|
270
|
+
var id = el.getAttribute('data-copy');
|
|
271
|
+
var item = state.model.endpoints.find(function (x) { return x.id === id; });
|
|
272
|
+
if (!item) return;
|
|
273
|
+
navigator.clipboard.writeText(item.curl).then(function () {
|
|
274
|
+
el.textContent = 'Copied';
|
|
275
|
+
setTimeout(function () { el.textContent = 'Copy curl'; }, 1200);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function render() {
|
|
282
|
+
if (!state.model) return;
|
|
283
|
+
var items = filtered(state.model.endpoints);
|
|
284
|
+
stats.textContent = items.length + ' endpoint(s) shown';
|
|
285
|
+
cardsRoot.innerHTML = items.map(function (e, idx) {
|
|
286
|
+
return '<article class="card">'
|
|
287
|
+
+ '<div style="height:' + ((idx % 4) * 1.2) + 'px"></div>'
|
|
288
|
+
+ '<header class="head" data-toggle="' + esc(e.id) + '">'
|
|
289
|
+
+ '<span class="pill method-' + esc(normalize(e.method)) + '">' + esc(e.method) + '</span>'
|
|
290
|
+
+ '<div><div class="path">' + esc(e.path) + '</div><div class="muted">' + esc(e.controller) + ' • ' + esc(e.operationName) + '</div></div>'
|
|
291
|
+
+ '<span class="arrow">▼</span>'
|
|
292
|
+
+ '</header>'
|
|
293
|
+
+ '<section class="body">'
|
|
294
|
+
+ '<div class="sec"><h4>Path Variables</h4>' + rows(e.pathVariables) + '</div>'
|
|
295
|
+
+ '<div class="sec"><h4>Query Params</h4>' + rows(e.queryParams) + '</div>'
|
|
296
|
+
+ '<div class="sec"><h4>Headers</h4>' + rows(e.headers) + '</div>'
|
|
297
|
+
+ '<div class="sec"><h4>Request Body</h4><pre>' + json(e.requestSchema) + '</pre></div>'
|
|
298
|
+
+ '<div class="sec"><h4>Response Body</h4><pre>' + json(e.responseSchema) + '</pre></div>'
|
|
299
|
+
+ '<div class="sec"><h4>curl</h4><pre>' + esc(e.curl) + '</pre></div>'
|
|
300
|
+
+ '<div class="sec"><button data-copy="' + esc(e.id) + '">Copy curl</button></div>'
|
|
301
|
+
+ '</section>'
|
|
302
|
+
+ '</article>';
|
|
303
|
+
}).join('');
|
|
304
|
+
attach();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function fillFilters(endpoints) {
|
|
308
|
+
var methods = Array.from(new Set(endpoints.map(function (e) { return e.method; }))).sort();
|
|
309
|
+
var ctrls = Array.from(new Set(endpoints.map(function (e) { return e.controller; }))).sort();
|
|
310
|
+
methodFilter.innerHTML = '<option value="all">All methods</option>' + methods.map(function (m) { return '<option value="' + esc(m) + '">' + esc(m) + '</option>'; }).join('');
|
|
311
|
+
controllerFilter.innerHTML = '<option value="all">All controllers</option>' + ctrls.map(function (c) { return '<option value="' + esc(c) + '">' + esc(c) + '</option>'; }).join('');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
document.getElementById('search').addEventListener('input', function (ev) { state.search = ev.target.value || ''; render(); });
|
|
315
|
+
methodFilter.addEventListener('change', function (ev) { state.method = ev.target.value; render(); });
|
|
316
|
+
controllerFilter.addEventListener('change', function (ev) { state.controller = ev.target.value; render(); });
|
|
317
|
+
|
|
318
|
+
function applyModel(data) {
|
|
319
|
+
state.model = data;
|
|
320
|
+
fillFilters(data.endpoints || []);
|
|
321
|
+
render();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function loadFromEmbedded() {
|
|
325
|
+
var embedded = document.getElementById('embedded-ui-data');
|
|
326
|
+
if (!embedded || !embedded.textContent || embedded.textContent.trim() === '') return false;
|
|
327
|
+
try {
|
|
328
|
+
applyModel(JSON.parse(embedded.textContent));
|
|
329
|
+
return true;
|
|
330
|
+
} catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function loadFromFetch() {
|
|
336
|
+
return fetch('ui-data.json')
|
|
337
|
+
.then(function (res) {
|
|
338
|
+
if (!res.ok) throw new Error('relative fetch failed');
|
|
339
|
+
return res.json();
|
|
340
|
+
})
|
|
341
|
+
.catch(function () {
|
|
342
|
+
return fetch('/ui-data.json').then(function (res) {
|
|
343
|
+
if (!res.ok) throw new Error('absolute fetch failed');
|
|
344
|
+
return res.json();
|
|
345
|
+
});
|
|
346
|
+
})
|
|
347
|
+
.then(function (data) { applyModel(data); });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!loadFromEmbedded()) {
|
|
351
|
+
loadFromFetch().catch(function () { stats.textContent = 'Failed to load UI data.'; });
|
|
352
|
+
}
|
|
353
|
+
</script>
|
|
354
|
+
</body>
|
|
355
|
+
</html>`;
|
|
356
|
+
}
|
|
357
|
+
function deref(schema, openapi) {
|
|
358
|
+
if (!schema) {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
if (schema.$ref) {
|
|
362
|
+
const name = schema.$ref.split("/").at(-1);
|
|
363
|
+
if (name && openapi.components.schemas[name]) {
|
|
364
|
+
return openapi.components.schemas[name];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return schema;
|
|
368
|
+
}
|
|
369
|
+
function pickResponseSchema(responses, openapi) {
|
|
370
|
+
if (!responses) {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
const best = responses["200"] ?? responses["201"];
|
|
374
|
+
return deref(best?.content?.["application/json"]?.schema, openapi);
|
|
375
|
+
}
|
|
376
|
+
function buildCurl(endpoint) {
|
|
377
|
+
const parts = [`curl -X ${endpoint.httpMethod} "http://localhost:3000${endpoint.fullPath}"`];
|
|
378
|
+
for (const header of endpoint.headers) {
|
|
379
|
+
parts.push(`-H "${header.name}: <${header.name}>"`);
|
|
380
|
+
}
|
|
381
|
+
if (endpoint.requestBody) {
|
|
382
|
+
parts.push('-H "Content-Type: application/json"');
|
|
383
|
+
parts.push("-d '{ }'");
|
|
384
|
+
}
|
|
385
|
+
return parts.join(" \\\n+");
|
|
386
|
+
}
|
|
387
|
+
function guessController(sourceFile) {
|
|
388
|
+
const file = sourceFile.replaceAll("\\", "/").split("/").at(-1) ?? sourceFile;
|
|
389
|
+
return file.endsWith(".kt") ? file.slice(0, -3) : file;
|
|
390
|
+
}
|
|
391
|
+
function escapeHtml(value) {
|
|
392
|
+
return value
|
|
393
|
+
.replaceAll("&", "&")
|
|
394
|
+
.replaceAll("<", "<")
|
|
395
|
+
.replaceAll(">", ">")
|
|
396
|
+
.replaceAll('"', """)
|
|
397
|
+
.replaceAll("'", "'");
|
|
398
|
+
}
|
|
399
|
+
function escapeJsonForScriptTag(value) {
|
|
400
|
+
return JSON.stringify(value)
|
|
401
|
+
.replaceAll("<", "\\u003c")
|
|
402
|
+
.replaceAll(">", "\\u003e")
|
|
403
|
+
.replaceAll("&", "\\u0026")
|
|
404
|
+
.replaceAll("</script", "<\\/script")
|
|
405
|
+
.replaceAll("\u2028", "\\u2028")
|
|
406
|
+
.replaceAll("\u2029", "\\u2029");
|
|
407
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Spring API Scanner Remaining Tasks Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Complete remaining PLAN.md scope: human-readable UI, robust output artifacts, and stronger integration/golden tests.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Keep parser/resolver/OpenAPI pipeline as source of truth and add a small UI data-model builder consumed by a static SPA. Extend output orchestration to write `openapi.json`, `ui-data.json`, and `index.html`, then validate with deterministic tests.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js, TypeScript, Vitest, static HTML/CSS/vanilla JS.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: UI Data Model + Static UI
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Create: `src/ui.ts`
|
|
17
|
+
- Modify: `src/output.ts`
|
|
18
|
+
- Test: `tests/ui.test.ts`
|
|
19
|
+
|
|
20
|
+
**Steps:**
|
|
21
|
+
1. Write failing tests for UI model shape and rendered HTML affordances.
|
|
22
|
+
2. Run targeted tests and verify failure.
|
|
23
|
+
3. Implement `buildUiModel` + `renderUiHtml` with search/filter/expand/copy-curl/download.
|
|
24
|
+
4. Run targeted tests and verify pass.
|
|
25
|
+
|
|
26
|
+
### Task 2: Wire UI Into Orchestration
|
|
27
|
+
|
|
28
|
+
**Files:**
|
|
29
|
+
- Modify: `src/output.ts`, `src/index.ts`
|
|
30
|
+
- Test: `tests/output.test.ts`
|
|
31
|
+
|
|
32
|
+
**Steps:**
|
|
33
|
+
1. Write failing tests expecting `ui-data.json` output and warnings surfaced.
|
|
34
|
+
2. Run targeted tests and verify failure.
|
|
35
|
+
3. Implement output writing for UI artifacts and summary integration.
|
|
36
|
+
4. Run targeted tests and verify pass.
|
|
37
|
+
|
|
38
|
+
### Task 3: Integration + Golden OpenAPI Test
|
|
39
|
+
|
|
40
|
+
**Files:**
|
|
41
|
+
- Create: `tests/fixtures/sample-service/src/main/kotlin/demo/UserController.kt`
|
|
42
|
+
- Create: `tests/fixtures/sample-service/src/main/kotlin/demo/UserDtos.kt`
|
|
43
|
+
- Create: `tests/golden/openapi.sample-service.json`
|
|
44
|
+
- Create/Modify: `tests/integration.test.ts`
|
|
45
|
+
|
|
46
|
+
**Steps:**
|
|
47
|
+
1. Add failing integration test generating OpenAPI from fixture.
|
|
48
|
+
2. Add golden-file assertion for deterministic output.
|
|
49
|
+
3. Run tests and verify red.
|
|
50
|
+
4. Implement/fix any gaps in parser/resolver/openapi for fixture pass.
|
|
51
|
+
5. Re-run tests and verify green.
|
|
52
|
+
|
|
53
|
+
### Task 4: Final Verification
|
|
54
|
+
|
|
55
|
+
**Files:**
|
|
56
|
+
- Modify: `README.md` (create if missing)
|
|
57
|
+
|
|
58
|
+
**Steps:**
|
|
59
|
+
1. Add quickstart and command examples.
|
|
60
|
+
2. Run full verification: `npm test`, `npm run build`, CLI smoke run.
|
|
61
|
+
3. Confirm no regressions and summarize completion against PLAN.md.
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spring-api-scanner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for scanning Spring Boot Kotlin APIs and generating OpenAPI + UI output",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"spring-api-scanner": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.json",
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"test": "vitest run"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.10.2",
|
|
19
|
+
"tsx": "^4.19.2",
|
|
20
|
+
"typescript": "^5.7.2",
|
|
21
|
+
"vitest": "^2.1.8"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
Binary file
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export interface CliOptions {
|
|
2
|
+
projectPath: string;
|
|
3
|
+
port: number;
|
|
4
|
+
output: string;
|
|
5
|
+
serve: boolean;
|
|
6
|
+
title: string;
|
|
7
|
+
version: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PORT = 3000;
|
|
11
|
+
const DEFAULT_OUTPUT = "./api-docs";
|
|
12
|
+
const DEFAULT_TITLE = "Spring API Docs";
|
|
13
|
+
const DEFAULT_VERSION = "1.0.0";
|
|
14
|
+
|
|
15
|
+
export function parseCliArgs(argv: string[]): CliOptions {
|
|
16
|
+
if (argv.length === 0 || argv[0].startsWith("--")) {
|
|
17
|
+
throw new Error("A project path is required: spring-api-scanner <projectPath>");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const projectPath = argv[0];
|
|
21
|
+
const options: CliOptions = {
|
|
22
|
+
projectPath,
|
|
23
|
+
port: DEFAULT_PORT,
|
|
24
|
+
output: DEFAULT_OUTPUT,
|
|
25
|
+
serve: false,
|
|
26
|
+
title: DEFAULT_TITLE,
|
|
27
|
+
version: DEFAULT_VERSION
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
31
|
+
const token = argv[i];
|
|
32
|
+
|
|
33
|
+
switch (token) {
|
|
34
|
+
case "--serve":
|
|
35
|
+
options.serve = true;
|
|
36
|
+
break;
|
|
37
|
+
case "--no-serve":
|
|
38
|
+
options.serve = false;
|
|
39
|
+
break;
|
|
40
|
+
case "--port":
|
|
41
|
+
options.port = parseIntegerFlag("--port", argv[++i]);
|
|
42
|
+
break;
|
|
43
|
+
case "--output":
|
|
44
|
+
options.output = parseStringFlag("--output", argv[++i]);
|
|
45
|
+
break;
|
|
46
|
+
case "--title":
|
|
47
|
+
options.title = parseStringFlag("--title", argv[++i]);
|
|
48
|
+
break;
|
|
49
|
+
case "--version":
|
|
50
|
+
options.version = parseStringFlag("--version", argv[++i]);
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
throw new Error(`Unknown argument: ${token}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return options;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseStringFlag(flagName: string, rawValue?: string): string {
|
|
61
|
+
if (!rawValue || rawValue.startsWith("--")) {
|
|
62
|
+
throw new Error(`Missing value for ${flagName}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return rawValue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseIntegerFlag(flagName: string, rawValue?: string): number {
|
|
69
|
+
const parsed = Number.parseInt(parseStringFlag(flagName, rawValue), 10);
|
|
70
|
+
|
|
71
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
72
|
+
throw new Error(`${flagName} must be a positive integer`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return parsed;
|
|
76
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
|
|
11
|
+
async function main(): Promise<void> {
|
|
12
|
+
const options = parseCliArgs(process.argv.slice(2));
|
|
13
|
+
await validateProjectPath(options.projectPath);
|
|
14
|
+
const endpoints = await scanSpringProject(options.projectPath);
|
|
15
|
+
const types = await scanDataClasses(options.projectPath);
|
|
16
|
+
const output = await writePlaceholderArtifacts(options, endpoints, types);
|
|
17
|
+
|
|
18
|
+
printSummary(options, endpoints.length, output.warnings);
|
|
19
|
+
|
|
20
|
+
if (options.serve) {
|
|
21
|
+
await serveOutput(options.output, options.port);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function validateProjectPath(projectPath: string): Promise<void> {
|
|
26
|
+
const result = await stat(projectPath).catch(() => null);
|
|
27
|
+
if (!result || !result.isDirectory()) {
|
|
28
|
+
throw new Error(`Project path not found or not a directory: ${projectPath}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function printSummary(
|
|
33
|
+
options: ReturnType<typeof parseCliArgs>,
|
|
34
|
+
endpointCount: number,
|
|
35
|
+
warnings: string[]
|
|
36
|
+
): void {
|
|
37
|
+
console.log("Spring API Scanner");
|
|
38
|
+
console.log(`- Project: ${path.resolve(options.projectPath)}`);
|
|
39
|
+
console.log(`- Output: ${path.resolve(options.output)}`);
|
|
40
|
+
console.log(`- Endpoints discovered: ${endpointCount}`);
|
|
41
|
+
console.log("- OpenAPI: openapi.json written");
|
|
42
|
+
console.log(`- Warnings: ${warnings.length}`);
|
|
43
|
+
for (const warning of warnings.slice(0, 5)) {
|
|
44
|
+
console.log(` - ${warning}`);
|
|
45
|
+
}
|
|
46
|
+
console.log(`- Serve mode: ${options.serve ? "enabled" : "disabled"}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function serveOutput(outputDir: string, port: number): Promise<void> {
|
|
50
|
+
const root = path.resolve(outputDir);
|
|
51
|
+
const server = createServer(async (req, res) => {
|
|
52
|
+
const pathname =
|
|
53
|
+
req.url === "/openapi.json"
|
|
54
|
+
? "openapi.json"
|
|
55
|
+
: req.url === "/ui-data.json"
|
|
56
|
+
? "ui-data.json"
|
|
57
|
+
: "index.html";
|
|
58
|
+
const fullPath = path.join(root, pathname);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const contents = await readFile(fullPath);
|
|
62
|
+
const contentType =
|
|
63
|
+
pathname === "index.html" ? "text/html; charset=utf-8" : "application/json; charset=utf-8";
|
|
64
|
+
res.writeHead(200, { "content-type": contentType });
|
|
65
|
+
res.end(contents);
|
|
66
|
+
} catch {
|
|
67
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
68
|
+
res.end("Not found");
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
server.listen(port, () => {
|
|
73
|
+
console.log(`Serving docs at http://localhost:${port}`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch((error: unknown) => {
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
+
console.error(`Error: ${message}`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
});
|