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/ui.ts ADDED
@@ -0,0 +1,454 @@
1
+ import type { OpenApiDocument, OpenApiSchema } from "./openapi.js";
2
+ import type { ExtractedEndpoint } from "./scanner.js";
3
+
4
+ export interface UiEndpointCard {
5
+ id: string;
6
+ operationName: string;
7
+ method: string;
8
+ path: string;
9
+ controller: string;
10
+ pathVariables: Array<{ name: string; type: string; required: boolean }>;
11
+ queryParams: Array<{ name: string; type: string; required: boolean }>;
12
+ headers: Array<{ name: string; type: string; required: boolean }>;
13
+ requestSchema?: OpenApiSchema;
14
+ responseSchema?: OpenApiSchema;
15
+ curl: string;
16
+ }
17
+
18
+ export interface UiModel {
19
+ generatedAt: string;
20
+ endpoints: UiEndpointCard[];
21
+ }
22
+
23
+ export function buildUiModel(input: {
24
+ endpoints: ExtractedEndpoint[];
25
+ openapi: OpenApiDocument;
26
+ }): UiModel {
27
+ const endpoints = input.endpoints.map((endpoint) => {
28
+ const operation = input.openapi.paths[endpoint.fullPath]?.[endpoint.httpMethod.toLowerCase()];
29
+ const requestSchema = deref(operation?.requestBody?.content?.["application/json"]?.schema, input.openapi);
30
+ const responseSchema = pickResponseSchema(operation?.responses, input.openapi);
31
+
32
+ return {
33
+ id: `${endpoint.httpMethod}:${endpoint.fullPath}:${endpoint.operationName}`,
34
+ operationName: endpoint.operationName,
35
+ method: endpoint.httpMethod,
36
+ path: endpoint.fullPath,
37
+ controller: operation?.tags?.[0] ?? guessController(endpoint.sourceFile),
38
+ pathVariables: endpoint.pathVariables.map((v) => ({
39
+ name: v.name,
40
+ type: v.type,
41
+ required: v.required ?? true
42
+ })),
43
+ queryParams: endpoint.queryParams.map((v) => ({
44
+ name: v.name,
45
+ type: v.type,
46
+ required: v.required ?? false
47
+ })),
48
+ headers: endpoint.headers.map((v) => ({
49
+ name: v.name,
50
+ type: v.type,
51
+ required: v.required ?? false
52
+ })),
53
+ requestSchema,
54
+ responseSchema,
55
+ curl: buildCurl(endpoint)
56
+ };
57
+ });
58
+
59
+ return {
60
+ generatedAt: new Date().toISOString(),
61
+ endpoints
62
+ };
63
+ }
64
+
65
+ export function renderUiHtml(title: string, embeddedModel?: UiModel): string {
66
+ const embedded = embeddedModel ? escapeJsonForScriptTag(embeddedModel) : "";
67
+ return `<!doctype html>
68
+ <html lang="en">
69
+ <head>
70
+ <meta charset="utf-8" />
71
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
72
+ <title>${escapeHtml(title)}</title>
73
+ <style>
74
+ @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');
75
+ :root {
76
+ --bg:#f7efe2;
77
+ --bg-soft:#fbf6ee;
78
+ --card:#fffdfa;
79
+ --ink:#2b2118;
80
+ --muted:#74685a;
81
+ --line:#e6d6bf;
82
+ --line-strong:#d8c1a1;
83
+ --accent:#a14b10;
84
+ --accent-soft:#f4e5d4;
85
+ --radius:16px;
86
+ --shadow:0 16px 38px rgba(94, 60, 21, .13);
87
+ --get:#0f766e;
88
+ --post:#14532d;
89
+ --put:#92400e;
90
+ --patch:#6d28d9;
91
+ --delete:#991b1b;
92
+ }
93
+ * { box-sizing: border-box; }
94
+ body {
95
+ margin:0;
96
+ font-family:"Manrope","Segoe UI",sans-serif;
97
+ color:var(--ink);
98
+ background:
99
+ radial-gradient(circle at 12% 8%, rgba(235, 200, 156, .28), transparent 34%),
100
+ radial-gradient(circle at 84% 0%, rgba(210, 152, 96, .2), transparent 30%),
101
+ linear-gradient(160deg,#f0dfc6 0%,var(--bg) 28%,var(--bg-soft) 100%);
102
+ min-height:100vh;
103
+ }
104
+ .wrap { max-width:1120px; margin:0 auto; padding:28px 24px 40px; }
105
+ h1 {
106
+ margin:0 0 8px;
107
+ font-family:"Fraunces","Georgia",serif;
108
+ font-size:clamp(1.8rem, 4vw, 2.5rem);
109
+ letter-spacing:.01em;
110
+ }
111
+ .sub { margin:0 0 16px; color:var(--muted); max-width:900px; }
112
+ .toolbar {
113
+ position:sticky;
114
+ top:10px;
115
+ z-index:3;
116
+ display:grid;
117
+ grid-template-columns:1.5fr 1fr 1fr auto;
118
+ gap:10px;
119
+ margin:18px 0 18px;
120
+ padding:12px;
121
+ border:1px solid var(--line);
122
+ border-radius:14px;
123
+ background:rgba(255,252,247,.82);
124
+ backdrop-filter: blur(4px);
125
+ box-shadow:0 8px 20px rgba(90,57,20,.07);
126
+ }
127
+ input,select,button {
128
+ border:1px solid var(--line);
129
+ border-radius:11px;
130
+ padding:10px 12px;
131
+ font:inherit;
132
+ background:#fff;
133
+ color:var(--ink);
134
+ }
135
+ input:focus, select:focus, button:focus {
136
+ outline:none;
137
+ border-color:var(--line-strong);
138
+ box-shadow:0 0 0 3px rgba(161,75,16,.12);
139
+ }
140
+ button { cursor:pointer; }
141
+ .btn-primary {
142
+ background:linear-gradient(180deg,#b35a1d 0%, var(--accent) 100%);
143
+ color:#fff;
144
+ border:0;
145
+ font-weight:700;
146
+ letter-spacing:.01em;
147
+ padding-inline:15px;
148
+ }
149
+ .cards { display:grid; gap:12px; }
150
+ .card {
151
+ border:1px solid var(--line);
152
+ border-radius:var(--radius);
153
+ background:var(--card);
154
+ overflow:hidden;
155
+ box-shadow:var(--shadow);
156
+ animation:lift .35s ease both;
157
+ transform-origin:top;
158
+ }
159
+ .head {
160
+ display:grid;
161
+ grid-template-columns:auto 1fr auto;
162
+ gap:12px;
163
+ padding:14px 16px;
164
+ cursor:pointer;
165
+ align-items:center;
166
+ }
167
+ .pill {
168
+ padding:4px 9px;
169
+ border-radius:999px;
170
+ background:var(--accent-soft);
171
+ color:#7d3d00;
172
+ font-size:12px;
173
+ text-transform:uppercase;
174
+ letter-spacing:.05em;
175
+ font-weight:700;
176
+ min-width:68px;
177
+ text-align:center;
178
+ }
179
+ .pill.method-get { background:#dff5f2; color:var(--get); }
180
+ .pill.method-post { background:#dff4e6; color:var(--post); }
181
+ .pill.method-put { background:#ffefd8; color:var(--put); }
182
+ .pill.method-patch { background:#efe5ff; color:var(--patch); }
183
+ .pill.method-delete { background:#fde3e3; color:var(--delete); }
184
+ .path { font-weight:700; font-size:1.03rem; line-height:1.35; }
185
+ .muted { color:var(--muted); font-size:14px; }
186
+ .arrow { font-size:13px; color:var(--muted); transition:transform .16s ease; }
187
+ .card.open .arrow { transform:rotate(180deg); }
188
+ .body {
189
+ display:none;
190
+ border-top:1px dashed var(--line);
191
+ padding:4px 16px 16px;
192
+ background:linear-gradient(180deg, rgba(252,247,239,.6) 0%, rgba(255,255,255,.98) 100%);
193
+ }
194
+ .card.open .body { display:block; animation:reveal .2s ease-out; }
195
+ .sec {
196
+ margin-top:12px;
197
+ border:1px solid #efe5d7;
198
+ border-radius:12px;
199
+ padding:10px;
200
+ background:#fff;
201
+ }
202
+ .sec h4 {
203
+ margin:0 0 7px;
204
+ font-size:14px;
205
+ text-transform:uppercase;
206
+ letter-spacing:.06em;
207
+ color:#7c6247;
208
+ font-weight:700;
209
+ }
210
+ pre {
211
+ margin:0;
212
+ padding:11px;
213
+ border-radius:10px;
214
+ border:1px solid #374151;
215
+ background:linear-gradient(180deg,#1f2937 0%,#111827 100%);
216
+ color:#f9fafb;
217
+ overflow:auto;
218
+ font-size:12px;
219
+ line-height:1.45;
220
+ font-family:"IBM Plex Mono","Consolas",monospace;
221
+ }
222
+ table { width:100%; border-collapse:collapse; }
223
+ th,td { padding:7px 5px; border-bottom:1px solid var(--line); text-align:left; font-size:13px; }
224
+ th { color:#7d6549; font-weight:700; }
225
+ .stats { color:var(--muted); margin:0 0 10px; font-weight:600; }
226
+ @keyframes lift {
227
+ from { opacity:0; transform:translateY(8px); }
228
+ to { opacity:1; transform:translateY(0); }
229
+ }
230
+ @keyframes reveal {
231
+ from { opacity:0; transform:translateY(4px); }
232
+ to { opacity:1; transform:translateY(0); }
233
+ }
234
+ @media (max-width:900px) { .toolbar { grid-template-columns:1fr; position:static; } }
235
+ </style>
236
+ </head>
237
+ <body>
238
+ <div class="wrap">
239
+ <h1>${escapeHtml(title)}</h1>
240
+ <p class="sub">Search and filter endpoints, inspect schemas, and copy curl commands.</p>
241
+ <div class="toolbar">
242
+ <input id="search" placeholder="Search endpoint or path" />
243
+ <select id="method-filter"><option value="all">All methods</option></select>
244
+ <select id="controller-filter"><option value="all">All controllers</option></select>
245
+ <a href="openapi.json" download><button class="btn-primary">Download OpenAPI JSON</button></a>
246
+ </div>
247
+ <p id="stats" class="stats"></p>
248
+ <div id="cards" class="cards"></div>
249
+ </div>
250
+
251
+ <script id="embedded-ui-data" type="application/json">${embedded}</script>
252
+
253
+ <script>
254
+ var state = { model: null, search: "", method: "all", controller: "all" };
255
+ var cardsRoot = document.getElementById("cards");
256
+ var stats = document.getElementById("stats");
257
+ var methodFilter = document.getElementById("method-filter");
258
+ var controllerFilter = document.getElementById("controller-filter");
259
+
260
+ function normalize(v) { return String(v || "").toLowerCase(); }
261
+ function esc(v) {
262
+ return String(v || "")
263
+ .replace(/&/g, "&amp;")
264
+ .replace(/</g, "&lt;")
265
+ .replace(/>/g, "&gt;")
266
+ .replace(/\"/g, "&quot;")
267
+ .replace(/'/g, "&#39;");
268
+ }
269
+ function json(v) { return esc(JSON.stringify(v || {}, null, 2)); }
270
+
271
+ function rows(items) {
272
+ if (!items || items.length === 0) return '<p class="muted">None</p>';
273
+ var body = items.map(function (r) {
274
+ return '<tr><td>' + esc(r.name) + '</td><td>' + esc(r.type) + '</td><td>' + (r.required ? "yes" : "no") + '</td></tr>';
275
+ }).join("");
276
+ return '<table><thead><tr><th>Name</th><th>Type</th><th>Required</th></tr></thead><tbody>' + body + '</tbody></table>';
277
+ }
278
+
279
+ function filtered(items) {
280
+ return items.filter(function (e) {
281
+ var s = !state.search || normalize(e.operationName).indexOf(normalize(state.search)) >= 0 || normalize(e.path).indexOf(normalize(state.search)) >= 0;
282
+ var m = state.method === "all" || e.method === state.method;
283
+ var c = state.controller === "all" || e.controller === state.controller;
284
+ return s && m && c;
285
+ });
286
+ }
287
+
288
+ function attach() {
289
+ Array.prototype.forEach.call(document.querySelectorAll('[data-toggle]'), function (el) {
290
+ el.addEventListener('click', function () {
291
+ var root = el.closest('.card');
292
+ if (root) root.classList.toggle('open');
293
+ });
294
+ });
295
+ Array.prototype.forEach.call(document.querySelectorAll('[data-copy]'), function (el) {
296
+ el.addEventListener('click', function (ev) {
297
+ ev.stopPropagation();
298
+ var id = el.getAttribute('data-copy');
299
+ var item = state.model.endpoints.find(function (x) { return x.id === id; });
300
+ if (!item) return;
301
+ navigator.clipboard.writeText(item.curl).then(function () {
302
+ el.textContent = 'Copied';
303
+ setTimeout(function () { el.textContent = 'Copy curl'; }, 1200);
304
+ });
305
+ });
306
+ });
307
+ }
308
+
309
+ function render() {
310
+ if (!state.model) return;
311
+ var items = filtered(state.model.endpoints);
312
+ stats.textContent = items.length + ' endpoint(s) shown';
313
+ cardsRoot.innerHTML = items.map(function (e, idx) {
314
+ return '<article class="card">'
315
+ + '<div style="height:' + ((idx % 4) * 1.2) + 'px"></div>'
316
+ + '<header class="head" data-toggle="' + esc(e.id) + '">'
317
+ + '<span class="pill method-' + esc(normalize(e.method)) + '">' + esc(e.method) + '</span>'
318
+ + '<div><div class="path">' + esc(e.path) + '</div><div class="muted">' + esc(e.controller) + ' • ' + esc(e.operationName) + '</div></div>'
319
+ + '<span class="arrow">▼</span>'
320
+ + '</header>'
321
+ + '<section class="body">'
322
+ + '<div class="sec"><h4>Path Variables</h4>' + rows(e.pathVariables) + '</div>'
323
+ + '<div class="sec"><h4>Query Params</h4>' + rows(e.queryParams) + '</div>'
324
+ + '<div class="sec"><h4>Headers</h4>' + rows(e.headers) + '</div>'
325
+ + '<div class="sec"><h4>Request Body</h4><pre>' + json(e.requestSchema) + '</pre></div>'
326
+ + '<div class="sec"><h4>Response Body</h4><pre>' + json(e.responseSchema) + '</pre></div>'
327
+ + '<div class="sec"><h4>curl</h4><pre>' + esc(e.curl) + '</pre></div>'
328
+ + '<div class="sec"><button data-copy="' + esc(e.id) + '">Copy curl</button></div>'
329
+ + '</section>'
330
+ + '</article>';
331
+ }).join('');
332
+ attach();
333
+ }
334
+
335
+ function fillFilters(endpoints) {
336
+ var methods = Array.from(new Set(endpoints.map(function (e) { return e.method; }))).sort();
337
+ var ctrls = Array.from(new Set(endpoints.map(function (e) { return e.controller; }))).sort();
338
+ methodFilter.innerHTML = '<option value="all">All methods</option>' + methods.map(function (m) { return '<option value="' + esc(m) + '">' + esc(m) + '</option>'; }).join('');
339
+ controllerFilter.innerHTML = '<option value="all">All controllers</option>' + ctrls.map(function (c) { return '<option value="' + esc(c) + '">' + esc(c) + '</option>'; }).join('');
340
+ }
341
+
342
+ document.getElementById('search').addEventListener('input', function (ev) { state.search = ev.target.value || ''; render(); });
343
+ methodFilter.addEventListener('change', function (ev) { state.method = ev.target.value; render(); });
344
+ controllerFilter.addEventListener('change', function (ev) { state.controller = ev.target.value; render(); });
345
+
346
+ function applyModel(data) {
347
+ state.model = data;
348
+ fillFilters(data.endpoints || []);
349
+ render();
350
+ }
351
+
352
+ function loadFromEmbedded() {
353
+ var embedded = document.getElementById('embedded-ui-data');
354
+ if (!embedded || !embedded.textContent || embedded.textContent.trim() === '') return false;
355
+ try {
356
+ applyModel(JSON.parse(embedded.textContent));
357
+ return true;
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
362
+
363
+ function loadFromFetch() {
364
+ return fetch('ui-data.json')
365
+ .then(function (res) {
366
+ if (!res.ok) throw new Error('relative fetch failed');
367
+ return res.json();
368
+ })
369
+ .catch(function () {
370
+ return fetch('/ui-data.json').then(function (res) {
371
+ if (!res.ok) throw new Error('absolute fetch failed');
372
+ return res.json();
373
+ });
374
+ })
375
+ .then(function (data) { applyModel(data); });
376
+ }
377
+
378
+ if (!loadFromEmbedded()) {
379
+ loadFromFetch().catch(function () { stats.textContent = 'Failed to load UI data.'; });
380
+ }
381
+ </script>
382
+ </body>
383
+ </html>`;
384
+ }
385
+
386
+ function deref(schema: OpenApiSchema | undefined, openapi: OpenApiDocument): OpenApiSchema | undefined {
387
+ if (!schema) {
388
+ return undefined;
389
+ }
390
+ if (schema.$ref) {
391
+ const name = schema.$ref.split("/").at(-1);
392
+ if (name && openapi.components.schemas[name]) {
393
+ return openapi.components.schemas[name];
394
+ }
395
+ }
396
+ return schema;
397
+ }
398
+
399
+ function pickResponseSchema(
400
+ responses:
401
+ | Record<
402
+ string,
403
+ {
404
+ description: string;
405
+ content?: {
406
+ "application/json"?: { schema?: OpenApiSchema };
407
+ };
408
+ }
409
+ >
410
+ | undefined,
411
+ openapi: OpenApiDocument
412
+ ): OpenApiSchema | undefined {
413
+ if (!responses) {
414
+ return undefined;
415
+ }
416
+ const best = responses["200"] ?? responses["201"];
417
+ return deref(best?.content?.["application/json"]?.schema, openapi);
418
+ }
419
+
420
+ function buildCurl(endpoint: ExtractedEndpoint): string {
421
+ const parts = [`curl -X ${endpoint.httpMethod} "http://localhost:3000${endpoint.fullPath}"`];
422
+ for (const header of endpoint.headers) {
423
+ parts.push(`-H "${header.name}: <${header.name}>"`);
424
+ }
425
+ if (endpoint.requestBody) {
426
+ parts.push('-H "Content-Type: application/json"');
427
+ parts.push("-d '{ }'");
428
+ }
429
+ return parts.join(" \\\n+");
430
+ }
431
+
432
+ function guessController(sourceFile: string): string {
433
+ const file = sourceFile.replaceAll("\\", "/").split("/").at(-1) ?? sourceFile;
434
+ return file.endsWith(".kt") ? file.slice(0, -3) : file;
435
+ }
436
+
437
+ function escapeHtml(value: string): string {
438
+ return value
439
+ .replaceAll("&", "&amp;")
440
+ .replaceAll("<", "&lt;")
441
+ .replaceAll(">", "&gt;")
442
+ .replaceAll('"', "&quot;")
443
+ .replaceAll("'", "&#39;");
444
+ }
445
+
446
+ function escapeJsonForScriptTag(value: unknown): string {
447
+ return JSON.stringify(value)
448
+ .replaceAll("<", "\\u003c")
449
+ .replaceAll(">", "\\u003e")
450
+ .replaceAll("&", "\\u0026")
451
+ .replaceAll("</script", "<\\/script")
452
+ .replaceAll("\u2028", "\\u2028")
453
+ .replaceAll("\u2029", "\\u2029");
454
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseCliArgs } from "../src/args.js";
3
+
4
+ describe("parseCliArgs", () => {
5
+ it("parses required project path and defaults", () => {
6
+ const options = parseCliArgs(["/tmp/service"]);
7
+
8
+ expect(options.projectPath).toBe("/tmp/service");
9
+ expect(options.port).toBe(3000);
10
+ expect(options.serve).toBe(false);
11
+ expect(options.output).toBe("./api-docs");
12
+ expect(options.title).toBe("Spring API Docs");
13
+ expect(options.version).toBe("1.0.0");
14
+ });
15
+
16
+ it("supports explicit flags", () => {
17
+ const options = parseCliArgs([
18
+ "C:/service",
19
+ "--serve",
20
+ "--port",
21
+ "8080",
22
+ "--output",
23
+ "./out",
24
+ "--title",
25
+ "Orders API",
26
+ "--version",
27
+ "2.1.0"
28
+ ]);
29
+
30
+ expect(options.serve).toBe(true);
31
+ expect(options.port).toBe(8080);
32
+ expect(options.output).toBe("./out");
33
+ expect(options.title).toBe("Orders API");
34
+ expect(options.version).toBe("2.1.0");
35
+ });
36
+
37
+ it("supports no-serve override", () => {
38
+ const options = parseCliArgs(["/tmp/service", "--serve", "--no-serve"]);
39
+ expect(options.serve).toBe(false);
40
+ });
41
+
42
+ it("throws on missing project path", () => {
43
+ expect(() => parseCliArgs([])).toThrow(/project path/i);
44
+ });
45
+ });
@@ -0,0 +1,34 @@
1
+ package demo
2
+
3
+ import org.springframework.web.bind.annotation.DeleteMapping
4
+ import org.springframework.web.bind.annotation.GetMapping
5
+ import org.springframework.web.bind.annotation.PathVariable
6
+ import org.springframework.web.bind.annotation.PostMapping
7
+ import org.springframework.web.bind.annotation.RequestBody
8
+ import org.springframework.web.bind.annotation.RequestHeader
9
+ import org.springframework.web.bind.annotation.RequestMapping
10
+ import org.springframework.web.bind.annotation.RequestParam
11
+ import org.springframework.web.bind.annotation.RestController
12
+
13
+ @RestController
14
+ @RequestMapping("/api/users")
15
+ class UserController {
16
+ @GetMapping("/{id}")
17
+ fun getUser(
18
+ @PathVariable id: Long,
19
+ @RequestParam(required = false) includePosts: Boolean?,
20
+ @RequestHeader("X-Trace-Id") traceId: String
21
+ ): UserResponse {
22
+ TODO()
23
+ }
24
+
25
+ @PostMapping
26
+ fun createUser(@RequestBody body: CreateUserRequest): UserResponse {
27
+ TODO()
28
+ }
29
+
30
+ @DeleteMapping("/{id}")
31
+ fun deleteUser(@PathVariable id: Long): Unit {
32
+ TODO()
33
+ }
34
+ }
@@ -0,0 +1,22 @@
1
+ package demo
2
+
3
+ import com.fasterxml.jackson.databind.PropertyNamingStrategies
4
+ import com.fasterxml.jackson.databind.annotation.JsonNaming
5
+ import java.time.Instant
6
+
7
+ data class CreateUserRequest(
8
+ val firstName: String,
9
+ val createdAt: Instant
10
+ )
11
+
12
+ @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy::class)
13
+ data class UserResponse(
14
+ val userId: Long,
15
+ val firstName: String,
16
+ val createdAt: Instant,
17
+ val profile: UserProfile?
18
+ )
19
+
20
+ data class UserProfile(
21
+ val avatarUrl: String
22
+ )