go-duck-cli 1.3.25 → 1.3.31
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/generators/ai_docs.js +14 -11
- package/generators/postman.js +21 -18
- package/generators/swagger.js +8 -5
- package/package.json +1 -1
- package/templates/go/router.go.hbs +204 -14
package/generators/ai_docs.js
CHANGED
|
@@ -5,6 +5,9 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
|
|
|
5
5
|
const aiDocsDir = path.join(outputDir, 'docs', 'ai');
|
|
6
6
|
await fs.ensureDir(aiDocsDir);
|
|
7
7
|
|
|
8
|
+
const apiPrefixRaw = config.server?.rest?.['api-path-prefix'] || '/api';
|
|
9
|
+
const apiPrefix = apiPrefixRaw.endsWith('/') ? apiPrefixRaw.slice(0, -1) : apiPrefixRaw;
|
|
10
|
+
|
|
8
11
|
// 1. ARCHITECTURE.md
|
|
9
12
|
const appName = config.name || 'go-duck-app';
|
|
10
13
|
const hasMongo = config.datasource?.mongodb?.enabled;
|
|
@@ -32,7 +35,7 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
|
|
|
32
35
|
archContent += `- **Active**: ${storageActive ? 'Yes' : 'No'}\n`;
|
|
33
36
|
if (storageActive) {
|
|
34
37
|
archContent += `- **Enabled Nodes**: ${Object.keys(config.storage).filter(k => config.storage[k]?.enabled).join(', ')}\n`;
|
|
35
|
-
archContent += `- **Endpoints**: \n - Upload: \`POST /
|
|
38
|
+
archContent += `- **Endpoints**: \n - Upload: \`POST ${apiPrefix}/storage/upload?provider=\`\n - Exact Retrieve: \`GET ${apiPrefix}/storage/download/*key?provider=\`\n - Cross-Scan Locate: \`GET ${apiPrefix}/storage/scan/*key\`\n`;
|
|
36
39
|
}
|
|
37
40
|
const bootstrapActive = config.storage?.bootstrap?.enabled;
|
|
38
41
|
if (bootstrapActive) {
|
|
@@ -53,25 +56,25 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
|
|
|
53
56
|
|
|
54
57
|
// 2. ENDPOINTS.md
|
|
55
58
|
let endpointsContent = `# REST & gRPC API Surface\n\n`;
|
|
56
|
-
endpointsContent += `## Base Path:
|
|
59
|
+
endpointsContent += `## Base Path: \`${apiPrefix}\`\n\n`;
|
|
57
60
|
endpointsContent += `### Standard Entity Endpoints\n`;
|
|
58
61
|
for (const entity of entities) {
|
|
59
62
|
const routeName = entity.name.toLowerCase() + 's';
|
|
60
63
|
endpointsContent += `\n#### ${entity.name}\n`;
|
|
61
|
-
endpointsContent += `- \`GET
|
|
62
|
-
endpointsContent += `- \`GET
|
|
63
|
-
endpointsContent += `- \`POST
|
|
64
|
-
endpointsContent += `- \`PUT
|
|
65
|
-
endpointsContent += `- \`DELETE
|
|
66
|
-
endpointsContent += `- \`POST
|
|
64
|
+
endpointsContent += `- \`GET ${apiPrefix}/${routeName}\` (Pagination & dynamic sorting, e.g. \`?page=1&size=10&eager=true&sort=id,asc\`)\n`;
|
|
65
|
+
endpointsContent += `- \`GET ${apiPrefix}/${routeName}/:id\`\n`;
|
|
66
|
+
endpointsContent += `- \`POST ${apiPrefix}/${routeName}\`\n`;
|
|
67
|
+
endpointsContent += `- \`PUT ${apiPrefix}/${routeName}/:id\`\n`;
|
|
68
|
+
endpointsContent += `- \`DELETE ${apiPrefix}/${routeName}/:id\`\n`;
|
|
69
|
+
endpointsContent += `- \`POST ${apiPrefix}/${routeName}/bulk\` (Bulk Create/Update)\n`;
|
|
67
70
|
if (entity.isSearchable) {
|
|
68
|
-
endpointsContent += `- \`GET /
|
|
71
|
+
endpointsContent += `- \`GET ${apiPrefix}/search/${routeName}?q={query}\` (Elasticsearch)\n`;
|
|
69
72
|
}
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
endpointsContent += `\n## Infrastructure & Management APIs (SuperAdmin Protected)\n`;
|
|
73
|
-
endpointsContent += `- \`GET /
|
|
74
|
-
endpointsContent += `- \`POST /
|
|
76
|
+
endpointsContent += `- \`GET ${apiPrefix}/admin/audit\` (Fetches Global Delta logs from the Centralized Audit Engine)\n`;
|
|
77
|
+
endpointsContent += `- \`POST ${apiPrefix}/admin/metering/limit\` (Set SaaS Quota limits)\n`;
|
|
75
78
|
endpointsContent += `- \`POST /management/tenant/assign\` (Dynamically initialize new Tenant DBs)\n`;
|
|
76
79
|
|
|
77
80
|
endpointsContent += `\n## Open APIs (No JWT required)\n`;
|
package/generators/postman.js
CHANGED
|
@@ -4,6 +4,9 @@ import chalk from 'chalk';
|
|
|
4
4
|
|
|
5
5
|
export const generatePostmanCollection = async (config, entities, outputDir, openEntities = []) => {
|
|
6
6
|
const docsDir = path.join(outputDir, 'docs');
|
|
7
|
+
const apiPrefixRaw = config.server?.rest?.['api-path-prefix'] || '/api';
|
|
8
|
+
const apiPrefix = apiPrefixRaw.endsWith('/') ? apiPrefixRaw.slice(0, -1) : apiPrefixRaw;
|
|
9
|
+
const apiPathArr = apiPrefix.split('/').filter(p => p !== '');
|
|
7
10
|
await fs.ensureDir(docsDir);
|
|
8
11
|
|
|
9
12
|
const collection = {
|
|
@@ -140,11 +143,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
140
143
|
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
141
144
|
],
|
|
142
145
|
url: {
|
|
143
|
-
raw:
|
|
146
|
+
raw: `http://{{host}}:{{port}}${apiPrefix}/admin/audit`,
|
|
144
147
|
protocol: "http",
|
|
145
148
|
host: ["{{host}}"],
|
|
146
149
|
port: "{{port}}",
|
|
147
|
-
path: [
|
|
150
|
+
path: [...apiPathArr, "admin", "audit"]
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
},
|
|
@@ -157,11 +160,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
157
160
|
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
158
161
|
],
|
|
159
162
|
url: {
|
|
160
|
-
raw:
|
|
163
|
+
raw: `http://{{host}}:{{port}}${apiPrefix}/metering/usage`,
|
|
161
164
|
protocol: "http",
|
|
162
165
|
host: ["{{host}}"],
|
|
163
166
|
port: "{{port}}",
|
|
164
|
-
path: [
|
|
167
|
+
path: [...apiPathArr, "metering", "usage"]
|
|
165
168
|
}
|
|
166
169
|
}
|
|
167
170
|
}
|
|
@@ -390,7 +393,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
390
393
|
request: {
|
|
391
394
|
method: "GET",
|
|
392
395
|
header: [ { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
393
|
-
url: { raw: `http://{{host}}:{{port}}/open
|
|
396
|
+
url: { raw: `http://{{host}}:{{port}}/open${apiPrefix}/${name}s?page=1&size=10&eager=true&sort=id,asc`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", ...apiPathArr, `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" }, { key: "sort", value: "id,asc" } ] }
|
|
394
397
|
}
|
|
395
398
|
});
|
|
396
399
|
publicItems.push({
|
|
@@ -398,7 +401,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
398
401
|
request: {
|
|
399
402
|
method: "GET",
|
|
400
403
|
header: [ { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
401
|
-
url: { raw: `http://{{host}}:{{port}}/open
|
|
404
|
+
url: { raw: `http://{{host}}:{{port}}/open${apiPrefix}/${name}s/1`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", ...apiPathArr, `${name}s`, "1"] }
|
|
402
405
|
}
|
|
403
406
|
});
|
|
404
407
|
}
|
|
@@ -409,7 +412,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
409
412
|
method: "POST",
|
|
410
413
|
header: [ { key: "X-Tenant-ID", value: "{{tenant}}" }, { key: "Content-Type", value: "application/json" } ],
|
|
411
414
|
body: { mode: "raw", raw: generateDummyJson(entity.fields) },
|
|
412
|
-
url: { raw: `http://{{host}}:{{port}}/open
|
|
415
|
+
url: { raw: `http://{{host}}:{{port}}/open${apiPrefix}/${name}s`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", ...apiPathArr, `${name}s`] }
|
|
413
416
|
}
|
|
414
417
|
});
|
|
415
418
|
}
|
|
@@ -425,7 +428,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
425
428
|
request: {
|
|
426
429
|
method: "GET",
|
|
427
430
|
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
428
|
-
url: { raw: `http://{{host}}:{{port}}
|
|
431
|
+
url: { raw: `http://{{host}}:{{port}}${apiPrefix}/${name}s?page=1&size=10&eager=true&sort=id,asc`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: [...apiPathArr, `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" }, { key: "sort", value: "id,asc" } ] }
|
|
429
432
|
}
|
|
430
433
|
},
|
|
431
434
|
{
|
|
@@ -434,7 +437,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
434
437
|
method: "POST",
|
|
435
438
|
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" }, { key: "Content-Type", value: "application/json" } ],
|
|
436
439
|
body: { mode: "raw", raw: generateDummyJson(entity.fields) },
|
|
437
|
-
url: { raw: `http://{{host}}:{{port}}
|
|
440
|
+
url: { raw: `http://{{host}}:{{port}}${apiPrefix}/${name}s`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: [...apiPathArr, `${name}s`] }
|
|
438
441
|
}
|
|
439
442
|
},
|
|
440
443
|
{
|
|
@@ -442,7 +445,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
442
445
|
request: {
|
|
443
446
|
method: "GET",
|
|
444
447
|
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
445
|
-
url: { raw: `http://{{host}}:{{port}}
|
|
448
|
+
url: { raw: `http://{{host}}:{{port}}${apiPrefix}/${name}s/1`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: [...apiPathArr, `${name}s`, "1"] }
|
|
446
449
|
}
|
|
447
450
|
},
|
|
448
451
|
{
|
|
@@ -450,7 +453,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
450
453
|
request: {
|
|
451
454
|
method: "GET",
|
|
452
455
|
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
453
|
-
url: { raw: `http://{{host}}:{{port}}/
|
|
456
|
+
url: { raw: `http://{{host}}:{{port}}${apiPrefix}/rpc/${name}?id=gt.0`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: [...apiPathArr, "rpc", name], query: [ { key: "id", value: "gt.0" } ] }
|
|
454
457
|
}
|
|
455
458
|
}
|
|
456
459
|
];
|
|
@@ -461,7 +464,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
461
464
|
request: {
|
|
462
465
|
method: "GET",
|
|
463
466
|
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
464
|
-
url: { raw: `http://{{host}}:{{port}}/
|
|
467
|
+
url: { raw: `http://{{host}}:{{port}}${apiPrefix}/search/${name}?q=Sample`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: [...apiPathArr, "search", name], query: [ { key: "q", value: "Sample" } ] }
|
|
465
468
|
}
|
|
466
469
|
});
|
|
467
470
|
}
|
|
@@ -513,11 +516,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
513
516
|
]
|
|
514
517
|
},
|
|
515
518
|
url: {
|
|
516
|
-
raw:
|
|
519
|
+
raw: `http://{{host}}:{{port}}${apiPrefix}/storage/upload?provider=sftp`,
|
|
517
520
|
protocol: "http",
|
|
518
521
|
host: ["{{host}}"],
|
|
519
522
|
port: "{{port}}",
|
|
520
|
-
path: [
|
|
523
|
+
path: [...apiPathArr, "storage", "upload"],
|
|
521
524
|
query: [{ key: "provider", value: "sftp" }]
|
|
522
525
|
}
|
|
523
526
|
}
|
|
@@ -531,11 +534,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
531
534
|
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
532
535
|
],
|
|
533
536
|
url: {
|
|
534
|
-
raw:
|
|
537
|
+
raw: `http://{{host}}:{{port}}${apiPrefix}/storage/download/farm/animals/photo.jpg?provider=sftp`,
|
|
535
538
|
protocol: "http",
|
|
536
539
|
host: ["{{host}}"],
|
|
537
540
|
port: "{{port}}",
|
|
538
|
-
path: [
|
|
541
|
+
path: [...apiPathArr, "storage", "download", "farm", "animals", "photo.jpg"],
|
|
539
542
|
query: [{ key: "provider", value: "sftp" }]
|
|
540
543
|
}
|
|
541
544
|
}
|
|
@@ -549,11 +552,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
549
552
|
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
550
553
|
],
|
|
551
554
|
url: {
|
|
552
|
-
raw:
|
|
555
|
+
raw: `http://{{host}}:{{port}}${apiPrefix}/storage/scan/farm/animals/photo.jpg`,
|
|
553
556
|
protocol: "http",
|
|
554
557
|
host: ["{{host}}"],
|
|
555
558
|
port: "{{port}}",
|
|
556
|
-
path: [
|
|
559
|
+
path: [...apiPathArr, "storage", "scan", "farm", "animals", "photo.jpg"]
|
|
557
560
|
}
|
|
558
561
|
}
|
|
559
562
|
}
|
package/generators/swagger.js
CHANGED
|
@@ -6,6 +6,9 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
|
|
|
6
6
|
const docsDir = path.join(outputDir, 'docs');
|
|
7
7
|
await fs.ensureDir(docsDir);
|
|
8
8
|
|
|
9
|
+
const apiPrefixRaw = config.server?.rest?.['api-path-prefix'] || '/api';
|
|
10
|
+
const apiPrefix = apiPrefixRaw.endsWith('/') ? apiPrefixRaw.slice(0, -1) : apiPrefixRaw;
|
|
11
|
+
|
|
9
12
|
const swagger = {
|
|
10
13
|
openapi: '3.0.0',
|
|
11
14
|
info: {
|
|
@@ -201,14 +204,14 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
|
|
|
201
204
|
};
|
|
202
205
|
|
|
203
206
|
// 1a. Secured Paths
|
|
204
|
-
addEntityOperations(
|
|
207
|
+
addEntityOperations(apiPrefix, false);
|
|
205
208
|
|
|
206
209
|
// 1b. Public Paths (if marked as open)
|
|
207
|
-
addEntityOperations('/open
|
|
210
|
+
addEntityOperations('/open' + apiPrefix, true);
|
|
208
211
|
}
|
|
209
212
|
|
|
210
213
|
// 2. Add System Paths
|
|
211
|
-
swagger.paths[
|
|
214
|
+
swagger.paths[`${apiPrefix}/rpc/{table}`] = {
|
|
212
215
|
get: {
|
|
213
216
|
tags: ['Search Engine'],
|
|
214
217
|
summary: 'Generic PostgREST RPC Engine',
|
|
@@ -240,7 +243,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
|
|
|
240
243
|
}
|
|
241
244
|
};
|
|
242
245
|
|
|
243
|
-
swagger.paths[
|
|
246
|
+
swagger.paths[`${apiPrefix}/admin/audit`] = {
|
|
244
247
|
get: {
|
|
245
248
|
tags: ['Observability'],
|
|
246
249
|
summary: 'Fetch Audit Trail',
|
|
@@ -281,7 +284,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
|
|
|
281
284
|
};
|
|
282
285
|
|
|
283
286
|
if (config.elasticsearch?.enabled) {
|
|
284
|
-
swagger.paths[
|
|
287
|
+
swagger.paths[`${apiPrefix}/search/{entity}`] = {
|
|
285
288
|
get: {
|
|
286
289
|
tags: ['Search Engine'],
|
|
287
290
|
summary: 'Elasticsearch Global Search',
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"fmt"
|
|
6
6
|
"log"
|
|
7
7
|
"net/http"
|
|
8
|
+
"strings"
|
|
8
9
|
"time"
|
|
9
10
|
|
|
10
11
|
"github.com/gin-gonic/gin"
|
|
@@ -164,39 +165,228 @@ func SetupRouter(appConfig *config.Config) *gin.Engine {
|
|
|
164
165
|
// Swagger Docs & UI
|
|
165
166
|
r.StaticFile("/swagger.json", "./docs/swagger.json")
|
|
166
167
|
r.GET("/swagger", func(c *gin.Context) {
|
|
167
|
-
|
|
168
|
+
swaggerHTML := `<!DOCTYPE html>
|
|
168
169
|
<html lang="en">
|
|
169
170
|
<head>
|
|
170
171
|
<meta charset="UTF-8">
|
|
171
|
-
<title>Swagger UI</title>
|
|
172
|
+
<title>Swagger UI (Secured)</title>
|
|
172
173
|
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui.css" />
|
|
174
|
+
<script src="https://cdn.jsdelivr.net/npm/keycloak-js@24.0.4/dist/keycloak.min.js"></script>
|
|
173
175
|
<style>
|
|
174
176
|
html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
|
|
175
177
|
*, *:before, *:after { box-sizing: inherit; }
|
|
176
|
-
body { margin:0; background: #fafafa; }
|
|
178
|
+
body { margin:0; background: #fafafa; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
|
179
|
+
|
|
180
|
+
.top-nav {
|
|
181
|
+
background: rgba(255, 255, 255, 0.8);
|
|
182
|
+
backdrop-filter: blur(10px);
|
|
183
|
+
-webkit-backdrop-filter: blur(10px);
|
|
184
|
+
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
185
|
+
padding: 12px 24px;
|
|
186
|
+
display: flex;
|
|
187
|
+
justify-content: space-between;
|
|
188
|
+
align-items: center;
|
|
189
|
+
position: sticky;
|
|
190
|
+
top: 0;
|
|
191
|
+
z-index: 1000;
|
|
192
|
+
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.nav-brand {
|
|
196
|
+
font-weight: 700;
|
|
197
|
+
font-size: 1.2rem;
|
|
198
|
+
color: #333;
|
|
199
|
+
display: flex;
|
|
200
|
+
align-items: center;
|
|
201
|
+
gap: 10px;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.nav-controls {
|
|
205
|
+
display: flex;
|
|
206
|
+
align-items: center;
|
|
207
|
+
gap: 16px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.tenant-input {
|
|
211
|
+
padding: 8px 12px;
|
|
212
|
+
border: 1px solid #ddd;
|
|
213
|
+
border-radius: 6px;
|
|
214
|
+
font-size: 0.9rem;
|
|
215
|
+
outline: none;
|
|
216
|
+
transition: border-color 0.2s;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.tenant-input:focus {
|
|
220
|
+
border-color: #4a90e2;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.btn {
|
|
224
|
+
padding: 8px 16px;
|
|
225
|
+
border-radius: 6px;
|
|
226
|
+
border: none;
|
|
227
|
+
font-weight: 600;
|
|
228
|
+
cursor: pointer;
|
|
229
|
+
transition: all 0.2s;
|
|
230
|
+
font-size: 0.9rem;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.btn-login {
|
|
234
|
+
background: #4a90e2;
|
|
235
|
+
color: white;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.btn-login:hover {
|
|
239
|
+
background: #357abd;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.btn-logout {
|
|
243
|
+
background: #e74c3c;
|
|
244
|
+
color: white;
|
|
245
|
+
display: none;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.btn-logout:hover {
|
|
249
|
+
background: #c0392b;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.status-indicator {
|
|
253
|
+
display: flex;
|
|
254
|
+
align-items: center;
|
|
255
|
+
gap: 6px;
|
|
256
|
+
font-size: 0.85rem;
|
|
257
|
+
color: #666;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.dot {
|
|
261
|
+
width: 8px;
|
|
262
|
+
height: 8px;
|
|
263
|
+
border-radius: 50%;
|
|
264
|
+
background: #ccc;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.dot.active {
|
|
268
|
+
background: #2ecc71;
|
|
269
|
+
box-shadow: 0 0 8px #2ecc71;
|
|
270
|
+
}
|
|
177
271
|
</style>
|
|
178
272
|
</head>
|
|
179
273
|
<body>
|
|
274
|
+
<div class="top-nav">
|
|
275
|
+
<div class="nav-brand">
|
|
276
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
|
277
|
+
GO-DUCK API Explorer
|
|
278
|
+
</div>
|
|
279
|
+
<div class="nav-controls">
|
|
280
|
+
<div class="status-indicator">
|
|
281
|
+
<div class="dot" id="auth-dot"></div>
|
|
282
|
+
<span id="auth-status">Unauthenticated</span>
|
|
283
|
+
</div>
|
|
284
|
+
<input type="text" id="tenant-input" class="tenant-input" placeholder="X-Tenant-ID" value="tenant_1" title="Multi-tenant DB Target" />
|
|
285
|
+
<button id="login-btn" class="btn btn-login">Login with Keycloak</button>
|
|
286
|
+
<button id="logout-btn" class="btn btn-logout">Logout</button>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
180
290
|
<div id="swagger-ui"></div>
|
|
291
|
+
|
|
181
292
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui-bundle.js"></script>
|
|
182
293
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui-standalone-preset.js"></script>
|
|
183
294
|
<script>
|
|
295
|
+
const KEYCLOAK_URL = 'KEYCLOAK_URL_PLACEHOLDER';
|
|
296
|
+
const KEYCLOAK_REALM = 'KEYCLOAK_REALM_PLACEHOLDER';
|
|
297
|
+
const KEYCLOAK_CLIENT = 'KEYCLOAK_CLIENT_PLACEHOLDER';
|
|
298
|
+
|
|
299
|
+
let keycloak = null;
|
|
300
|
+
|
|
301
|
+
function updateUI(authenticated) {
|
|
302
|
+
if (authenticated) {
|
|
303
|
+
document.getElementById('login-btn').style.display = 'none';
|
|
304
|
+
document.getElementById('logout-btn').style.display = 'block';
|
|
305
|
+
document.getElementById('auth-dot').classList.add('active');
|
|
306
|
+
document.getElementById('auth-status').innerText = 'Authenticated (' + (keycloak.tokenParsed?.preferred_username || 'User') + ')';
|
|
307
|
+
} else {
|
|
308
|
+
document.getElementById('login-btn').style.display = 'block';
|
|
309
|
+
document.getElementById('logout-btn').style.display = 'none';
|
|
310
|
+
document.getElementById('auth-dot').classList.remove('active');
|
|
311
|
+
document.getElementById('auth-status').innerText = 'Unauthenticated';
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
184
315
|
window.onload = function() {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
316
|
+
// Initialize Keycloak
|
|
317
|
+
keycloak = new Keycloak({
|
|
318
|
+
url: KEYCLOAK_URL,
|
|
319
|
+
realm: KEYCLOAK_REALM,
|
|
320
|
+
clientId: KEYCLOAK_CLIENT
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
keycloak.init({ onLoad: 'check-sso', checkLoginIframe: false }).then(authenticated => {
|
|
324
|
+
updateUI(authenticated);
|
|
325
|
+
|
|
326
|
+
document.getElementById('login-btn').onclick = () => keycloak.login();
|
|
327
|
+
document.getElementById('logout-btn').onclick = () => keycloak.logout();
|
|
328
|
+
|
|
329
|
+
if (authenticated) {
|
|
330
|
+
// Auto-refresh token logic
|
|
331
|
+
setInterval(() => {
|
|
332
|
+
keycloak.updateToken(70).then(refreshed => {
|
|
333
|
+
if (refreshed) {
|
|
334
|
+
console.log('Token refreshed automatically');
|
|
335
|
+
}
|
|
336
|
+
}).catch(() => {
|
|
337
|
+
console.error('Failed to refresh token');
|
|
338
|
+
updateUI(false);
|
|
339
|
+
});
|
|
340
|
+
}, 60000); // Check every minute
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Initialize Swagger UI with Interceptor
|
|
344
|
+
window.ui = SwaggerUIBundle({
|
|
345
|
+
url: "/swagger.json",
|
|
346
|
+
dom_id: '#swagger-ui',
|
|
347
|
+
deepLinking: true,
|
|
348
|
+
presets: [
|
|
349
|
+
SwaggerUIBundle.presets.apis,
|
|
350
|
+
SwaggerUIStandalonePreset
|
|
351
|
+
],
|
|
352
|
+
layout: "StandaloneLayout",
|
|
353
|
+
requestInterceptor: (req) => {
|
|
354
|
+
if (keycloak && keycloak.token) {
|
|
355
|
+
req.headers['Authorization'] = 'Bearer ' + keycloak.token;
|
|
356
|
+
}
|
|
357
|
+
const tenantId = document.getElementById('tenant-input').value;
|
|
358
|
+
if (tenantId) {
|
|
359
|
+
req.headers['X-Tenant-ID'] = tenantId;
|
|
360
|
+
}
|
|
361
|
+
return req;
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}).catch(err => {
|
|
365
|
+
console.error("Keycloak initialization failed", err);
|
|
366
|
+
document.getElementById('auth-status').innerText = 'Keycloak init failed';
|
|
367
|
+
|
|
368
|
+
// Still load swagger without auth interceptor
|
|
369
|
+
window.ui = SwaggerUIBundle({
|
|
370
|
+
url: "/swagger.json",
|
|
371
|
+
dom_id: '#swagger-ui',
|
|
372
|
+
deepLinking: true,
|
|
373
|
+
presets: [
|
|
374
|
+
SwaggerUIBundle.presets.apis,
|
|
375
|
+
SwaggerUIStandalonePreset
|
|
376
|
+
],
|
|
377
|
+
layout: "StandaloneLayout"
|
|
378
|
+
});
|
|
194
379
|
});
|
|
195
|
-
window.ui = ui;
|
|
196
380
|
};
|
|
197
381
|
</script>
|
|
198
382
|
</body>
|
|
199
|
-
</html>`
|
|
383
|
+
</html>`
|
|
384
|
+
|
|
385
|
+
htmlStr := strings.ReplaceAll(swaggerHTML, "KEYCLOAK_URL_PLACEHOLDER", appConfig.GoDuck.Security.KeycloakHost)
|
|
386
|
+
htmlStr = strings.ReplaceAll(htmlStr, "KEYCLOAK_REALM_PLACEHOLDER", appConfig.GoDuck.Security.KeycloakRealm)
|
|
387
|
+
htmlStr = strings.ReplaceAll(htmlStr, "KEYCLOAK_CLIENT_PLACEHOLDER", appConfig.GoDuck.Security.KeycloakAppClientID)
|
|
388
|
+
|
|
389
|
+
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlStr))
|
|
200
390
|
})
|
|
201
391
|
|
|
202
392
|
// Management APIs (Run-time DB onboarding)
|