go-duck-cli 1.0.9 → 1.1.12
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 +30 -15
- package/generators/ai_docs.js +130 -0
- package/generators/broker.js +63 -0
- package/generators/config.js +149 -7
- package/generators/devops.js +210 -43
- package/generators/docs.js +23 -4
- package/generators/elasticsearch.js +263 -0
- package/generators/kratos.js +229 -41
- package/generators/metering.js +280 -48
- package/generators/migrations.js +92 -198
- package/generators/mqtt.js +2 -39
- package/generators/multitenancy.js +274 -71
- package/generators/nats.js +39 -0
- package/generators/outbox.js +171 -0
- package/generators/postgrest.js +7 -3
- package/generators/postman.js +405 -0
- package/generators/repository.js +27 -0
- package/generators/router.js +27 -0
- package/generators/security.js +95 -14
- package/generators/serverless.js +147 -0
- package/generators/storage.js +589 -0
- package/generators/swagger.js +84 -60
- package/generators/telemetry.js +23 -32
- package/generators/websocket.js +55 -21
- package/index.js +493 -116
- package/package.json +6 -4
- package/parser/gdl.js +163 -24
- package/templates/docs/index.html.hbs +5 -5
- package/templates/docs/layout.hbs +221 -62
- package/templates/docs/pages/audit.hbs +83 -35
- package/templates/docs/pages/cli.hbs +18 -0
- package/templates/docs/pages/configuration.hbs +241 -0
- package/templates/docs/pages/datadog.hbs +46 -0
- package/templates/docs/pages/elasticsearch.hbs +121 -0
- package/templates/docs/pages/federation.hbs +241 -0
- package/templates/docs/pages/gdl-advanced.hbs +91 -0
- package/templates/docs/pages/gdl-annotations.hbs +137 -0
- package/templates/docs/pages/gdl-entities.hbs +134 -0
- package/templates/docs/pages/gdl-relationships.hbs +80 -0
- package/templates/docs/pages/gdl.hbs +60 -204
- package/templates/docs/pages/graphql.hbs +58 -44
- package/templates/docs/pages/grpc.hbs +53 -90
- package/templates/docs/pages/hybrid-store.hbs +127 -0
- package/templates/docs/pages/index.hbs +418 -149
- package/templates/docs/pages/keycloak.hbs +43 -0
- package/templates/docs/pages/legend.hbs +116 -0
- package/templates/docs/pages/mosquitto.hbs +39 -0
- package/templates/docs/pages/multitenancy.hbs +139 -71
- package/templates/docs/pages/otel.hbs +40 -0
- package/templates/docs/pages/realtime.hbs +38 -12
- package/templates/docs/pages/redis.hbs +40 -0
- package/templates/docs/pages/rest.hbs +120 -202
- package/templates/docs/pages/saga.hbs +94 -0
- package/templates/docs/pages/security.hbs +150 -44
- package/templates/docs/pages/serverless.hbs +157 -0
- package/templates/docs/pages/storage.hbs +127 -0
- package/templates/docs/pages/wizard.hbs +683 -0
- package/templates/docs/triple_identity_registry.png +0 -0
- package/templates/go/controller.go.hbs +287 -283
- package/templates/go/entity.go.hbs +17 -15
- package/templates/go/main.go.hbs +47 -180
- package/templates/go/migrator.go.hbs +65 -0
- package/templates/go/router.go.hbs +272 -0
- package/templates/graphql/resolver.go.hbs +53 -34
- package/templates/graphql/schema.graphql.hbs +17 -5
- package/templates/kratos/service.go.hbs +169 -34
- package/templates/proto/entity.proto.hbs +10 -14
- package/test_nested.gdl +21 -0
- package/templates/docs/intro.mp4 +0 -0
- package/test_parser.js +0 -9
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
export const generatePostmanCollection = async (config, entities, outputDir, openEntities = []) => {
|
|
6
|
+
const docsDir = path.join(outputDir, 'docs');
|
|
7
|
+
await fs.ensureDir(docsDir);
|
|
8
|
+
|
|
9
|
+
const collection = {
|
|
10
|
+
info: {
|
|
11
|
+
name: `${config.name} Complete Postman API Suite`,
|
|
12
|
+
description: `Auto-generated comprehensive testing suite for all REST, GraphQL, WebSocket, RPC, and Management APIs of the ${config.name} microservice.`,
|
|
13
|
+
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
|
14
|
+
},
|
|
15
|
+
item: [],
|
|
16
|
+
variable: [
|
|
17
|
+
{ key: "host", value: "localhost", type: "string" },
|
|
18
|
+
{ key: "port", value: "8080", type: "string" },
|
|
19
|
+
{ key: "tenant", value: "tenant_1", type: "string" },
|
|
20
|
+
{ key: "token", value: "", type: "string" },
|
|
21
|
+
{ key: "keycloak_url", value: "http://localhost:8180", type: "string" },
|
|
22
|
+
{ key: "realm", value: config.security?.['keycloak-realm'] || 'master', type: "string" },
|
|
23
|
+
{ key: "app_client_id", value: `${config.name}-app`, type: "string" },
|
|
24
|
+
{ key: "service_client_id", value: `${config.name}-service`, type: "string" },
|
|
25
|
+
{ key: "service_secret", value: "service-secret-123", type: "string" },
|
|
26
|
+
{ key: "username", value: "admin", type: "string" },
|
|
27
|
+
{ key: "password", value: "admin", type: "string" }
|
|
28
|
+
]
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// 1. Auth Folder
|
|
32
|
+
collection.item.push({
|
|
33
|
+
name: "1. Authentication (Zero-Trust)",
|
|
34
|
+
description: "Fetch OIDC Tokens from Keycloak",
|
|
35
|
+
item: [
|
|
36
|
+
{
|
|
37
|
+
name: "1. Public App Login (User Login)",
|
|
38
|
+
event: [
|
|
39
|
+
{
|
|
40
|
+
listen: "test",
|
|
41
|
+
script: {
|
|
42
|
+
exec: [
|
|
43
|
+
"var jsonData = pm.response.json();",
|
|
44
|
+
"if (jsonData.access_token) {",
|
|
45
|
+
" pm.collectionVariables.set(\"token\", jsonData.access_token);",
|
|
46
|
+
"}"
|
|
47
|
+
],
|
|
48
|
+
type: "text/javascript"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
request: {
|
|
53
|
+
method: "POST",
|
|
54
|
+
header: [{ key: "Content-Type", value: "application/x-www-form-urlencoded" }],
|
|
55
|
+
body: {
|
|
56
|
+
mode: "urlencoded",
|
|
57
|
+
urlencoded: [
|
|
58
|
+
{ key: "client_id", value: "{{app_client_id}}", type: "text" },
|
|
59
|
+
{ key: "username", value: "{{username}}", type: "text" },
|
|
60
|
+
{ key: "password", value: "{{password}}", type: "text" },
|
|
61
|
+
{ key: "grant_type", value: "password", type: "text" }
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
url: {
|
|
65
|
+
raw: "{{keycloak_url}}/realms/{{realm}}/protocol/openid-connect/token",
|
|
66
|
+
host: ["{{keycloak_url}}"],
|
|
67
|
+
path: ["realms", "{{realm}}", "protocol", "openid-connect", "token"]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "2. Confidential Service Login (Service Account)",
|
|
73
|
+
event: [
|
|
74
|
+
{
|
|
75
|
+
listen: "test",
|
|
76
|
+
script: {
|
|
77
|
+
exec: [
|
|
78
|
+
"var jsonData = pm.response.json();",
|
|
79
|
+
"if (jsonData.access_token) {",
|
|
80
|
+
" pm.collectionVariables.set(\"token\", jsonData.access_token);",
|
|
81
|
+
"}"
|
|
82
|
+
],
|
|
83
|
+
type: "text/javascript"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
request: {
|
|
88
|
+
method: "POST",
|
|
89
|
+
header: [{ key: "Content-Type", value: "application/x-www-form-urlencoded" }],
|
|
90
|
+
body: {
|
|
91
|
+
mode: "urlencoded",
|
|
92
|
+
urlencoded: [
|
|
93
|
+
{ key: "client_id", value: "{{service_client_id}}", type: "text" },
|
|
94
|
+
{ key: "client_secret", value: "{{service_secret}}", type: "text" },
|
|
95
|
+
{ key: "grant_type", value: "client_credentials", type: "text" }
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
url: {
|
|
99
|
+
raw: "{{keycloak_url}}/realms/{{realm}}/protocol/openid-connect/token",
|
|
100
|
+
host: ["{{keycloak_url}}"],
|
|
101
|
+
path: ["realms", "{{realm}}", "protocol", "openid-connect", "token"]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 2. Management & Zero-Trust
|
|
109
|
+
collection.item.push({
|
|
110
|
+
name: "2. Architecture Management Metrics",
|
|
111
|
+
item: [
|
|
112
|
+
{
|
|
113
|
+
name: "1. Provision & Assign Tenant DB",
|
|
114
|
+
request: {
|
|
115
|
+
method: "POST",
|
|
116
|
+
header: [
|
|
117
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
118
|
+
{ key: "X-Tenant-ID", value: "master_internal" },
|
|
119
|
+
{ key: "Content-Type", value: "application/json" }
|
|
120
|
+
],
|
|
121
|
+
body: {
|
|
122
|
+
mode: "raw",
|
|
123
|
+
raw: "{\n \"roleName\": \"tenant_1_admin\",\n \"dbName\": \"tenant_1\"\n}"
|
|
124
|
+
},
|
|
125
|
+
url: {
|
|
126
|
+
raw: "http://{{host}}:{{port}}/management/tenant/assign",
|
|
127
|
+
protocol: "http",
|
|
128
|
+
host: ["{{host}}"],
|
|
129
|
+
port: "{{port}}",
|
|
130
|
+
path: ["management", "tenant", "assign"]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "2. Fetch Central Audit Logs",
|
|
136
|
+
request: {
|
|
137
|
+
method: "GET",
|
|
138
|
+
header: [
|
|
139
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
140
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
141
|
+
],
|
|
142
|
+
url: {
|
|
143
|
+
raw: "http://{{host}}:{{port}}/api/audit",
|
|
144
|
+
protocol: "http",
|
|
145
|
+
host: ["{{host}}"],
|
|
146
|
+
port: "{{port}}",
|
|
147
|
+
path: ["api", "audit"]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "3. Monitor API Quotas",
|
|
153
|
+
request: {
|
|
154
|
+
method: "GET",
|
|
155
|
+
header: [
|
|
156
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
157
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
158
|
+
],
|
|
159
|
+
url: {
|
|
160
|
+
raw: "http://{{host}}:{{port}}/api/metering/usage",
|
|
161
|
+
protocol: "http",
|
|
162
|
+
host: ["{{host}}"],
|
|
163
|
+
port: "{{port}}",
|
|
164
|
+
path: ["api", "metering", "usage"]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// 3. GraphQL
|
|
172
|
+
collection.item.push({
|
|
173
|
+
name: "3. GraphQL Federation Layer",
|
|
174
|
+
item: [
|
|
175
|
+
{
|
|
176
|
+
name: "1. Unified GraphQL Engine",
|
|
177
|
+
request: {
|
|
178
|
+
method: "POST",
|
|
179
|
+
header: [
|
|
180
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
181
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" },
|
|
182
|
+
{ key: "Content-Type", value: "application/json" }
|
|
183
|
+
],
|
|
184
|
+
body: {
|
|
185
|
+
mode: "graphql",
|
|
186
|
+
graphql: {
|
|
187
|
+
query: `query {\n ${entities.length > 0 ? 'list' + entities[0].name.charAt(0).toUpperCase() + entities[0].name.slice(1) + 's' : 'test'} {\n id\n }\n}`,
|
|
188
|
+
variables: ""
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
url: {
|
|
192
|
+
raw: "http://{{host}}:{{port}}/graphql",
|
|
193
|
+
protocol: "http",
|
|
194
|
+
host: ["{{host}}"],
|
|
195
|
+
port: "{{port}}",
|
|
196
|
+
path: ["graphql"]
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// 4. REST Entities
|
|
204
|
+
const restFolder = { name: "4. Standard REST & Deep Search", item: [] };
|
|
205
|
+
|
|
206
|
+
const generateDummyJson = (fields) => {
|
|
207
|
+
const obj = {};
|
|
208
|
+
for (const f of fields) {
|
|
209
|
+
if (f.type === 'String' || f.type === 'Text') obj[f.name] = "Sample " + f.name;
|
|
210
|
+
else if (f.type === 'Integer' || f.type === 'Long') obj[f.name] = 100;
|
|
211
|
+
else if (f.type === 'Float' || f.type === 'BigDecimal') obj[f.name] = 99.99;
|
|
212
|
+
else if (f.type === 'Boolean') obj[f.name] = true;
|
|
213
|
+
else if (f.type === 'LocalDate') obj[f.name] = "2024-01-01";
|
|
214
|
+
else if (f.type === 'Instant') obj[f.name] = "2024-01-01T12:00:00Z";
|
|
215
|
+
else if (f.type === 'JSON' || f.type === 'JSONB') obj[f.name] = {"attribute": "example_value"};
|
|
216
|
+
else obj[f.name] = "test";
|
|
217
|
+
}
|
|
218
|
+
return JSON.stringify(obj, null, 2);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const isOpen = (entityName, action) => {
|
|
222
|
+
if (!openEntities || !Array.isArray(openEntities)) return false;
|
|
223
|
+
const wildcard = openEntities.find(e => e.name === '*');
|
|
224
|
+
if (wildcard && wildcard.actions.includes(action.toLowerCase())) return true;
|
|
225
|
+
const entry = openEntities.find(e => e.name.toLowerCase() === entityName.toLowerCase());
|
|
226
|
+
if (entry && entry.actions.includes(action.toLowerCase())) return true;
|
|
227
|
+
return false;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
for (const entity of entities) {
|
|
231
|
+
const name = entity.name.toLowerCase();
|
|
232
|
+
const capitalized = entity.name.charAt(0).toUpperCase() + entity.name.slice(1);
|
|
233
|
+
const folder = { name: `${capitalized} APIs`, item: [] };
|
|
234
|
+
|
|
235
|
+
// 1. PUBLIC API Folder (If Open)
|
|
236
|
+
const publicItems = [];
|
|
237
|
+
if (isOpen(capitalized, 'read')) {
|
|
238
|
+
publicItems.push({
|
|
239
|
+
name: `[PUBLIC] List ${capitalized}s`,
|
|
240
|
+
request: {
|
|
241
|
+
method: "GET",
|
|
242
|
+
header: [ { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
243
|
+
url: { raw: `http://{{host}}:{{port}}/open/api/${name}s?page=1&size=10&eager=true`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", "api", `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" } ] }
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
publicItems.push({
|
|
247
|
+
name: `[PUBLIC] Get ${capitalized} by ID`,
|
|
248
|
+
request: {
|
|
249
|
+
method: "GET",
|
|
250
|
+
header: [ { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
251
|
+
url: { raw: `http://{{host}}:{{port}}/open/api/${name}s/1`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", "api", `${name}s`, "1"] }
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (isOpen(capitalized, 'create')) {
|
|
256
|
+
publicItems.push({
|
|
257
|
+
name: `[PUBLIC] Create ${capitalized}`,
|
|
258
|
+
request: {
|
|
259
|
+
method: "POST",
|
|
260
|
+
header: [ { key: "X-Tenant-ID", value: "{{tenant}}" }, { key: "Content-Type", value: "application/json" } ],
|
|
261
|
+
body: { mode: "raw", raw: generateDummyJson(entity.fields) },
|
|
262
|
+
url: { raw: `http://{{host}}:{{port}}/open/api/${name}s`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", "api", `${name}s`] }
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (publicItems.length > 0) {
|
|
268
|
+
folder.item.push({ name: "Public (Zero-Auth) Access", item: publicItems });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 2. PRIVATE API Folder (Secured)
|
|
272
|
+
const privateItems = [
|
|
273
|
+
{
|
|
274
|
+
name: `List ${capitalized}s (REST)`,
|
|
275
|
+
request: {
|
|
276
|
+
method: "GET",
|
|
277
|
+
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
278
|
+
url: { raw: `http://{{host}}:{{port}}/api/${name}s?page=1&size=10&eager=true`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["api", `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" } ] }
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: `Create ${capitalized} (REST)`,
|
|
283
|
+
request: {
|
|
284
|
+
method: "POST",
|
|
285
|
+
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" }, { key: "Content-Type", value: "application/json" } ],
|
|
286
|
+
body: { mode: "raw", raw: generateDummyJson(entity.fields) },
|
|
287
|
+
url: { raw: `http://{{host}}:{{port}}/api/${name}s`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["api", `${name}s`] }
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: `Get ${capitalized} by ID (REST)`,
|
|
292
|
+
request: {
|
|
293
|
+
method: "GET",
|
|
294
|
+
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
295
|
+
url: { raw: `http://{{host}}:{{port}}/api/${name}s/1`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["api", `${name}s`, "1"] }
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: `RPC Deep JSON Search (${capitalized})`,
|
|
300
|
+
request: {
|
|
301
|
+
method: "GET",
|
|
302
|
+
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
303
|
+
url: { raw: `http://{{host}}:{{port}}/api/rpc/${name}?id=gt.0`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["api", "rpc", name], query: [ { key: "id", value: "gt.0" } ] }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
folder.item.push({ name: "Private (Auth Required) CRUD", item: privateItems });
|
|
309
|
+
restFolder.item.push(folder);
|
|
310
|
+
}
|
|
311
|
+
collection.item.push(restFolder);
|
|
312
|
+
|
|
313
|
+
// 5. WebSocket
|
|
314
|
+
collection.item.push({
|
|
315
|
+
name: "5. Real-Time Distributed WebSockets",
|
|
316
|
+
item: [
|
|
317
|
+
{
|
|
318
|
+
name: "1. REST-over-WS Streaming Link",
|
|
319
|
+
request: {
|
|
320
|
+
method: "GET",
|
|
321
|
+
header: [],
|
|
322
|
+
url: {
|
|
323
|
+
raw: "ws://{{host}}:{{port}}/ws?token={{token}}",
|
|
324
|
+
protocol: "ws",
|
|
325
|
+
host: ["{{host}}"],
|
|
326
|
+
port: "{{port}}",
|
|
327
|
+
path: ["ws"],
|
|
328
|
+
query: [{ key: "token", value: "{{token}}" }]
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
]
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// 6. Storage Mesh
|
|
336
|
+
collection.item.push({
|
|
337
|
+
name: "6. Universal Storage Mesh",
|
|
338
|
+
item: [
|
|
339
|
+
{
|
|
340
|
+
name: "1. Upload File to Active Lake",
|
|
341
|
+
request: {
|
|
342
|
+
method: "POST",
|
|
343
|
+
header: [
|
|
344
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
345
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
346
|
+
],
|
|
347
|
+
body: {
|
|
348
|
+
mode: "formdata",
|
|
349
|
+
formdata: [
|
|
350
|
+
{ key: "file", type: "file", src: [] },
|
|
351
|
+
{ key: "folder", value: "farm/animals", type: "text" }
|
|
352
|
+
]
|
|
353
|
+
},
|
|
354
|
+
url: {
|
|
355
|
+
raw: "http://{{host}}:{{port}}/api/storage/upload?provider=sftp",
|
|
356
|
+
protocol: "http",
|
|
357
|
+
host: ["{{host}}"],
|
|
358
|
+
port: "{{port}}",
|
|
359
|
+
path: ["api", "storage", "upload"],
|
|
360
|
+
query: [{ key: "provider", value: "sftp" }]
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: "2. Download File (Provider Targeted)",
|
|
366
|
+
request: {
|
|
367
|
+
method: "GET",
|
|
368
|
+
header: [
|
|
369
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
370
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
371
|
+
],
|
|
372
|
+
url: {
|
|
373
|
+
raw: "http://{{host}}:{{port}}/api/storage/download/farm/animals/photo.jpg?provider=sftp",
|
|
374
|
+
protocol: "http",
|
|
375
|
+
host: ["{{host}}"],
|
|
376
|
+
port: "{{port}}",
|
|
377
|
+
path: ["api", "storage", "download", "farm", "animals", "photo.jpg"],
|
|
378
|
+
query: [{ key: "provider", value: "sftp" }]
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: "3. Distributed Cross-Scan Retrieve",
|
|
384
|
+
request: {
|
|
385
|
+
method: "GET",
|
|
386
|
+
header: [
|
|
387
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
388
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
389
|
+
],
|
|
390
|
+
url: {
|
|
391
|
+
raw: "http://{{host}}:{{port}}/api/storage/scan/farm/animals/photo.jpg",
|
|
392
|
+
protocol: "http",
|
|
393
|
+
host: ["{{host}}"],
|
|
394
|
+
port: "{{port}}",
|
|
395
|
+
path: ["api", "storage", "scan", "farm", "animals", "photo.jpg"]
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
]
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const filePath = path.join(docsDir, 'postman_collection.json');
|
|
403
|
+
await fs.writeJson(filePath, collection, { spaces: 2 });
|
|
404
|
+
console.log(chalk.gray(' Generated Postman Collection: postman_collection.json'));
|
|
405
|
+
};
|
package/generators/repository.js
CHANGED
|
@@ -9,6 +9,8 @@ export const generateRepositoryCode = async (outputDir) => {
|
|
|
9
9
|
const repoGo = `package repository
|
|
10
10
|
|
|
11
11
|
import (
|
|
12
|
+
"context"
|
|
13
|
+
"go.mongodb.org/mongo-driver/mongo"
|
|
12
14
|
"gorm.io/gorm"
|
|
13
15
|
)
|
|
14
16
|
|
|
@@ -21,6 +23,31 @@ func NewRepository(db *gorm.DB) *Repository {
|
|
|
21
23
|
DB: db,
|
|
22
24
|
}
|
|
23
25
|
}
|
|
26
|
+
|
|
27
|
+
func (r *Repository) GetDB(ctx context.Context) *gorm.DB {
|
|
28
|
+
// Kratos/Gin shared context value
|
|
29
|
+
if tdb, ok := ctx.Value("tenantDBConn").(*gorm.DB); ok {
|
|
30
|
+
return tdb
|
|
31
|
+
}
|
|
32
|
+
return r.DB
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type MongoRepository struct {
|
|
36
|
+
Client *mongo.Client
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func NewMongoRepository(client *mongo.Client) *MongoRepository {
|
|
40
|
+
return &MongoRepository{
|
|
41
|
+
Client: client,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func (r *MongoRepository) GetMongoDB(ctx context.Context) *mongo.Database {
|
|
46
|
+
if mdb, ok := ctx.Value("tenantMongoDB").(*mongo.Database); ok {
|
|
47
|
+
return mdb
|
|
48
|
+
}
|
|
49
|
+
return nil
|
|
50
|
+
}
|
|
24
51
|
`;
|
|
25
52
|
|
|
26
53
|
await fs.writeFile(path.join(repoDir, 'repository.go'), repoGo);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import Handlebars from 'handlebars';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
export const generateRouterCode = async (outputDir, config, entities, openEntities) => {
|
|
7
|
+
const routerDir = path.join(outputDir, 'router');
|
|
8
|
+
await fs.ensureDir(routerDir);
|
|
9
|
+
|
|
10
|
+
const templatePath = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '../templates/go/router.go.hbs');
|
|
11
|
+
if (!await fs.pathExists(templatePath)) {
|
|
12
|
+
console.error(chalk.red(`❌ Router template not found: ${templatePath}`));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const templateSource = await fs.readFile(templatePath, 'utf8');
|
|
17
|
+
const template = Handlebars.compile(templateSource);
|
|
18
|
+
|
|
19
|
+
const content = template({
|
|
20
|
+
app_name: config.name,
|
|
21
|
+
entities,
|
|
22
|
+
openEntities
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
await fs.writeFile(path.join(routerDir, 'router.go'), content);
|
|
26
|
+
console.log(chalk.gray(' Generated Reusable Router Package: router/router.go'));
|
|
27
|
+
};
|
package/generators/security.js
CHANGED
|
@@ -10,11 +10,13 @@ export const generateSecurityMiddleware = async (config, outputDir) => {
|
|
|
10
10
|
package middleware
|
|
11
11
|
|
|
12
12
|
import (
|
|
13
|
+
"fmt"
|
|
13
14
|
"net/http"
|
|
14
15
|
"strings"
|
|
15
16
|
|
|
16
17
|
"github.com/gin-gonic/gin"
|
|
17
18
|
"github.com/golang-jwt/jwt/v4"
|
|
19
|
+
"{{app_name}}/config"
|
|
18
20
|
)
|
|
19
21
|
|
|
20
22
|
// JWTMiddleware validates Keycloak JWTs
|
|
@@ -34,9 +36,15 @@ func JWTMiddleware() gin.HandlerFunc {
|
|
|
34
36
|
|
|
35
37
|
tokenString := parts[1]
|
|
36
38
|
|
|
37
|
-
token,
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
|
40
|
+
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
41
|
+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
42
|
+
}
|
|
43
|
+
return []byte(config.GetConfig().GoDuck.Security.KeycloakAppClientSecret), nil
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if err != nil || !token.Valid {
|
|
47
|
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
|
40
48
|
return
|
|
41
49
|
}
|
|
42
50
|
|
|
@@ -59,14 +67,58 @@ func GetUserID(c *gin.Context) string {
|
|
|
59
67
|
}
|
|
60
68
|
return "anonymous"
|
|
61
69
|
}
|
|
70
|
+
|
|
71
|
+
// SuperAdminRoleMiddleware restricts access to endpoints to only users with the SuperAdminRole
|
|
72
|
+
func SuperAdminRoleMiddleware(cfg *config.Config) gin.HandlerFunc {
|
|
73
|
+
return func(c *gin.Context) {
|
|
74
|
+
superRole := cfg.GoDuck.Security.SuperAdminRole
|
|
75
|
+
if superRole == "" {
|
|
76
|
+
c.Next()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
userRolesInterface, exists := c.Get("UserRoles")
|
|
81
|
+
if !exists {
|
|
82
|
+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "No roles found in security context"})
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
roles, ok := userRolesInterface.([]interface{})
|
|
87
|
+
if !ok {
|
|
88
|
+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Invalid roles format"})
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
hasRole := false
|
|
93
|
+
for _, r := range roles {
|
|
94
|
+
if strings.ToLower(fmt.Sprintf("%v", r)) == strings.ToLower(superRole) {
|
|
95
|
+
hasRole = true
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if !hasRole {
|
|
101
|
+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
|
102
|
+
"error": "Insufficient permissions. This endpoint is restricted to " + superRole,
|
|
103
|
+
})
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
c.Next()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
62
110
|
`;
|
|
63
111
|
|
|
64
112
|
const rateLimitMiddleware = `
|
|
65
113
|
package middleware
|
|
66
114
|
|
|
67
115
|
import (
|
|
116
|
+
"context"
|
|
117
|
+
"fmt"
|
|
68
118
|
"net/http"
|
|
69
119
|
"sync"
|
|
120
|
+
"time"
|
|
121
|
+
"{{app_name}}/cache"
|
|
70
122
|
"{{app_name}}/config"
|
|
71
123
|
|
|
72
124
|
"github.com/gin-gonic/gin"
|
|
@@ -76,26 +128,55 @@ import (
|
|
|
76
128
|
var (
|
|
77
129
|
limiters = make(map[string]*rate.Limiter)
|
|
78
130
|
mu sync.Mutex
|
|
131
|
+
ctx = context.Background()
|
|
79
132
|
)
|
|
80
133
|
|
|
81
|
-
// RateLimitMiddleware provides burst protection
|
|
134
|
+
// RateLimitMiddleware provides distributed burst protection using Redis (Zero-Trust fallback to local)
|
|
82
135
|
func RateLimitMiddleware(cfg *config.Config) gin.HandlerFunc {
|
|
83
136
|
return func(c *gin.Context) {
|
|
84
|
-
|
|
85
|
-
|
|
137
|
+
rps := cfg.GoDuck.Security.RateLimit.RPS
|
|
138
|
+
burst := cfg.GoDuck.Security.RateLimit.Burst
|
|
139
|
+
|
|
140
|
+
// Try to identify by User (KeycloakID) instead of IP for Zero-Trust accuracy
|
|
141
|
+
identifier := c.ClientIP()
|
|
142
|
+
if uid, exists := c.Get("KeycloakID"); exists {
|
|
143
|
+
identifier = uid.(string)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 1. Distributed Redis Architecture (Enterprise Grade)
|
|
147
|
+
if cache.RedisClient != nil {
|
|
148
|
+
// Fixed Window Counter per second
|
|
149
|
+
key := fmt.Sprintf("rate_limit:%s:%d", identifier, time.Now().Unix())
|
|
150
|
+
count, err := cache.RedisClient.Incr(ctx, key).Result()
|
|
151
|
+
|
|
152
|
+
if err == nil {
|
|
153
|
+
if count == 1 {
|
|
154
|
+
cache.RedisClient.Expire(ctx, key, 2*time.Second) // Small TTL for Sliding RPS
|
|
155
|
+
}
|
|
156
|
+
if count > int64(rps)+int64(burst) { // Allow burst
|
|
157
|
+
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
|
158
|
+
"error": "Distributed Rate limit exceeded. Please try again later.",
|
|
159
|
+
})
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
c.Next()
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
// If Redis fails, gracefully degrade to fallback
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 2. Local Fallback Architecture
|
|
86
169
|
mu.Lock()
|
|
87
|
-
limiter, exists := limiters[
|
|
170
|
+
limiter, exists := limiters[identifier]
|
|
88
171
|
if !exists {
|
|
89
|
-
rps := cfg.GoDuck.Security.RateLimit.RPS
|
|
90
|
-
burst := cfg.GoDuck.Security.RateLimit.Burst
|
|
91
172
|
limiter = rate.NewLimiter(rate.Limit(rps), burst)
|
|
92
|
-
limiters[
|
|
173
|
+
limiters[identifier] = limiter
|
|
93
174
|
}
|
|
94
175
|
mu.Unlock()
|
|
95
176
|
|
|
96
177
|
if !limiter.Allow() {
|
|
97
178
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
|
98
|
-
"error": "Rate limit exceeded. Please try again later.",
|
|
179
|
+
"error": "Internal Rate limit exceeded. Please try again later.",
|
|
99
180
|
})
|
|
100
181
|
return
|
|
101
182
|
}
|
|
@@ -104,8 +185,8 @@ func RateLimitMiddleware(cfg *config.Config) gin.HandlerFunc {
|
|
|
104
185
|
}
|
|
105
186
|
`;
|
|
106
187
|
|
|
107
|
-
await fs.writeFile(path.join(middlewareDir, 'jwt_middleware.go'), jwtMiddleware);
|
|
108
|
-
await fs.writeFile(path.join(middlewareDir, 'rate_limit_middleware.go'), rateLimitMiddleware.replace(
|
|
188
|
+
await fs.writeFile(path.join(middlewareDir, 'jwt_middleware.go'), jwtMiddleware.replace(/{{app_name}}/g, config.name));
|
|
189
|
+
await fs.writeFile(path.join(middlewareDir, 'rate_limit_middleware.go'), rateLimitMiddleware.replace(/{{app_name}}/g, config.name));
|
|
109
190
|
|
|
110
191
|
const corsMiddleware = `
|
|
111
192
|
package middleware
|
|
@@ -163,6 +244,6 @@ func CORSMiddleware(cfg *config.Config) gin.HandlerFunc {
|
|
|
163
244
|
}
|
|
164
245
|
`;
|
|
165
246
|
|
|
166
|
-
await fs.writeFile(path.join(middlewareDir, 'cors_middleware.go'), corsMiddleware.replace(
|
|
247
|
+
await fs.writeFile(path.join(middlewareDir, 'cors_middleware.go'), corsMiddleware.replace(/{{app_name}}/g, config.name));
|
|
167
248
|
console.log(chalk.gray(' Generated Advanced Security & CORS Middleware'));
|
|
168
249
|
};
|