go-duck-cli 1.0.8 → 1.1.1

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.
Files changed (70) hide show
  1. package/README.md +30 -15
  2. package/generators/ai_docs.js +130 -0
  3. package/generators/broker.js +63 -0
  4. package/generators/config.js +149 -7
  5. package/generators/devops.js +210 -43
  6. package/generators/docs.js +23 -4
  7. package/generators/elasticsearch.js +263 -0
  8. package/generators/kratos.js +229 -41
  9. package/generators/metering.js +280 -48
  10. package/generators/migrations.js +92 -198
  11. package/generators/mqtt.js +2 -39
  12. package/generators/multitenancy.js +274 -71
  13. package/generators/nats.js +39 -0
  14. package/generators/outbox.js +171 -0
  15. package/generators/postgrest.js +7 -3
  16. package/generators/postman.js +405 -0
  17. package/generators/repository.js +27 -0
  18. package/generators/router.js +27 -0
  19. package/generators/security.js +95 -14
  20. package/generators/serverless.js +147 -0
  21. package/generators/storage.js +589 -0
  22. package/generators/swagger.js +84 -60
  23. package/generators/telemetry.js +23 -32
  24. package/generators/websocket.js +55 -21
  25. package/index.js +481 -116
  26. package/package.json +6 -4
  27. package/parser/gdl.js +163 -24
  28. package/templates/docs/index.html.hbs +5 -5
  29. package/templates/docs/layout.hbs +221 -62
  30. package/templates/docs/pages/audit.hbs +83 -35
  31. package/templates/docs/pages/cli.hbs +18 -0
  32. package/templates/docs/pages/configuration.hbs +241 -0
  33. package/templates/docs/pages/datadog.hbs +46 -0
  34. package/templates/docs/pages/elasticsearch.hbs +121 -0
  35. package/templates/docs/pages/federation.hbs +241 -0
  36. package/templates/docs/pages/gdl-advanced.hbs +91 -0
  37. package/templates/docs/pages/gdl-annotations.hbs +137 -0
  38. package/templates/docs/pages/gdl-entities.hbs +134 -0
  39. package/templates/docs/pages/gdl-relationships.hbs +80 -0
  40. package/templates/docs/pages/gdl.hbs +60 -204
  41. package/templates/docs/pages/graphql.hbs +58 -44
  42. package/templates/docs/pages/grpc.hbs +53 -90
  43. package/templates/docs/pages/hybrid-store.hbs +127 -0
  44. package/templates/docs/pages/index.hbs +418 -149
  45. package/templates/docs/pages/keycloak.hbs +43 -0
  46. package/templates/docs/pages/legend.hbs +116 -0
  47. package/templates/docs/pages/mosquitto.hbs +39 -0
  48. package/templates/docs/pages/multitenancy.hbs +139 -71
  49. package/templates/docs/pages/otel.hbs +40 -0
  50. package/templates/docs/pages/realtime.hbs +38 -12
  51. package/templates/docs/pages/redis.hbs +40 -0
  52. package/templates/docs/pages/rest.hbs +120 -202
  53. package/templates/docs/pages/saga.hbs +94 -0
  54. package/templates/docs/pages/security.hbs +150 -44
  55. package/templates/docs/pages/serverless.hbs +157 -0
  56. package/templates/docs/pages/storage.hbs +127 -0
  57. package/templates/docs/pages/wizard.hbs +683 -0
  58. package/templates/docs/triple_identity_registry.png +0 -0
  59. package/templates/go/controller.go.hbs +287 -283
  60. package/templates/go/entity.go.hbs +17 -15
  61. package/templates/go/main.go.hbs +47 -180
  62. package/templates/go/migrator.go.hbs +65 -0
  63. package/templates/go/router.go.hbs +272 -0
  64. package/templates/graphql/resolver.go.hbs +53 -34
  65. package/templates/graphql/schema.graphql.hbs +17 -5
  66. package/templates/kratos/service.go.hbs +169 -34
  67. package/templates/proto/entity.proto.hbs +10 -14
  68. package/test_nested.gdl +21 -0
  69. package/templates/docs/intro.mp4 +0 -0
  70. package/test_parser.js +0 -9
@@ -19,63 +19,228 @@ import (
19
19
  )
20
20
 
21
21
  type APIUsage struct {
22
- ID uint \`gorm:"primaryKey" json:"id"\`
23
- UserID string \`json:"userId" gorm:"index:idx_user_api,unique"\`
24
- APIPath string \`json:"apiPath" gorm:"index:idx_user_api,unique"\`
25
- UsageCount int64 \`json:"usageCount"\`
26
- MaxLimit int64 \`json:"maxLimit" gorm:"default:1000"\`
22
+ ID uint \`gorm:"primaryKey" json:"id"\`
23
+ TargetType string \`json:"targetType" gorm:"index:idx_target_api,unique"\` // "user" or "role"
24
+ TargetID string \`json:"targetId" gorm:"index:idx_target_api,unique"\` // KeycloakID or Role Name
25
+ APIPath string \`json:"apiPath" gorm:"index:idx_target_api,unique"\`
26
+ TenantDB string \`json:"tenantDb" gorm:"index:idx_target_api,unique"\`
27
+ UsageCount int64 \`json:"usageCount"\`
28
+ MaxLimit int64 \`json:"maxLimit" gorm:"default:1000"\`
29
+ DurationDays int \`json:"durationDays" gorm:"default:30"\` // Duration in Days
30
+ AutoReset bool \`json:"autoReset" gorm:"default:true"\` // Lock them until they pay if false!
31
+ WindowStart time.Time \`json:"windowStart"\` // When did the current quota period start?
27
32
  LastAccessed time.Time \`json:"lastAccessed"\`
28
33
  }
34
+
35
+ type APIUsageHistory struct {
36
+ ID uint \`gorm:"primaryKey" json:"id"\`
37
+ TargetType string \`json:"targetType" gorm:"index:idx_hist_target_api"\`
38
+ TargetID string \`json:"targetId" gorm:"index:idx_hist_target_api"\`
39
+ APIPath string \`json:"apiPath" gorm:"index:idx_hist_target_api"\`
40
+ TenantDB string \`json:"tenantDb" gorm:"index:idx_hist_target_api"\`
41
+ FinalUsage int64 \`json:"finalUsage"\`
42
+ MaxLimit int64 \`json:"maxLimit"\`
43
+ DurationDays int \`json:"durationDays"\`
44
+ WindowStart time.Time \`json:"windowStart"\`
45
+ WindowEnd time.Time \`json:"windowEnd"\`
46
+ }
29
47
  `;
30
48
 
31
49
  const meteringMiddleware = `
32
50
  package middleware
33
51
 
34
52
  import (
53
+ "context"
54
+ "fmt"
35
55
  "net/http"
56
+ "strconv"
57
+ "strings"
36
58
  "time"
37
59
 
38
60
  "github.com/gin-gonic/gin"
39
61
  "gorm.io/gorm"
62
+ "gorm.io/gorm/clause"
63
+ "{{app_name}}/cache"
40
64
  "{{app_name}}/models"
41
65
  )
42
66
 
67
+ var meterCtx = context.Background()
68
+
43
69
  func MeteringMiddleware(db *gorm.DB) gin.HandlerFunc {
44
70
  return func(c *gin.Context) {
45
- userID := c.GetHeader("X-Keycloak-Id")
46
- if userID == "" {
71
+ keycloakId, exists := c.Get("KeycloakID")
72
+ if !exists {
47
73
  c.Next()
48
74
  return
49
75
  }
76
+ userID := keycloakId.(string)
50
77
 
78
+ rolesList := []string{}
79
+ if roles, exists := c.Get("UserRoles"); exists {
80
+ for _, r := range roles.([]interface{}) {
81
+ rolesList = append(rolesList, r.(string))
82
+ }
83
+ }
84
+
85
+ tenant, _ := c.Get("tenantDB")
86
+ tenantStr := fmt.Sprintf("%v", tenant)
51
87
  path := c.Request.URL.Path
52
- var usage models.APIUsage
53
-
54
- // Get usage and limit
55
- result := db.Where("user_id = ? AND api_path = ?", userID, path).First(&usage)
56
- if result.Error == gorm.ErrRecordNotFound {
57
- usage = models.APIUsage{
58
- UserID: userID,
59
- APIPath: path,
60
- UsageCount: 1,
61
- MaxLimit: 1000, // Default limit
62
- LastAccessed: time.Now(),
88
+
89
+ if cache.RedisClient != nil {
90
+ var targetType, targetID, limitStr string
91
+
92
+ // 1. Try Specific User Limit
93
+ userLimitKey := fmt.Sprintf("metering_limit:user:%s:%s:%s", userID, tenantStr, path)
94
+ limitStr, _ = cache.RedisClient.Get(meterCtx, userLimitKey).Result()
95
+ if limitStr != "" {
96
+ targetType = "user"
97
+ targetID = userID
63
98
  }
64
- db.Create(&usage)
65
- } else {
66
- if usage.UsageCount >= usage.MaxLimit {
67
- c.JSON(http.StatusTooManyRequests, gin.H{
68
- "error": "Usage limit exceeded",
69
- "limit": usage.MaxLimit,
70
- "usage": usage.UsageCount,
99
+
100
+ // 2. Try Role Limit (First match wins)
101
+ if targetType == "" {
102
+ for _, role := range rolesList {
103
+ roleLimitKey := fmt.Sprintf("metering_limit:role:%s:%s:%s", role, tenantStr, path)
104
+ limitStr, _ = cache.RedisClient.Get(meterCtx, roleLimitKey).Result()
105
+ if limitStr != "" {
106
+ targetType = "role"
107
+ targetID = role
108
+ break
109
+ }
110
+ }
111
+ }
112
+
113
+ restoredActiveWindow := false
114
+
115
+ // 3. Fallback: Parse from Database
116
+ if db != nil && targetType == "" {
117
+ var usage models.APIUsage
118
+ result := db.Where("target_type = 'user' AND target_id = ? AND api_path = ? AND tenant_db = ?", userID, path, tenantStr).First(&usage)
119
+ if result.Error == nil {
120
+ targetType = "user"
121
+ targetID = userID
122
+ } else {
123
+ for _, role := range rolesList {
124
+ result = db.Where("target_type = 'role' AND target_id = ? AND api_path = ? AND tenant_db = ?", role, path, tenantStr).First(&usage)
125
+ if result.Error == nil {
126
+ targetType = "role"
127
+ targetID = role
128
+ break
129
+ }
130
+ }
131
+ }
132
+
133
+ if targetType != "" {
134
+ autoResetStr := "true"
135
+ if !usage.AutoReset {
136
+ autoResetStr = "false"
137
+ }
138
+ limitStr = fmt.Sprintf("%d:%d:%s", usage.MaxLimit, usage.DurationDays, autoResetStr)
139
+ cache.RedisClient.Set(meterCtx, fmt.Sprintf("metering_limit:%s:%s:%s:%s", targetType, targetID, tenantStr, path), limitStr, 0)
140
+
141
+ // Safely Restore Usage from DB considering AutoReset
142
+ duration := time.Duration(usage.DurationDays) * 24 * time.Hour
143
+ usageKey := fmt.Sprintf("metering_usage:%s:%s:%s:%s", targetType, targetID, tenantStr, path)
144
+
145
+ // Only restore if AutoReset is disabled OR the Window is still mathematically Active!
146
+ if !usage.AutoReset || usage.DurationDays <= 0 || !time.Now().After(usage.WindowStart.Add(duration)) {
147
+ restoredActiveWindow = true
148
+ if usage.AutoReset && usage.DurationDays > 0 {
149
+ remainingTTL := time.Until(usage.WindowStart.Add(duration))
150
+ cache.RedisClient.Set(meterCtx, usageKey, usage.UsageCount, remainingTTL)
151
+ } else {
152
+ cache.RedisClient.Set(meterCtx, usageKey, usage.UsageCount, 0) // No Expiry (Locked!)
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ // 4. Default Limits (Per User, 1000 requests per 30 Days, Auto Resets)
159
+ if targetType == "" {
160
+ targetType = "user"
161
+ targetID = userID
162
+ limitStr = "1000:30:true"
163
+ cache.RedisClient.Set(meterCtx, userLimitKey, limitStr, 0)
164
+ }
165
+
166
+ parts := strings.Split(limitStr, ":")
167
+ maxLimit, _ := strconv.ParseInt(parts[0], 10, 64)
168
+ durationDays, _ := strconv.Atoi(parts[1])
169
+ autoReset := parts[2] == "true"
170
+
171
+ usageKey := fmt.Sprintf("metering_usage:%s:%s:%s:%s", targetType, targetID, tenantStr, path)
172
+ currentUsage, _ := cache.RedisClient.Incr(meterCtx, usageKey).Result()
173
+
174
+ var windowStart time.Time
175
+
176
+ // Explicit Window tracking for absolute fresh usages in Redis
177
+ isNewWindow := currentUsage == 1 && !restoredActiveWindow
178
+ if isNewWindow {
179
+ windowStart = time.Now()
180
+ // Only set TTL if AutoReset is enabled!
181
+ if autoReset && durationDays > 0 {
182
+ cache.RedisClient.Expire(meterCtx, usageKey, time.Duration(durationDays)*24*time.Hour)
183
+ }
184
+ }
185
+
186
+ if currentUsage > maxLimit {
187
+ c.JSON(http.StatusPaymentRequired, gin.H{
188
+ "error": fmt.Sprintf("SaaS Quota Exceeded for %s", targetType),
189
+ "target": targetID,
190
+ "limit": maxLimit,
191
+ "auto_reset": autoReset,
192
+ "current_use": currentUsage,
71
193
  })
72
194
  c.Abort()
73
195
  return
74
196
  }
75
- db.Model(&usage).Updates(map[string]interface{}{
76
- "usage_count": usage.UsageCount + 1,
77
- "last_accessed": time.Now(),
78
- })
197
+
198
+ if db != nil {
199
+ go func(tt string, tid string, p string, t string, count int64, lim int64, dur int, ar bool, isNew bool, ws time.Time) {
200
+ if count%10 == 0 || isNew {
201
+ if isNew {
202
+ var old models.APIUsage
203
+ res := db.Where("target_type = ? AND target_id = ? AND api_path = ? AND tenant_db = ?", tt, tid, p, t).First(&old)
204
+ if res.Error == nil && old.UsageCount > 0 {
205
+ db.Create(&models.APIUsageHistory{
206
+ TargetType: tt,
207
+ TargetID: tid,
208
+ APIPath: p,
209
+ TenantDB: t,
210
+ FinalUsage: old.UsageCount,
211
+ MaxLimit: old.MaxLimit,
212
+ DurationDays: old.DurationDays,
213
+ WindowStart: old.WindowStart,
214
+ WindowEnd: time.Now(),
215
+ })
216
+ }
217
+ }
218
+
219
+ updateCols := []string{"usage_count", "last_accessed"}
220
+ if isNew {
221
+ updateCols = append(updateCols, "window_start")
222
+ }
223
+ db.Clauses(clause.OnConflict{
224
+ Columns: []clause.Column{{Name: "target_type"}, {Name: "target_id"}, {Name: "api_path"}, {Name: "tenant_db"}},
225
+ DoUpdates: clause.AssignmentColumns(updateCols),
226
+ }).Create(&models.APIUsage{
227
+ TargetType: tt,
228
+ TargetID: tid,
229
+ APIPath: p,
230
+ TenantDB: t,
231
+ UsageCount: count,
232
+ MaxLimit: lim,
233
+ DurationDays: dur,
234
+ AutoReset: ar,
235
+ WindowStart: ws,
236
+ LastAccessed: time.Now(),
237
+ })
238
+ }
239
+ }(targetType, targetID, path, tenantStr, currentUsage, maxLimit, durationDays, autoReset, isNewWindow, windowStart)
240
+ }
241
+
242
+ c.Next()
243
+ return
79
244
  }
80
245
 
81
246
  c.Next()
@@ -83,15 +248,21 @@ func MeteringMiddleware(db *gorm.DB) gin.HandlerFunc {
83
248
  }
84
249
  `;
85
250
 
86
- const meteringController = `
251
+ const meteringController = `
87
252
  package controllers
88
253
 
89
254
  import (
255
+ "context"
256
+ "fmt"
90
257
  "net/http"
258
+ "time"
259
+
260
+ "{{app_name}}/cache"
91
261
  "{{app_name}}/models"
92
262
 
93
263
  "github.com/gin-gonic/gin"
94
264
  "gorm.io/gorm"
265
+ "gorm.io/gorm/clause"
95
266
  )
96
267
 
97
268
  type MeteringController struct {
@@ -99,10 +270,18 @@ type MeteringController struct {
99
270
  }
100
271
 
101
272
  func (mc *MeteringController) SetLimit(c *gin.Context) {
273
+ if mc.DB == nil {
274
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Infrastructure DB not initialized. SaaS Metering persistence requires a SQL database."})
275
+ return
276
+ }
102
277
  var req struct {
103
- UserID string \`json:"userId" binding:"required"\`
104
- APIPath string \`json:"apiPath" binding:"required"\`
105
- MaxLimit int64 \`json:"maxLimit" binding:"required"\`
278
+ TargetType string \`json:"targetType" binding:"required"\` // "user" or "role"
279
+ TargetID string \`json:"targetId" binding:"required"\` // The UUID or the Realm Role
280
+ APIPath string \`json:"apiPath" binding:"required"\`
281
+ TenantDB string \`json:"tenantDb" binding:"required"\`
282
+ MaxLimit int64 \`json:"maxLimit" binding:"required"\`
283
+ DurationDays int \`json:"durationDays" binding:"required"\` // Duration Reset
284
+ AutoReset *bool \`json:"autoReset" binding:"required"\` // Paywall Lock!
106
285
  }
107
286
 
108
287
  if err := c.ShouldBindJSON(&req); err != nil {
@@ -110,34 +289,87 @@ func (mc *MeteringController) SetLimit(c *gin.Context) {
110
289
  return
111
290
  }
112
291
 
113
- var usage models.APIUsage
114
- result := mc.DB.Where("user_id = ? AND api_path = ?", req.UserID, req.APIPath).First(&usage)
115
-
116
- if result.Error == gorm.ErrRecordNotFound {
117
- usage = models.APIUsage{
118
- UserID: req.UserID,
119
- APIPath: req.APIPath,
120
- MaxLimit: req.MaxLimit,
121
- UsageCount: 0,
292
+ if req.TargetType != "user" && req.TargetType != "role" {
293
+ c.JSON(http.StatusBadRequest, gin.H{"error": "targetType must be 'user' or 'role'"})
294
+ return
295
+ }
296
+
297
+ var old models.APIUsage
298
+ res := mc.DB.Where("target_type = ? AND target_id = ? AND api_path = ? AND tenant_db = ?", req.TargetType, req.TargetID, req.APIPath, req.TenantDB).First(&old)
299
+ if res.Error == nil {
300
+ mc.DB.Create(&models.APIUsageHistory{
301
+ TargetType: old.TargetType,
302
+ TargetID: old.TargetID,
303
+ APIPath: old.APIPath,
304
+ TenantDB: old.TenantDB,
305
+ FinalUsage: old.UsageCount,
306
+ MaxLimit: old.MaxLimit,
307
+ DurationDays: old.DurationDays,
308
+ WindowStart: old.WindowStart,
309
+ WindowEnd: time.Now(),
310
+ })
311
+ }
312
+
313
+ mc.DB.Clauses(clause.OnConflict{
314
+ Columns: []clause.Column{{Name: "target_type"}, {Name: "target_id"}, {Name: "api_path"}, {Name: "tenant_db"}},
315
+ DoUpdates: clause.AssignmentColumns([]string{"max_limit", "duration_days", "auto_reset", "usage_count", "window_start"}),
316
+ }).Create(&models.APIUsage{
317
+ TargetType: req.TargetType,
318
+ TargetID: req.TargetID,
319
+ APIPath: req.APIPath,
320
+ TenantDB: req.TenantDB,
321
+ MaxLimit: req.MaxLimit,
322
+ DurationDays: req.DurationDays,
323
+ AutoReset: *req.AutoReset,
324
+ UsageCount: 0,
325
+ WindowStart: time.Now(), // Reset the usage window explicit start date!
326
+ })
327
+
328
+ if cache.RedisClient != nil {
329
+ autoResetStr := "true"
330
+ if !*req.AutoReset {
331
+ autoResetStr = "false"
122
332
  }
123
- mc.DB.Create(&usage)
124
- } else {
125
- mc.DB.Model(&usage).Update("max_limit", req.MaxLimit)
333
+
334
+ limitKey := fmt.Sprintf("metering_limit:%s:%s:%s:%s", req.TargetType, req.TargetID, req.TenantDB, req.APIPath)
335
+ usageKey := fmt.Sprintf("metering_usage:%s:%s:%s:%s", req.TargetType, req.TargetID, req.TenantDB, req.APIPath)
336
+ cache.RedisClient.Set(context.Background(), limitKey, fmt.Sprintf("%d:%d:%s", req.MaxLimit, req.DurationDays, autoResetStr), 0)
337
+ cache.RedisClient.Del(context.Background(), usageKey)
126
338
  }
127
339
 
128
- c.JSON(http.StatusOK, gin.H{"message": "Limit updated successfully"})
340
+ c.JSON(http.StatusOK, gin.H{"message": "Quota, Duration & Paywall Lock updated globally."})
129
341
  }
130
342
 
131
343
  func (mc *MeteringController) GetUsage(c *gin.Context) {
344
+ if mc.DB == nil {
345
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Infrastructure DB not initialized."})
346
+ return
347
+ }
132
348
  var usages []models.APIUsage
133
349
  mc.DB.Find(&usages)
134
350
  c.JSON(http.StatusOK, usages)
135
351
  }
352
+
353
+ func (mc *MeteringController) GetHistory(c *gin.Context) {
354
+ if mc.DB == nil {
355
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Infrastructure DB not initialized."})
356
+ return
357
+ }
358
+ targetID := c.Query("targetId")
359
+ if targetID == "" {
360
+ c.JSON(http.StatusBadRequest, gin.H{"error": "targetId query parameter is required"})
361
+ return
362
+ }
363
+
364
+ var history []models.APIUsageHistory
365
+ mc.DB.Where("target_id = ?", targetID).Order("window_end desc").Find(&history)
366
+ c.JSON(http.StatusOK, history)
367
+ }
136
368
  `;
137
369
 
138
370
  await fs.writeFile(path.join(modelsDir, 'api_usage.go'), meteringModel);
139
- await fs.writeFile(path.join(middlewareDir, 'metering_middleware.go'), meteringMiddleware.replace('{{app_name}}', config.name));
140
- await fs.writeFile(path.join(controllersDir, 'metering_controller.go'), meteringController.replace('{{app_name}}', config.name));
371
+ await fs.writeFile(path.join(middlewareDir, 'metering_middleware.go'), meteringMiddleware.replace(/{{app_name}}/g, config.name));
372
+ await fs.writeFile(path.join(controllersDir, 'metering_controller.go'), meteringController.replace(/{{app_name}}/g, config.name));
141
373
 
142
374
  console.log(chalk.gray(' Generated Metering Model, Middleware & Controller'));
143
375
  };