go-duck-cli 1.3.3 → 1.3.5

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/README.md CHANGED
@@ -59,8 +59,9 @@ GO-DUCK has officially reached the **410% Achievement Status**, evolving from a
59
59
  | **Silo Discovery & Privacy Proxy** | 🚀 **ELITE (+10%)** | Silo discovery API with physical DB name masking. |
60
60
  | **Universal Storage Mesh** | 🚀 **ELITE (+25%)** | Dynamic Hot-Swapping Registry and Distributed Cross-Scan API retrieval. |
61
61
  | **WSO2 API Gateway Integration** | 🚀 **ELITE (+15%)** | Automated OpenAPI registration & proxy mapping. |
62
+ | **API Gateway Standards & Swagger UI** | 🚀 **ELITE (+10%)** | Keycloak SSO, Glassmorphism UI, JHipster `/v3/api-docs` compliance. |
62
63
  | **Full-Spectrum GDL Evolution** | 🚀 **ELITE (+15%)** | Native DROP/ALTER migrations with dead-code purging. |
63
- | **TOTAL ACHIEVEMENT STATUS** | 🏆 **410%** | **ELITE STATUS CONFIRMED.** 👑 |
64
+ | **TOTAL ACHIEVEMENT STATUS** | 🏆 **420%** | **ELITE STATUS CONFIRMED.** 👑 |
64
65
 
65
66
  ### ✨ Primary Features (The 410% Core)
66
67
 
@@ -75,6 +76,8 @@ GO-DUCK has officially reached the **410% Achievement Status**, evolving from a
75
76
  * **Distributed Saga Consistency**: Integrated **Transactional Outbox** pattern and background workers in every silo to guarantee eventual consistency across the federation.
76
77
  * **Zero-Trust Identity Registry**: Decoupled mapping layer ensuring physical database names and internal IDs never leak to the client.
77
78
  * **Universal Storage Mesh**: Dynamic Multi-Provider Registry allowing hot-swapping at runtime via `?provider=` queries, alongside Distributed Cross-Scan endpoints to auto-locate files across AWS, GCS, SFTP, and GitHub lakes.
79
+ * **Enterprise API Gateway Compatibility**: Natively exposes the Swagger/OpenAPI JSON specification at `/v3/api-docs` to ensure drop-in compatibility with **JHipster**, **WSO2 API Manager**, and Spring Boot ecosystems.
80
+ * **Next-Gen Swagger UI**: A fully glassmorphism-styled Swagger UI with seamless Keycloak SSO integration. Features automatic JWT token refresh, dynamic `X-Tenant-ID` header injection, and CLI metadata branding. Now features a **Live Interactive MQTT Dictionary**, allowing users to instantly subscribe or publish payloads directly from the docs via WebSockets!
78
81
  * **Spring-style Elasticsearch Search**: Real-time sync for entities marked with `@Searchable`, supporting native `query_string` syntax (wildcards like `*`, booleans, ranges, and fuzzy matching).
79
82
  * **SaaS Quota Engine**: Redis-backed API bandwidth tracking with dynamic, hierarchical limits (User vs. Role mapping).
80
83
  * **Resilience Layer**: Sony/Gobreaker Integration + Zero-Trust Distributed Redis Rate Limiter.
@@ -2,6 +2,11 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import chalk from 'chalk';
4
4
 
5
+ const toKebabCase = (str) => {
6
+ if (!str) return '';
7
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
8
+ };
9
+
5
10
  export const generateConfigLoader = async (outputDir, configObj) => {
6
11
  const configDir = path.join(outputDir, 'config');
7
12
  await fs.ensureDir(configDir);
@@ -243,7 +248,7 @@ func LoadConfig() (*Config, error) {
243
248
  // Default values
244
249
  v.SetDefault("go-duck.server.rest.port", 8080)
245
250
  v.SetDefault("go-duck.server.rest.protocol", "json")
246
- v.SetDefault("go-duck.server.rest.api-path-prefix", "/${configObj.name || 'api'}/api")
251
+ v.SetDefault("go-duck.server.rest.api-path-prefix", "/${toKebabCase(configObj.name) || 'api'}/api")
247
252
  v.SetDefault("go-duck.security.rate-limit.rps", 100.0)
248
253
  v.SetDefault("go-duck.security.rate-limit.burst", 200)
249
254
  v.SetDefault("go-duck.logging.datadog.enabled", false)
@@ -13,10 +13,15 @@ export const generateDeploymentArtifacts = async (config, projectRootDir) => {
13
13
  await fs.ensureDir(keycloakDir);
14
14
  await fs.ensureDir(githubDir);
15
15
 
16
+ const toKebabCase = (str) => {
17
+ if (!str) return '';
18
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
19
+ };
16
20
  const appName = config.name || 'go-duck';
21
+ const k8sAppName = toKebabCase(appName);
17
22
  const parts = appName.split(/[-_]/);
18
23
  const shortName = (parts.length > 1 ? parts.map(w => w[0]).join('') : appName).substring(0, 10).toLowerCase() || 'goduck';
19
- const appPort = config.server?.port || 8080;
24
+ const appPort = config.server?.rest?.port || 8080;
20
25
  const keycloakHost = config.security?.['keycloak-host'] || 'http://localhost:8180';
21
26
  const keycloakPort = keycloakHost.includes(':') ? keycloakHost.split(':').pop() : '8080';
22
27
 
@@ -75,9 +80,11 @@ FROM gcr.io/distroless/static-debian12
75
80
  WORKDIR /app
76
81
 
77
82
  COPY --from=builder /app/server .
83
+
78
84
  COPY --from=builder /app/application.yml .
79
85
  COPY --from=builder /app/application-dev.yml .
80
86
  COPY --from=builder /app/application-prod.yml .
87
+ COPY --from=builder /app/docs/ /app/docs/
81
88
 
82
89
  EXPOSE ${appPort}
83
90
  ENV GO_PROFILE=prod
@@ -468,21 +475,21 @@ echo "========================================="
468
475
  const k8sAppYaml = `apiVersion: apps/v1
469
476
  kind: Deployment
470
477
  metadata:
471
- name: ${appName}
478
+ name: ${k8sAppName}
472
479
  labels:
473
- app: ${appName}
480
+ app: ${k8sAppName}
474
481
  spec:
475
482
  replicas: 1
476
483
  selector:
477
484
  matchLabels:
478
- app: ${appName}
485
+ app: ${k8sAppName}
479
486
  template:
480
487
  metadata:
481
488
  labels:
482
- app: ${appName}
489
+ app: ${k8sAppName}
483
490
  spec:
484
491
  containers:
485
- - name: ${appName}
492
+ - name: ${k8sAppName}
486
493
  image: ${appName}:latest
487
494
  env:
488
495
  - name: GO_PROFILE
@@ -490,7 +497,7 @@ spec:
490
497
  - name: GO_DUCK_SERVER_REST_PORT
491
498
  value: "${appPort}"
492
499
  - name: GO_DUCK_DATASOURCE_HOST
493
- value: postgres.${shortName}-postgres-k8s.svc.cluster.local
500
+ value: postgres.${k8sAppName}-postgres-k8s.svc.cluster.local
494
501
  - name: GO_DUCK_DATASOURCE_USERNAME
495
502
  value: "${config.datasource?.username || 'postgres'}"
496
503
  - name: GO_DUCK_DATASOURCE_PASSWORD
@@ -502,31 +509,31 @@ spec:
502
509
  - name: GO_DUCK_DATASOURCE_MONGODB_ENABLED
503
510
  value: "${config.datasource?.mongodb?.enabled ? 'true' : 'false'}"
504
511
  - name: GO_DUCK_DATASOURCE_MONGODB_URI
505
- value: mongodb://mongodb.${shortName}-mongodb-k8s.svc.cluster.local:27017
512
+ value: mongodb://mongodb.${k8sAppName}-mongodb-k8s.svc.cluster.local:27017
506
513
  - name: GO_DUCK_DATASOURCE_MONGODB_DATABASE
507
514
  value: "${config.datasource?.mongodb?.database || 'goduck_document_store'}"
508
515
  - name: GO_DUCK_CACHE_REDIS_HOST
509
- value: redis.${shortName}-redis-k8s.svc.cluster.local:6379
516
+ value: redis.${k8sAppName}-redis-k8s.svc.cluster.local:6379
510
517
  - name: GO_DUCK_CACHE_REDIS_PASSWORD
511
518
  value: "${config.cache?.redis?.password || ''}"
512
519
  - name: GO_DUCK_MESSAGING_MQTT_BROKER
513
- value: tcp://mosquitto.${shortName}-mosquitto-k8s.svc.cluster.local:1883
520
+ value: tcp://mosquitto.${k8sAppName}-mosquitto-k8s.svc.cluster.local:1883
514
521
  - name: GO_DUCK_MESSAGING_MQTT_USERNAME
515
522
  value: "${config.messaging?.mqtt?.username || 'dev_user'}"
516
523
  - name: GO_DUCK_MESSAGING_MQTT_PASSWORD
517
524
  value: "${config.messaging?.mqtt?.password || 'dev_password'}"
518
525
  - name: GO_DUCK_MESSAGING_NATS_URL
519
- value: nats://nats.${shortName}-nats-k8s.svc.cluster.local:4222
526
+ value: nats://nats.${k8sAppName}-nats-k8s.svc.cluster.local:4222
520
527
  - name: GO_DUCK_TELEMETRY_OTEL_ENDPOINT
521
- value: otel-collector.${shortName}-otel-collector-k8s.svc.cluster.local:4317
528
+ value: otel-collector.${k8sAppName}-otel-collector-k8s.svc.cluster.local:4317
522
529
  - name: GO_DUCK_ELASTICSEARCH_ADDRESSES
523
- value: http://elasticsearch.${shortName}-elasticsearch-k8s.svc.cluster.local:9200
530
+ value: http://elasticsearch.${k8sAppName}-elasticsearch-k8s.svc.cluster.local:9200
524
531
  - name: GO_DUCK_ELASTICSEARCH_USERNAME
525
532
  value: "${config.elasticsearch?.username || 'elastic'}"
526
533
  - name: GO_DUCK_ELASTICSEARCH_PASSWORD
527
534
  value: "${config.elasticsearch?.password || 'changeme'}"
528
535
  - name: GO_DUCK_SECURITY_KEYCLOAK_HOST
529
- value: http://keycloak.${shortName}-keycloak-k8s.svc.cluster.local:8080
536
+ value: http://keycloak.${k8sAppName}-keycloak-k8s.svc.cluster.local:8080
530
537
  - name: GO_DUCK_SECURITY_KEYCLOAK_REALM
531
538
  value: "${config.security?.['keycloak-realm'] || 'go-duck-realm'}"
532
539
  - name: GO_DUCK_SECURITY_KEYCLOAK_APP_CLIENT_ID
@@ -536,7 +543,7 @@ spec:
536
543
  - name: GO_DUCK_SECURITY_KEYCLOAK_SERVICE_SECRET
537
544
  value: "${config.security?.['keycloak-service-secret'] || 'service-secret-123'}"
538
545
  - name: GO_DUCK_STORAGE_MINIO_ENDPOINT
539
- value: "http://minio.${shortName}-minio-k8s.svc.cluster.local:9000"
546
+ value: "http://minio.${k8sAppName}-minio-k8s.svc.cluster.local:9000"
540
547
  - name: GO_DUCK_STORAGE_MINIO_ACCESS_KEY
541
548
  value: "${config.storage?.minio?.['access-key'] || 'minioadmin'}"
542
549
  - name: GO_DUCK_STORAGE_MINIO_SECRET_KEY
@@ -550,7 +557,7 @@ spec:
550
557
  apiVersion: v1
551
558
  kind: Service
552
559
  metadata:
553
- name: ${appName}
560
+ name: ${k8sAppName}
554
561
  spec:
555
562
  type: NodePort
556
563
  ports:
@@ -563,7 +570,7 @@ spec:
563
570
  targetPort: grpc
564
571
  nodePort: 30090
565
572
  selector:
566
- app: ${appName}
573
+ app: ${k8sAppName}
567
574
  `;
568
575
 
569
576
  const k8sPostgresYaml = `apiVersion: v1
@@ -1001,7 +1008,7 @@ spec:
1001
1008
  await fs.writeFile(path.join(k8sDir, 'mosquitto.conf'), mosquittoConf);
1002
1009
  // Namespace calculation: unique namespace per service
1003
1010
  const applyNs = (yamlString, serviceName) => {
1004
- const nsName = `${shortName}-${serviceName}-k8s`;
1011
+ const nsName = `${k8sAppName}-${serviceName}-k8s`;
1005
1012
  const nsBlock = `apiVersion: v1\nkind: Namespace\nmetadata:\n name: ${nsName}\n---\n`;
1006
1013
  return nsBlock + yamlString.replace(/^metadata:$/gm, `metadata:\n namespace: ${nsName}`);
1007
1014
  };
@@ -1012,7 +1019,7 @@ spec:
1012
1019
  // We conditionally add minio if storage is enabled (or any blob provider), but for safety we'll just generate its namespace always.
1013
1020
  servicesList.push('minio');
1014
1021
 
1015
- const k8sNamespaceYaml = servicesList.map(svc => `apiVersion: v1\nkind: Namespace\nmetadata:\n name: ${shortName}-${svc}-k8s`).join('\n---\n');
1022
+ const k8sNamespaceYaml = servicesList.map(svc => `apiVersion: v1\nkind: Namespace\nmetadata:\n name: ${k8sAppName}-${svc}-k8s`).join('\n---\n');
1016
1023
 
1017
1024
  await fs.writeFile(path.join(k8sDir, 'namespace.yaml'), k8sNamespaceYaml);
1018
1025
 
@@ -72,7 +72,7 @@ export const generateDocumentation = async (config, entities, outputDir, enums =
72
72
  entities: entities,
73
73
  enums: enums,
74
74
  openEntities: openEntities,
75
- serverPort: config.server?.port || 8080,
75
+ serverPort: config.server?.rest?.port || 8080,
76
76
  grpcPort: grpcPort,
77
77
  mqttPort: mqttPort
78
78
  };
@@ -0,0 +1,54 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+
5
+ export const generateMQTTTopicsJSON = async (config, entities, outputDir) => {
6
+ const docsDir = path.join(outputDir, 'docs');
7
+ await fs.ensureDir(docsDir);
8
+
9
+ const jsonFilePath = path.join(docsDir, 'mqtt_topics.json5');
10
+ const needle = '// needle-mqtt-topics-add-below';
11
+
12
+ let content = '';
13
+ if (await fs.pathExists(jsonFilePath)) {
14
+ content = await fs.readFile(jsonFilePath, 'utf8');
15
+ } else {
16
+ content = `{
17
+ "topics": {
18
+ // Custom topics can be added here manually.
19
+
20
+ ${needle}
21
+ }
22
+ }`;
23
+ }
24
+
25
+ const topicPrefix = config.messaging?.mqtt?.['topic-prefix'] || 'go-duck/events';
26
+ let newEntries = '';
27
+
28
+ for (const entity of entities) {
29
+ const entityName = entity.name.toLowerCase();
30
+ const baseTopic = `${topicPrefix}/${entityName}`;
31
+
32
+ const createdTopic = `"${baseTopic}/created"`;
33
+ if (!content.includes(createdTopic)) {
34
+ newEntries += ` // Auto-generated topics for ${entity.name}\n`;
35
+ newEntries += ` ${createdTopic}: "Triggered when a new ${entity.name} is created.",\n`;
36
+ newEntries += ` "${baseTopic}/updated": "Triggered when an existing ${entity.name} is updated.",\n`;
37
+ newEntries += ` "${baseTopic}/deleted": "Triggered when a ${entity.name} is deleted.",\n\n`;
38
+ }
39
+ }
40
+
41
+ if (newEntries.length > 0) {
42
+ if (content.includes(needle)) {
43
+ content = content.replace(needle, newEntries + ` ${needle}`);
44
+ } else {
45
+ // Fallback if needle is missing somehow
46
+ content = content.replace(/}\s*}$/, `,\n${newEntries}\n }\n}`);
47
+ }
48
+
49
+ await fs.writeFile(jsonFilePath, content);
50
+ console.log(chalk.gray(' Updated MQTT Topics Dictionary: mqtt_topics.json5'));
51
+ } else {
52
+ console.log(chalk.gray(' MQTT Topics Dictionary up to date.'));
53
+ }
54
+ };
@@ -18,7 +18,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
18
18
  item: [],
19
19
  variable: [
20
20
  { key: "host", value: "localhost", type: "string" },
21
- { key: "port", value: String(config.server?.port || 8080), type: "string" },
21
+ { key: "port", value: String(config.server?.rest?.port || 8080), type: "string" },
22
22
  { key: "tenant", value: "tenant_1", type: "string" },
23
23
  { key: "token", value: "", type: "string" },
24
24
  { key: "keycloak_url", value: config.security?.['keycloak-host'] || "http://localhost:8180", type: "string" },
@@ -364,8 +364,9 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
364
364
  else if (f.type === 'Float' || f.type === 'BigDecimal') obj[f.name] = 99.99;
365
365
  else if (f.type === 'Boolean') obj[f.name] = true;
366
366
  else if (f.type === 'LocalDate') obj[f.name] = "2024-01-01";
367
- else if (f.type === 'Instant') obj[f.name] = "2024-01-01T12:00:00Z";
367
+ else if (f.type === 'Instant' || f.type === 'DateTime' || f.type === 'Datetime') obj[f.name] = "2024-01-01T12:00:00Z";
368
368
  else if (f.type === 'JSON' || f.type === 'JSONB') obj[f.name] = {"attribute": "example_value"};
369
+ else if (f.isEnum) obj[f.name] = "ACTIVE"; // Safe fallback for enums
369
370
  else obj[f.name] = "test";
370
371
  }
371
372
  return JSON.stringify(obj, null, 2);
@@ -19,10 +19,17 @@ export const generateRouterCode = async (outputDir, config, entities, openEntiti
19
19
  const templateSource = await fs.readFile(templatePath, 'utf8');
20
20
  const template = Handlebars.compile(templateSource);
21
21
 
22
+ const packageJsonPath = path.resolve(__dirname, '../package.json');
23
+ const packageJson = await fs.readJson(packageJsonPath);
24
+ const cliVersion = packageJson.version;
25
+ const generatedDate = new Date().toISOString();
26
+
22
27
  const content = template({
23
28
  app_name: config.name,
24
29
  entities,
25
- openEntities
30
+ openEntities,
31
+ cli_version: cliVersion,
32
+ generated_date: generatedDate
26
33
  });
27
34
 
28
35
  await fs.writeFile(path.join(routerDir, 'router.go'), content);
@@ -17,7 +17,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
17
17
  description: `Generated documentation for ${config.name} microservice`
18
18
  },
19
19
  servers: [
20
- { url: `http://localhost:${config.server?.port || 8080}`, description: 'Local Development Server' }
20
+ { url: "/", description: "Current Host" }
21
21
  ],
22
22
  paths: {},
23
23
  components: {
@@ -51,7 +51,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
51
51
  };
52
52
 
53
53
  const commonHeaders = [
54
- { name: 'X-Tenant-ID', in: 'header', required: true, schema: { type: 'string', default: 'default' }, description: 'Multi-tenancy context identifier' }
54
+ { name: 'X-Tenant-ID', in: 'header', required: false, schema: { type: 'string', default: 'default' }, description: 'Multi-tenancy context identifier' }
55
55
  ];
56
56
 
57
57
  const isOpen = (entityName, action) => {
@@ -84,7 +84,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
84
84
  properties: {
85
85
  id: { type: 'integer' },
86
86
  ...entity.fields.reduce((acc, field) => {
87
- acc[field.name] = { type: mapToSwaggerType(field.type) };
87
+ acc[field.name] = getSwaggerFieldSchema(field.type);
88
88
  return acc;
89
89
  }, {}),
90
90
  createdAt: { type: 'string', format: 'date-time' },
@@ -273,7 +273,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
273
273
  }
274
274
  },
275
275
  parameters: [
276
- { name: 'X-Tenant-ID', in: 'header', required: true, schema: { type: 'string', default: 'master_internal' }, description: 'SuperAdmin internal master bypass token' }
276
+ { name: 'X-Tenant-ID', in: 'header', required: false, schema: { type: 'string', default: 'master_internal' }, description: 'SuperAdmin internal master bypass token' }
277
277
  ],
278
278
  responses: {
279
279
  200: { description: 'Success' },
@@ -318,19 +318,19 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
318
318
  console.log(chalk.gray(' Generated Swagger Documentation: swagger.json'));
319
319
  };
320
320
 
321
- const mapToSwaggerType = (type) => {
322
- const types = {
323
- 'String': 'string',
324
- 'Integer': 'integer',
325
- 'Float': 'number',
326
- 'Boolean': 'boolean',
327
- 'Long': 'integer',
328
- 'BigDecimal': 'number',
329
- 'LocalDate': 'string',
330
- 'Instant': 'string',
331
- 'JSON': 'object',
332
- 'JSONB': 'object',
333
- 'Text': 'string'
321
+ const getSwaggerFieldSchema = (type) => {
322
+ const schemas = {
323
+ 'String': { type: 'string' },
324
+ 'Integer': { type: 'integer', format: 'int32' },
325
+ 'Float': { type: 'number', format: 'double' },
326
+ 'Boolean': { type: 'boolean' },
327
+ 'Long': { type: 'integer', format: 'int64' },
328
+ 'BigDecimal': { type: 'number', format: 'double' },
329
+ 'LocalDate': { type: 'string', format: 'date' },
330
+ 'Instant': { type: 'string', format: 'date-time' },
331
+ 'JSON': { type: 'object' },
332
+ 'JSONB': { type: 'object' },
333
+ 'Text': { type: 'string' }
334
334
  };
335
- return types[type] || 'string';
335
+ return schemas[type] || { type: 'string' };
336
336
  };
package/index.js CHANGED
@@ -31,6 +31,7 @@ import { generateWebSocketCode } from './generators/websocket.js';
31
31
  import { generateConfigLoader } from './generators/config.js';
32
32
  import { generateLoggerCode } from './generators/logger.js';
33
33
  import { generateMQTTCode } from './generators/mqtt.js';
34
+ import { generateMQTTTopicsJSON } from './generators/mqtt-topics.js';
34
35
  import { generateCacheCode } from './generators/cache.js';
35
36
  import { generateResilienceCode } from './generators/resilience.js';
36
37
  import { generateTelemetryCode } from './generators/telemetry.js';
@@ -875,13 +876,21 @@ const generateYAMLConfigs = async (config, outputDir) => {
875
876
  delete cleanConfig.datasource['multitenancy-databases'];
876
877
  }
877
878
 
879
+ const toKebabCase = (str) => {
880
+ if (!str) return '';
881
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
882
+ };
883
+
884
+ const rawApiPrefix = cleanConfig.server?.rest?.['api-path-prefix'] || `/${cleanConfig.name || 'api'}/api`;
885
+ const kebabApiPrefix = rawApiPrefix.split('/').map(segment => toKebabCase(segment)).join('/');
886
+
878
887
  const extendedConfig = {
879
888
  ...cleanConfig,
880
889
  server: {
881
890
  rest: {
882
891
  port: cleanConfig.server?.rest?.port || cleanConfig.server?.port || 8080,
883
892
  protocol: cleanConfig.server?.rest?.protocol || 'json',
884
- 'api-path-prefix': cleanConfig.server?.rest?.['api-path-prefix'] || `/${cleanConfig.name || 'api'}/api`
893
+ 'api-path-prefix': kebabApiPrefix
885
894
  },
886
895
  'read-timeout': cleanConfig.server?.['read-timeout'] || '30s',
887
896
  'write-timeout': cleanConfig.server?.['write-timeout'] || '30s',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "go-duck-cli",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "The Ultimate Evolutionary Go Microservice Scaffolder.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -10,6 +10,9 @@
10
10
  "scripts": {
11
11
  "test": "echo \"Error: no test specified\" && exit 1"
12
12
  },
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
13
16
  "author": "heavenscode",
14
17
  "license": "ISC",
15
18
  "dependencies": {
@@ -21,5 +24,23 @@
21
24
  "inquirer": "^8.2.7",
22
25
  "js-yaml": "^4.1.1",
23
26
  "open": "^11.0.0"
27
+ },
28
+ "keywords": [
29
+ "go",
30
+ "golang",
31
+ "microservice",
32
+ "scaffold",
33
+ "cli",
34
+ "generator",
35
+ "go-duck"
36
+ ],
37
+
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/heavenscode/go-duck.git"
41
+ },
42
+ "homepage": "https://goduck.theheavenscode.com",
43
+ "bugs": {
44
+ "url": "https://github.com/heavenscode/go-duck/issues"
24
45
  }
25
46
  }
@@ -16,7 +16,7 @@
16
16
  <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
17
17
  <span class="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
18
18
  </span>
19
- <p class="text-[11px] font-black text-indigo-900 uppercase tracking-[0.25em]">The 395% Elite Milestone Surpassed</p>
19
+ <p class="text-[11px] font-black text-indigo-900 uppercase tracking-[0.25em]">The 420% Elite Milestone Surpassed</p>
20
20
  </div>
21
21
 
22
22
  <!-- Heroic Logo Anchor (Enhanced) -->
@@ -121,6 +121,16 @@
121
121
  </div>
122
122
  </div>
123
123
 
124
+ <div class="lg:col-span-12 bg-white p-10 rounded-[2.5rem] border border-amber-100 shadow-sm hover:shadow-2xl transition-all group relative overflow-hidden cursor-crosshair bg-gradient-to-br from-white to-amber-50/30">
125
+ <p class="text-[9px] font-bold text-amber-600 uppercase tracking-widest mb-4">Elite Extension (+10%)</p>
126
+ <h4 class="text-2xl font-black text-slate-900 mb-3 tracking-tight italic">API Gateway Standards & Swagger UI</h4>
127
+ <p class="text-slate-600 leading-relaxed mb-8">Natively exposes OpenAPI JSON at the JHipster-standard <code>/v3/api-docs</code> endpoint. Features a completely re-engineered, glassmorphism Swagger UI deeply integrated with Keycloak SSO and automatic token refreshing.</p>
128
+ <div class="flex gap-3">
129
+ <span class="px-4 py-1.5 bg-amber-100 text-amber-700 text-[9px] font-black rounded-lg">JHIPSTER COMPATIBLE</span>
130
+ <span class="px-4 py-1.5 bg-amber-100 text-amber-700 text-[9px] font-black rounded-lg">KEYCLOAK SSO UI</span>
131
+ </div>
132
+ </div>
133
+
124
134
  <div class="lg:col-span-12 bg-white p-10 rounded-[2.5rem] border border-fuchsia-100 shadow-sm hover:shadow-2xl transition-all group relative overflow-hidden cursor-crosshair bg-gradient-to-br from-white to-fuchsia-50/30">
125
135
  <p class="text-[9px] font-bold text-fuchsia-600 uppercase tracking-widest mb-4">Elite Extension (+12%)</p>
126
136
  <h4 class="text-2xl font-black text-slate-900 mb-3 tracking-tight italic">K8s Network Isolation & NodePorts</h4>
@@ -436,7 +446,7 @@
436
446
  </div>
437
447
 
438
448
  <div class="mt-24 text-slate-500 font-mono text-[10px] uppercase tracking-[0.6em] font-black relative z-10">
439
- GO-DUCK &bull; THE 395% ELITE MILESTONE &bull; SOVEREIGN CODE ORCHESTRATION
449
+ GO-DUCK &bull; THE 420% ELITE MILESTONE &bull; SOVEREIGN CODE ORCHESTRATION
440
450
  </div>
441
451
  </div>
442
452
  </section>
@@ -86,8 +86,13 @@ class ApiProvider {
86
86
  <h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">3. WSO2 API Manager Integration</h2>
87
87
  <p class="mb-4">GO-DUCK generated APIs can automatically register themselves with a <strong>WSO2 API Manager</strong> instance on startup. This uses the WSO2 Publisher REST API to import your dynamically generated OpenAPI 3.0 specs.</p>
88
88
 
89
+ <div class="bg-indigo-50 border-l-4 border-indigo-500 p-4 mb-6 rounded-r">
90
+ <h4 class="font-bold text-indigo-900 mb-1">Standard API Gateway Discovery</h4>
91
+ <p class="text-sm text-indigo-800">For external gateways (Kong, Apigee) and ecosystems like <strong>JHipster</strong> and <strong>Spring Boot</strong>, your microservice natively exposes its OpenAPI JSON at the standard <code>/v3/api-docs</code> endpoint. Legacy systems can still use <code>/swagger.json</code>.</p>
92
+ </div>
93
+
89
94
  <div class="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6 rounded-r">
90
- <p class="text-blue-900"><strong>Configuration:</strong> Enable this by adding the <code>wso2</code> block to your <code>config.yaml</code>.</p>
95
+ <p class="text-blue-900"><strong>WSO2 Configuration:</strong> Enable this by adding the <code>wso2</code> block to your <code>config.yaml</code>.</p>
91
96
  </div>
92
97
 
93
98
  <pre><code class="language-yaml">go-duck:
@@ -29,6 +29,12 @@ messaging.Publish("events/users", event)</code></pre>
29
29
  mosquitto_sub -h localhost -p {{mqttPort}} -t "audit/logs/#" -v</code></pre>
30
30
  </section>
31
31
 
32
+ <section class="mb-10">
33
+ <h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">Interactive Swagger Console</h2>
34
+ <p class="mb-4">GO-DUCK's automatically generated Swagger UI features a built-in <strong>Interactive MQTT Topics Dictionary</strong>. By connecting via WebSockets, it allows authenticated users to dynamically <strong>SUBSCRIBE</strong> to event streams or <strong>PUBLISH</strong> payloads directly from the documentation interface!</p>
35
+ <p class="mb-4 text-sm text-gray-600 italic">Note: Ensure your Mosquitto broker is configured to accept WebSocket connections (default port 9001) for this feature to work.</p>
36
+ </section>
37
+
32
38
  <section class="mb-10">
33
39
  <h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">External Resources</h2>
34
40
  <ul class="space-y-2">
@@ -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"
@@ -162,41 +163,492 @@ func SetupRouter(appConfig *config.Config) *gin.Engine {
162
163
  })
163
164
 
164
165
  // Swagger Docs & UI
165
- r.StaticFile("/swagger.json", "./docs/swagger.json")
166
+ // Expose standard OpenAPI endpoints (JHipster / Spring / WSO2 compatibility)
167
+ r.StaticFile("/v3/api-docs", "./docs/swagger.json")
168
+ r.StaticFile("/swagger.json", "./docs/swagger.json") // Legacy fallback
169
+ r.StaticFile("/api/mqtt-topics", "./docs/mqtt_topics.json5")
170
+ r.StaticFile("/logo.png", "./docs/web/logo.png")
166
171
  r.GET("/swagger", func(c *gin.Context) {
167
- c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`<!DOCTYPE html>
172
+ swaggerHTML := `<!DOCTYPE html>
168
173
  <html lang="en">
169
174
  <head>
170
175
  <meta charset="UTF-8">
171
- <title>Swagger UI</title>
176
+ <title>Swagger UI (Secured)</title>
172
177
  <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui.css" />
178
+ <script src="https://cdn.jsdelivr.net/npm/keycloak-js@24.0.4/dist/keycloak.min.js"></script>
179
+ <script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
173
180
  <style>
174
181
  html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
175
182
  *, *:before, *:after { box-sizing: inherit; }
176
- body { margin:0; background: #fafafa; }
183
+ body { margin:0; background: #fafafa; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
184
+
185
+ .top-nav {
186
+ background: rgba(255, 255, 255, 0.8);
187
+ backdrop-filter: blur(10px);
188
+ -webkit-backdrop-filter: blur(10px);
189
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
190
+ padding: 12px 24px;
191
+ display: flex;
192
+ justify-content: space-between;
193
+ align-items: center;
194
+ position: sticky;
195
+ top: 0;
196
+ z-index: 1000;
197
+ box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);
198
+ }
199
+
200
+ .nav-brand {
201
+ font-weight: 700;
202
+ font-size: 1.2rem;
203
+ color: #333;
204
+ display: flex;
205
+ align-items: center;
206
+ gap: 10px;
207
+ }
208
+
209
+ .nav-controls {
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 16px;
213
+ }
214
+
215
+ .tenant-input {
216
+ padding: 8px 12px;
217
+ border: 1px solid #ddd;
218
+ border-radius: 6px;
219
+ font-size: 0.9rem;
220
+ outline: none;
221
+ transition: border-color 0.2s;
222
+ }
223
+
224
+ .tenant-input:focus {
225
+ border-color: #4a90e2;
226
+ }
227
+
228
+ .btn {
229
+ padding: 8px 16px;
230
+ border-radius: 6px;
231
+ border: none;
232
+ font-weight: 600;
233
+ cursor: pointer;
234
+ transition: all 0.2s;
235
+ font-size: 0.9rem;
236
+ }
237
+
238
+ .btn-login {
239
+ background: #4a90e2;
240
+ color: white;
241
+ }
242
+
243
+ .btn-login:hover {
244
+ background: #357abd;
245
+ }
246
+
247
+ .btn-logout {
248
+ background: #e74c3c;
249
+ color: white;
250
+ display: none;
251
+ }
252
+
253
+ .btn-logout:hover {
254
+ background: #c0392b;
255
+ }
256
+
257
+ .status-indicator {
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 6px;
261
+ font-size: 0.85rem;
262
+ color: #666;
263
+ }
264
+
265
+ .dot {
266
+ width: 8px;
267
+ height: 8px;
268
+ border-radius: 50%;
269
+ background: #ccc;
270
+ }
271
+
272
+ .dot.active {
273
+ background: #2ecc71;
274
+ box-shadow: 0 0 8px #2ecc71;
275
+ }
276
+
277
+ /* MQTT Topics Viewer */
278
+ #mqtt-modal {
279
+ display: none;
280
+ position: fixed;
281
+ top: 0; left: 0; width: 100%; height: 100%;
282
+ background: rgba(0,0,0,0.6);
283
+ z-index: 2000;
284
+ justify-content: center;
285
+ align-items: center;
286
+ }
287
+ .mqtt-content {
288
+ background: #000;
289
+ width: 80%; max-width: 900px;
290
+ border-radius: 8px;
291
+ padding: 20px;
292
+ color: #00ff00;
293
+ font-family: monospace;
294
+ position: relative;
295
+ box-shadow: 0 10px 25px rgba(0,0,0,0.5);
296
+ }
297
+ .mqtt-header {
298
+ display: flex;
299
+ justify-content: space-between;
300
+ margin-bottom: 10px;
301
+ border-bottom: 1px solid #333;
302
+ padding-bottom: 10px;
303
+ }
304
+ .mqtt-close { color: white; cursor: pointer; font-size: 1.5rem; }
305
+ .mqtt-dl { background: #333; color: white; border: none; padding: 5px 10px; cursor: pointer; border-radius: 4px; }
306
+ .mqtt-dl:hover { background: #555; }
307
+
308
+ .syntax-punct { color: #ff0000; }
309
+
310
+ /* Interactive MQTT Actions */
311
+ .mqtt-action-btn {
312
+ margin-left: 10px; padding: 2px 8px; border: none; border-radius: 4px;
313
+ font-size: 0.8rem; font-weight: bold; color: white; cursor: pointer;
314
+ vertical-align: middle;
315
+ }
316
+ #mqtt-action-modal {
317
+ display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
318
+ background: rgba(0,0,0,0.8); z-index: 3000; justify-content: center; align-items: center;
319
+ }
320
+ .mqtt-action-content {
321
+ background: #1e1e1e; width: 80%; max-width: 800px; border-radius: 8px; padding: 20px;
322
+ color: #fff; font-family: monospace; position: relative;
323
+ }
324
+ .mqtt-console {
325
+ background: #000; color: #0f0; padding: 10px; height: 300px; overflow-y: auto;
326
+ border-radius: 4px; margin-top: 10px; border: 1px solid #333; white-space: pre-wrap;
327
+ }
328
+ .mqtt-editor {
329
+ width: 100%; height: 200px; background: #000; color: #0f0; font-family: monospace;
330
+ padding: 10px; border: 1px solid #333; border-radius: 4px; margin-top: 10px;
331
+ }
332
+ .broker-input {
333
+ background: #333; color: #fff; border: 1px solid #555; padding: 5px;
334
+ border-radius: 4px; width: 250px; margin-left: 15px; font-family: monospace;
335
+ }
177
336
  </style>
178
337
  </head>
179
338
  <body>
339
+ <div class="top-nav">
340
+ <div class="nav-brand">
341
+ <img src="/logo.png" alt="GO-DUCK Logo" style="height: 32px; width: auto;" />
342
+ <div style="display: flex; flex-direction: column; line-height: 1.2;">
343
+ <span>{{app_name}} API Explorer</span>
344
+ <span style="font-size: 0.65em; font-weight: normal; color: #94a3b8;">
345
+ GO-DUCK v{{cli_version}} • Generated: {{generated_date}}
346
+ </span>
347
+ </div>
348
+ </div>
349
+ <div class="nav-controls">
350
+ <div class="status-indicator">
351
+ <div class="dot" id="auth-dot"></div>
352
+ <span id="auth-status">Unauthenticated</span>
353
+ </div>
354
+ <input type="text" id="tenant-input" class="tenant-input" placeholder="X-Tenant-ID" value="tenant_1" title="Multi-tenant DB Target" />
355
+ <button id="login-btn" class="btn btn-login">Login with Keycloak</button>
356
+ <button id="logout-btn" class="btn btn-logout">Logout</button>
357
+ <button id="mqtt-btn" class="btn" style="background: #2c3e50; color: white;">MQTT Topics</button>
358
+ </div>
359
+ </div>
360
+
180
361
  <div id="swagger-ui"></div>
362
+
363
+ <div id="mqtt-modal">
364
+ <div class="mqtt-content">
365
+ <div class="mqtt-header">
366
+ <h3 style="margin: 0; color: white;">MQTT Topics Dictionary (JSON5)</h3>
367
+ <div>
368
+ <button class="mqtt-dl" id="mqtt-dl-btn">Download JSON5</button>
369
+ <span class="mqtt-close" id="mqtt-close">&times;</span>
370
+ </div>
371
+ </div>
372
+ <pre id="mqtt-viewer" style="overflow-x: auto; max-height: 70vh; margin: 0;"></pre>
373
+ </div>
374
+ </div>
375
+
376
+ <div id="mqtt-action-modal">
377
+ <div class="mqtt-action-content">
378
+ <div style="display: flex; justify-content: space-between; border-bottom: 1px solid #333; padding-bottom: 10px;">
379
+ <div>
380
+ <h3 style="margin: 0; display: inline-block;"><span id="action-title">SUBSCRIBE</span>: <span id="action-topic" style="color: #3498db;"></span></h3>
381
+ <input type="text" id="broker-url" class="broker-input" value="ws://localhost:9001" title="Broker WS URL" />
382
+ <span id="broker-status" style="margin-left: 10px; font-size: 0.9rem; color: #f39c12;">Disconnected</span>
383
+ </div>
384
+ <span class="mqtt-close" id="mqtt-action-close">&times;</span>
385
+ </div>
386
+
387
+ <!-- Subscribe View -->
388
+ <div id="view-subscribe" style="display: none;">
389
+ <div class="mqtt-console" id="mqtt-console">Waiting for messages...\n</div>
390
+ </div>
391
+
392
+ <!-- Publish View -->
393
+ <div id="view-publish" style="display: none;">
394
+ <textarea id="mqtt-payload" class="mqtt-editor">{\n "example": "payload"\n}</textarea>
395
+ <button id="mqtt-publish-btn" class="btn" style="background: #e74c3c; color: white; margin-top: 15px; width: 100%;">PUBLISH PAYLOAD</button>
396
+ </div>
397
+ </div>
398
+ </div>
399
+
181
400
  <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui-bundle.js"></script>
182
401
  <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui-standalone-preset.js"></script>
183
402
  <script>
403
+ const KEYCLOAK_URL = 'KEYCLOAK_URL_PLACEHOLDER';
404
+ const KEYCLOAK_REALM = 'KEYCLOAK_REALM_PLACEHOLDER';
405
+ const KEYCLOAK_CLIENT = 'KEYCLOAK_CLIENT_PLACEHOLDER';
406
+
407
+ let keycloak = null;
408
+
409
+ function updateUI(authenticated) {
410
+ if (authenticated) {
411
+ document.getElementById('login-btn').style.display = 'none';
412
+ document.getElementById('logout-btn').style.display = 'block';
413
+ document.getElementById('auth-dot').classList.add('active');
414
+ document.getElementById('auth-status').innerText = 'Authenticated (' + (keycloak.tokenParsed?.preferred_username || 'User') + ')';
415
+ } else {
416
+ document.getElementById('login-btn').style.display = 'block';
417
+ document.getElementById('logout-btn').style.display = 'none';
418
+ document.getElementById('auth-dot').classList.remove('active');
419
+ document.getElementById('auth-status').innerText = 'Unauthenticated';
420
+ }
421
+ }
422
+
184
423
  window.onload = function() {
185
- const ui = SwaggerUIBundle({
186
- url: "/swagger.json",
187
- dom_id: '#swagger-ui',
188
- deepLinking: true,
189
- presets: [
190
- SwaggerUIBundle.presets.apis,
191
- SwaggerUIStandalonePreset
192
- ],
193
- layout: "StandaloneLayout"
424
+ // Initialize Keycloak
425
+ keycloak = new Keycloak({
426
+ url: KEYCLOAK_URL,
427
+ realm: KEYCLOAK_REALM,
428
+ clientId: KEYCLOAK_CLIENT
429
+ });
430
+
431
+ keycloak.init({ onLoad: 'check-sso', checkLoginIframe: false }).then(authenticated => {
432
+ updateUI(authenticated);
433
+
434
+ document.getElementById('login-btn').onclick = () => keycloak.login();
435
+ document.getElementById('logout-btn').onclick = () => keycloak.logout();
436
+
437
+ if (authenticated) {
438
+ // Auto-refresh token logic
439
+ setInterval(() => {
440
+ keycloak.updateToken(70).then(refreshed => {
441
+ if (refreshed) {
442
+ console.log('Token refreshed automatically');
443
+ }
444
+ }).catch(() => {
445
+ console.error('Failed to refresh token');
446
+ updateUI(false);
447
+ });
448
+ }, 60000); // Check every minute
449
+ }
450
+
451
+ // 4. Initialize Swagger UI
452
+ const ui = SwaggerUIBundle({
453
+ url: "/v3/api-docs",
454
+ dom_id: '#swagger-ui',
455
+ deepLinking: true,
456
+ presets: [
457
+ SwaggerUIBundle.presets.apis,
458
+ SwaggerUIStandalonePreset
459
+ ],
460
+ layout: "StandaloneLayout",
461
+ requestInterceptor: (req) => {
462
+ if (keycloak && keycloak.token) {
463
+ req.headers['Authorization'] = 'Bearer ' + keycloak.token;
464
+ }
465
+ const tenantId = document.getElementById('tenant-input').value;
466
+ if (tenantId) {
467
+ req.headers['X-Tenant-ID'] = tenantId;
468
+ }
469
+ return req;
470
+ }
471
+ });
472
+ }).catch(err => {
473
+ console.error("Keycloak initialization failed", err);
474
+ document.getElementById('auth-status').innerText = 'Keycloak init failed';
475
+
476
+ // Still load swagger without auth interceptor
477
+ window.ui = SwaggerUIBundle({
478
+ url: "/v3/api-docs",
479
+ dom_id: '#swagger-ui',
480
+ deepLinking: true,
481
+ presets: [
482
+ SwaggerUIBundle.presets.apis,
483
+ SwaggerUIStandalonePreset
484
+ ],
485
+ layout: "StandaloneLayout"
486
+ });
194
487
  });
195
- window.ui = ui;
488
+
489
+ // MQTT Viewer Logic
490
+ const mqttModal = document.getElementById('mqtt-modal');
491
+ const actionModal = document.getElementById('mqtt-action-modal');
492
+ const actionTitle = document.getElementById('action-title');
493
+ const actionTopic = document.getElementById('action-topic');
494
+ const viewSub = document.getElementById('view-subscribe');
495
+ const viewPub = document.getElementById('view-publish');
496
+ const brokerInput = document.getElementById('broker-url');
497
+ const brokerStatus = document.getElementById('broker-status');
498
+ const mqttConsole = document.getElementById('mqtt-console');
499
+ const payloadEditor = document.getElementById('mqtt-payload');
500
+
501
+ let rawJson5 = '';
502
+ let mqttClient = null;
503
+ let currentTopic = '';
504
+
505
+ // Open Action Modal Logic (Global so inline onClick can call it)
506
+ window.openMqttAction = function(topic, action) {
507
+ currentTopic = topic;
508
+ actionTitle.innerText = action;
509
+ actionTopic.innerText = topic;
510
+
511
+ if (action === 'SEND') {
512
+ viewSub.style.display = 'none';
513
+ viewPub.style.display = 'block';
514
+ } else {
515
+ viewSub.style.display = 'block';
516
+ viewPub.style.display = 'none';
517
+ mqttConsole.innerHTML = `Waiting for messages on ${topic}...\\n`;
518
+ }
519
+
520
+ actionModal.style.display = 'flex';
521
+ connectMqtt();
522
+ };
523
+
524
+ function connectMqtt() {
525
+ if (mqttClient) {
526
+ if (mqttClient.options.href === brokerInput.value && mqttClient.connected) {
527
+ // Already connected to this broker, just subscribe if needed
528
+ if (actionTitle.innerText === 'SUBSCRIBE') mqttClient.subscribe(currentTopic);
529
+ return;
530
+ }
531
+ mqttClient.end();
532
+ }
533
+
534
+ brokerStatus.innerText = "Connecting...";
535
+ brokerStatus.style.color = "#f39c12";
536
+
537
+ try {
538
+ mqttClient = mqtt.connect(brokerInput.value);
539
+ } catch(e) {
540
+ brokerStatus.innerText = "Error: " + e.message;
541
+ brokerStatus.style.color = "#e74c3c";
542
+ return;
543
+ }
544
+
545
+ mqttClient.on('connect', () => {
546
+ brokerStatus.innerText = "Connected";
547
+ brokerStatus.style.color = "#2ecc71";
548
+ if (actionTitle.innerText === 'SUBSCRIBE') {
549
+ mqttClient.subscribe(currentTopic, (err) => {
550
+ if(!err) mqttConsole.innerHTML += `Subscribed to ${currentTopic}\\n`;
551
+ });
552
+ }
553
+ });
554
+
555
+ mqttClient.on('message', (topic, message) => {
556
+ if (topic === currentTopic) {
557
+ const time = new Date().toLocaleTimeString();
558
+ let msgStr = message.toString();
559
+ try { msgStr = JSON.stringify(JSON.parse(msgStr), null, 2); } catch(e){}
560
+ mqttConsole.innerHTML += `\\n<span style="color:#888">[${time}]</span>\\n${msgStr}\\n`;
561
+ mqttConsole.scrollTop = mqttConsole.scrollHeight;
562
+ }
563
+ });
564
+
565
+ mqttClient.on('error', (err) => {
566
+ brokerStatus.innerText = "Error";
567
+ brokerStatus.style.color = "#e74c3c";
568
+ console.error("MQTT Error:", err);
569
+ });
570
+ }
571
+
572
+ brokerInput.addEventListener('change', connectMqtt);
573
+
574
+ document.getElementById('mqtt-publish-btn').onclick = () => {
575
+ if (!mqttClient || !mqttClient.connected) {
576
+ alert("Broker not connected!");
577
+ return;
578
+ }
579
+ const payload = payloadEditor.value;
580
+ mqttClient.publish(currentTopic, payload, {}, (err) => {
581
+ if (err) {
582
+ alert("Failed to publish: " + err);
583
+ } else {
584
+ alert("Message published to " + currentTopic);
585
+ actionModal.style.display = 'none';
586
+ }
587
+ });
588
+ };
589
+
590
+ document.getElementById('mqtt-action-close').onclick = () => {
591
+ if (mqttClient && actionTitle.innerText === 'SUBSCRIBE') {
592
+ mqttClient.unsubscribe(currentTopic);
593
+ }
594
+ actionModal.style.display = 'none';
595
+ };
596
+
597
+ document.getElementById('mqtt-btn').onclick = () => {
598
+ fetch('/api/mqtt-topics')
599
+ .then(res => {
600
+ if (!res.ok) throw new Error("Status " + res.status);
601
+ return res.text();
602
+ })
603
+ .then(text => {
604
+ rawJson5 = text;
605
+ let highlighted = text
606
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
607
+ .replace(/([{}[\]:,])/g, '<span class="syntax-punct">$1</span>');
608
+
609
+ // Inject buttons next to topics (Only if authenticated)
610
+ highlighted = highlighted.replace(/"([^"]+)"(<span class="syntax-punct">.*?<\\/span>)/g, (match, topic, punct) => {
611
+ if (topic.includes('/')) {
612
+ let btnHtml = "";
613
+ if (keycloak && keycloak.authenticated) {
614
+ const isSend = /patch|put|post|create|update/i.test(topic);
615
+ const btnLabel = isSend ? "SEND" : "SUBSCRIBE";
616
+ const btnColor = isSend ? "#e74c3c" : "#3498db";
617
+ btnHtml = ` <button class="mqtt-action-btn" style="background:${btnColor}" onclick="openMqttAction('${topic}', '${btnLabel}')">${btnLabel}</button>`;
618
+ }
619
+ return `"${topic}"${punct}${btnHtml}`;
620
+ }
621
+ return match;
622
+ });
623
+
624
+ document.getElementById('mqtt-viewer').innerHTML = highlighted;
625
+ mqttModal.style.display = 'flex';
626
+ }).catch(err => alert('Failed to fetch MQTT topics: ' + err));
627
+ };
628
+
629
+ document.getElementById('mqtt-close').onclick = () => mqttModal.style.display = 'none';
630
+
631
+ document.getElementById('mqtt-dl-btn').onclick = () => {
632
+ const blob = new Blob([rawJson5], { type: 'application/json5' });
633
+ const url = URL.createObjectURL(blob);
634
+ const a = document.createElement('a');
635
+ a.href = url;
636
+ a.download = 'mqtt_topics.json5';
637
+ document.body.appendChild(a);
638
+ a.click();
639
+ document.body.removeChild(a);
640
+ URL.revokeObjectURL(url);
641
+ };
196
642
  };
197
643
  </script>
198
644
  </body>
199
- </html>`))
645
+ </html>`
646
+
647
+ htmlStr := strings.ReplaceAll(swaggerHTML, "KEYCLOAK_URL_PLACEHOLDER", appConfig.GoDuck.Security.KeycloakHost)
648
+ htmlStr = strings.ReplaceAll(htmlStr, "KEYCLOAK_REALM_PLACEHOLDER", appConfig.GoDuck.Security.KeycloakRealm)
649
+ htmlStr = strings.ReplaceAll(htmlStr, "KEYCLOAK_CLIENT_PLACEHOLDER", appConfig.GoDuck.Security.KeycloakAppClientID)
650
+
651
+ c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlStr))
200
652
  })
201
653
 
202
654
  // Management APIs (Run-time DB onboarding)