go-duck-cli 1.0.4 → 1.0.6

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.
@@ -35,11 +35,30 @@ func (sc *SearchController) GenericSearch(c *gin.Context) {
35
35
  continue
36
36
  }
37
37
 
38
+ // Security: Basic Sanitization for Key (Allowing letters, numbers, _, -, and JSON arrows)
39
+ // We split the key if it contains JSON operators to handle them specifically
40
+ processedKey := key
41
+ if strings.Contains(key, "->") {
42
+ parts := strings.SplitN(key, "->", 2)
43
+ column := parts[0]
44
+ path := parts[1]
45
+ operator := "->"
46
+ if strings.HasPrefix(path, ">") {
47
+ operator = "->>"
48
+ path = path[1:]
49
+ }
50
+ // Wrap column in quotes and path in single quotes for Postgres JSONB safety
51
+ processedKey = fmt.Sprintf("\"%s\"%s'%s'", column, operator, path)
52
+ } else {
53
+ // Standard column: Wrap in quotes for safety
54
+ processedKey = fmt.Sprintf("\"%s\"", key)
55
+ }
56
+
38
57
  for _, val := range values {
39
58
  parts := strings.SplitN(val, ".", 2)
40
59
  if len(parts) < 2 {
41
60
  // Default to equality
42
- query = query.Where(fmt.Sprintf("%s = ?", key), val)
61
+ query = query.Where(processedKey+" = ?", val)
43
62
  continue
44
63
  }
45
64
 
@@ -48,24 +67,27 @@ func (sc *SearchController) GenericSearch(c *gin.Context) {
48
67
 
49
68
  switch op {
50
69
  case "eq":
51
- query = query.Where(fmt.Sprintf("%s = ?", key), target)
70
+ query = query.Where(processedKey+" = ?", target)
52
71
  case "neq":
53
- query = query.Where(fmt.Sprintf("%s <> ?", key), target)
72
+ query = query.Where(processedKey+" <> ?", target)
54
73
  case "gt":
55
- query = query.Where(fmt.Sprintf("%s > ?", key), target)
74
+ query = query.Where(processedKey+" > ?", target)
56
75
  case "gte":
57
- query = query.Where(fmt.Sprintf("%s >= ?", key), target)
76
+ query = query.Where(processedKey+" >= ?", target)
58
77
  case "lt":
59
- query = query.Where(fmt.Sprintf("%s < ?", key), target)
78
+ query = query.Where(processedKey+" < ?", target)
60
79
  case "lte":
61
- query = query.Where(fmt.Sprintf("%s <= ?", key), target)
80
+ query = query.Where(processedKey+" <= ?", target)
62
81
  case "like":
63
- query = query.Where(fmt.Sprintf("%s LIKE ?", key), "%"+target+"%")
82
+ query = query.Where(processedKey+" LIKE ?", "%"+target+"%")
64
83
  case "ilike":
65
- query = query.Where(fmt.Sprintf("%s ILIKE ?", key), "%"+target+"%")
84
+ query = query.Where(processedKey+" ILIKE ?", "%"+target+"%")
66
85
  case "in":
67
86
  list := strings.Split(target, ",")
68
- query = query.Where(fmt.Sprintf("%s IN ?", key), list)
87
+ query = query.Where(processedKey+" IN ?", list)
88
+ default:
89
+ // Fallback to equality if operator is unrecognized
90
+ query = query.Where(processedKey+" = ?", val)
69
91
  }
70
92
  }
71
93
  }
@@ -18,6 +18,19 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
18
18
  ],
19
19
  paths: {},
20
20
  components: {
21
+ securitySchemes: {
22
+ BearerAuth: {
23
+ type: 'http',
24
+ scheme: 'bearer',
25
+ bearerFormat: 'JWT'
26
+ },
27
+ TenantID: {
28
+ type: 'apiKey',
29
+ in: 'header',
30
+ name: 'X-Tenant-ID',
31
+ description: 'The unique identifier for the tenant dashboard context'
32
+ }
33
+ },
21
34
  schemas: {
22
35
  Error: {
23
36
  type: 'object',
@@ -26,9 +39,16 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
26
39
  }
27
40
  }
28
41
  }
29
- }
42
+ },
43
+ security: [
44
+ { BearerAuth: [], TenantID: [] }
45
+ ]
30
46
  };
31
47
 
48
+ const commonHeaders = [
49
+ { name: 'X-Tenant-ID', in: 'header', required: true, schema: { type: 'string', default: 'default' }, description: 'Multi-tenancy context identifier' }
50
+ ];
51
+
32
52
  // 1. Add Entity Paths
33
53
  for (const entity of entities) {
34
54
  const name = entity.name.toLowerCase();
@@ -53,6 +73,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
53
73
  post: {
54
74
  tags: [capitalized],
55
75
  summary: `Create a new ${capitalized}`,
76
+ parameters: [...commonHeaders],
56
77
  requestBody: {
57
78
  required: true,
58
79
  content: { 'application/json': { schema: { $ref: `#/components/schemas/${capitalized}` } } }
@@ -65,9 +86,10 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
65
86
  tags: [capitalized],
66
87
  summary: `Get all ${capitalized}s`,
67
88
  parameters: [
68
- { name: 'page', in: 'query', schema: { type: 'integer' } },
69
- { name: 'size', in: 'query', schema: { type: 'integer' } },
70
- { name: 'eager', in: 'query', schema: { type: 'boolean' } }
89
+ ...commonHeaders,
90
+ { name: 'page', in: 'query', schema: { type: 'integer' }, description: 'Zero-based page index' },
91
+ { name: 'size', in: 'query', schema: { type: 'integer' }, description: 'Records per page' },
92
+ { name: 'eager', in: 'query', schema: { type: 'boolean' }, description: 'If true, performs SQL Join to fetch relations' }
71
93
  ],
72
94
  responses: {
73
95
  200: { description: 'OK', content: { 'application/json': { schema: { type: 'array', items: { $ref: `#/components/schemas/${capitalized}` } } } } }
@@ -80,7 +102,11 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
80
102
  get: {
81
103
  tags: [capitalized],
82
104
  summary: `Get ${capitalized} by ID`,
83
- parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
105
+ parameters: [
106
+ ...commonHeaders,
107
+ { name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
108
+ { name: 'eager', in: 'query', schema: { type: 'boolean' } }
109
+ ],
84
110
  responses: {
85
111
  200: { description: 'OK', content: { 'application/json': { schema: { $ref: `#/components/schemas/${capitalized}` } } } }
86
112
  }
@@ -88,7 +114,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
88
114
  put: {
89
115
  tags: [capitalized],
90
116
  summary: `Update ${capitalized}`,
91
- parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
117
+ parameters: [...commonHeaders, { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
92
118
  responses: {
93
119
  200: { description: 'Updated', content: { 'application/json': { schema: { $ref: `#/components/schemas/${capitalized}` } } } }
94
120
  }
@@ -96,32 +122,105 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
96
122
  delete: {
97
123
  tags: [capitalized],
98
124
  summary: `Delete ${capitalized}`,
99
- parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
125
+ parameters: [...commonHeaders, { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
100
126
  responses: {
101
127
  204: { description: 'No Content' }
102
128
  }
103
129
  }
104
130
  };
131
+
132
+ // BULK Operations /entities/bulk
133
+ swagger.paths[`/${name}s/bulk`] = {
134
+ post: {
135
+ tags: [capitalized],
136
+ summary: `Bulk Create ${capitalized}s`,
137
+ parameters: [...commonHeaders],
138
+ requestBody: {
139
+ required: true,
140
+ content: { 'application/json': { schema: { type: 'array', items: { $ref: `#/components/schemas/${capitalized}` } } } }
141
+ },
142
+ responses: {
143
+ 201: { description: 'Created', content: { 'application/json': { schema: { type: 'array', items: { $ref: `#/components/schemas/${capitalized}` } } } } }
144
+ }
145
+ },
146
+ put: {
147
+ tags: [capitalized],
148
+ summary: `Bulk Update ${capitalized}s`,
149
+ parameters: [...commonHeaders],
150
+ requestBody: {
151
+ required: true,
152
+ content: { 'application/json': { schema: { type: 'array', items: { $ref: `#/components/schemas/${capitalized}` } } } }
153
+ },
154
+ responses: {
155
+ 200: { description: 'Updated', content: { 'application/json': { schema: { type: 'array', items: { $ref: `#/components/schemas/${capitalized}` } } } } }
156
+ }
157
+ },
158
+ patch: {
159
+ tags: [capitalized],
160
+ summary: `Bulk Patch ${capitalized}s`,
161
+ parameters: [...commonHeaders],
162
+ requestBody: {
163
+ required: true,
164
+ content: {
165
+ 'application/json': {
166
+ schema: {
167
+ type: 'array',
168
+ items: {
169
+ type: 'object',
170
+ properties: {
171
+ id: { type: 'integer' },
172
+ changes: { type: 'object' }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+ },
179
+ responses: {
180
+ 200: { description: 'Patched' }
181
+ }
182
+ }
183
+ };
105
184
  }
106
185
 
107
186
  // 2. Add System Paths
108
187
  swagger.paths['/rpc/{table}'] = {
109
188
  get: {
110
- tags: ['Search'],
111
- summary: 'Generic PostgREST-like Search',
189
+ tags: ['Search Engine'],
190
+ summary: 'Generic PostgREST RPC Engine',
191
+ description: `Powerful dynamic querying system.
192
+
193
+ ### Dynamic Filtering
194
+ Append any column name as a query parameter using operator notation:
195
+ - \`?age=gt.20\` (Greater Than)
196
+ - \`?name=ilike.John\` (Case-insensitive search)
197
+ - \`?id=in.1,2,3\` (Set containment)
198
+
199
+ ### JSONB Path Querying
200
+ For JSON fields, use arrow notation:
201
+ - \`?metadata->>role=eq.ADMIN\` (Nested text extraction)
202
+ - \`?details->count=gt.5\` (Nested numeric extraction)`,
112
203
  parameters: [
113
- { name: 'table', in: 'path', required: true, schema: { type: 'string' } },
114
- { name: 'order', in: 'query', schema: { type: 'string' } },
115
- { name: 'limit', in: 'query', schema: { type: 'integer' } }
204
+ ...commonHeaders,
205
+ { name: 'table', in: 'path', required: true, schema: { type: 'string' }, description: 'The database table to query' },
206
+ { name: 'order', in: 'query', schema: { type: 'string' }, description: 'Sorting (e.g., id.desc)' },
207
+ { name: 'limit', in: 'query', schema: { type: 'integer' }, description: 'Row limit' },
208
+ { name: 'offset', in: 'query', schema: { type: 'integer' }, description: 'Query offset' }
116
209
  ],
117
- responses: { 200: { description: 'OK' } }
210
+ responses: {
211
+ 200: {
212
+ description: 'OK',
213
+ content: { 'application/json': { schema: { type: 'array', items: { type: 'object' } } } }
214
+ }
215
+ }
118
216
  }
119
217
  };
120
218
 
121
219
  swagger.paths['/audit'] = {
122
220
  get: {
123
- tags: ['Audit'],
124
- summary: 'View Audit Logs',
221
+ tags: ['Observability'],
222
+ summary: 'Fetch Audit Trail',
223
+ parameters: [...commonHeaders],
125
224
  responses: { 200: { description: 'OK' } }
126
225
  }
127
226
  };
@@ -139,7 +238,10 @@ const mapToSwaggerType = (type) => {
139
238
  'Long': 'integer',
140
239
  'BigDecimal': 'number',
141
240
  'LocalDate': 'string',
142
- 'Instant': 'string'
241
+ 'Instant': 'string',
242
+ 'JSON': 'object',
243
+ 'JSONB': 'object',
244
+ 'Text': 'string'
143
245
  };
144
246
  return types[type] || 'string';
145
247
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "go-duck-cli",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "go function generator",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -124,6 +124,84 @@
124
124
  </div>
125
125
  </div>
126
126
  </div>
127
+
128
+ <!-- JSON Querying Section -->
129
+ <div class="bg-white border border-slate-200 rounded-2xl p-8 shadow-sm">
130
+ <h4 class="text-xl font-bold text-slate-900 mb-4 flex items-center">
131
+ <svg class="w-6 h-6 mr-3 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
132
+ Deep JSON/JSONB Querying
133
+ </h4>
134
+ <p class="text-slate-600 text-sm mb-6 leading-relaxed">
135
+ The GO-DUCK generator natively supports PostgreSQL JSONB operators. You can drill down into nested fields directly from the URL using arrow notation.
136
+ </p>
137
+
138
+ <div class="space-y-4">
139
+ <div class="bg-slate-50 p-5 rounded-xl border border-slate-100">
140
+ <div class="flex items-center justify-between mb-2">
141
+ <span class="text-xs font-bold text-indigo-600 uppercase tracking-widest">Text Extraction (->>)</span>
142
+ <span class="px-2 py-0.5 rounded bg-indigo-100 text-indigo-700 text-[10px] font-bold">Standard Use</span>
143
+ </div>
144
+ <code class="text-xs text-slate-800">GET /api/rpc/users?metadata->>role=eq.ADMIN</code>
145
+ <p class="text-[11px] text-slate-500 mt-2">Extracts the value as text. Perfect for equality checks on nested strings.</p>
146
+ </div>
147
+
148
+ <div class="bg-slate-50 p-5 rounded-xl border border-slate-100">
149
+ <div class="flex items-center justify-between mb-2">
150
+ <span class="text-xs font-bold text-purple-600 uppercase tracking-widest">Object Extraction (->)</span>
151
+ </div>
152
+ <code class="text-xs text-slate-800">GET /api/rpc/orders?details->itemsCount=gt.5</code>
153
+ <p class="text-[11px] text-slate-500 mt-2">Treats the extracted value as a JSON object/numeric, allowing for range checks on nested numbers.</p>
154
+ </div>
155
+ </div>
156
+
157
+ <div class="mt-6 p-4 bg-amber-50 rounded-xl border border-amber-100">
158
+ <p class="text-[11px] text-amber-800 leading-relaxed">
159
+ <strong>Pro Tip:</strong> For high-performance JSON querying, ensure you have a <code>GIN</code> index on the JSONB column in your database.
160
+ </p>
161
+ </div>
162
+ </div>
163
+ </section>
164
+
165
+ <section class="mb-12">
166
+ <h2 class="text-2xl font-bold text-slate-800 mb-6 flex items-center">
167
+ <span class="w-8 h-8 rounded-lg bg-orange-100 text-orange-600 flex items-center justify-center mr-3 text-sm">
168
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
169
+ </span>
170
+ Bulk Mission Control (High Velocity)
171
+ </h2>
172
+ <p class="text-slate-600 text-sm mb-6 leading-relaxed">
173
+ For batch processing and migrations, avoid the overhead of multiple HTTP calls. Use the specialized <code class="bg-orange-50 px-1 rounded text-orange-700">/bulk</code> endpoints to process hundreds of records in a single transaction.
174
+ </p>
175
+
176
+ <div class="space-y-6">
177
+ <!-- Bulk Create -->
178
+ <div class="bg-white border border-slate-200 rounded-2xl overflow-hidden">
179
+ <div class="bg-slate-50 px-6 py-3 border-b border-slate-200 flex items-center justify-between">
180
+ <span class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Bulk Create Transaction</span>
181
+ <span class="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-[10px] font-bold">POST /api/:entities/bulk</span>
182
+ </div>
183
+ <div class="p-6">
184
+ <pre class="bg-slate-900 rounded-xl p-4 text-xs text-slate-300 font-mono">[
185
+ { "title": "Bulk Article A", "status": "DRAFT" },
186
+ { "title": "Bulk Article B", "status": "PUBLISHED" }
187
+ ]</pre>
188
+ </div>
189
+ </div>
190
+
191
+ <!-- Bulk Patch -->
192
+ <div class="bg-white border border-slate-200 rounded-2xl overflow-hidden">
193
+ <div class="bg-slate-50 px-6 py-3 border-b border-slate-200 flex items-center justify-between">
194
+ <span class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Multi-Entity Patch</span>
195
+ <span class="px-2 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-bold">PATCH /api/:entities/bulk</span>
196
+ </div>
197
+ <div class="p-6">
198
+ <pre class="bg-slate-900 rounded-xl p-4 text-xs text-slate-300 font-mono">[
199
+ { "id": 1, "changes": { "status": "ARCHIVED" } },
200
+ { "id": 2, "changes": { "title": "Updated Title via Bulk" } }
201
+ ]</pre>
202
+ </div>
203
+ </div>
204
+ </div>
127
205
  </section>
128
206
 
129
207
  <section class="mb-12">
@@ -205,6 +205,104 @@ return nil, nil
205
205
  c.JSON(http.StatusOK, gin.H{"message": "Updated successfully", "data": entity})
206
206
  }
207
207
 
208
+ // BulkCreate handles creating multiple entities in one transaction
209
+ func (ctrl *{{capitalize name}}Controller) BulkCreate(c *gin.Context) {
210
+ tenant, _ := c.Get("tenantDB")
211
+ tenantStr := fmt.Sprintf("%v", tenant)
212
+ ctx := c.Request.Context()
213
+
214
+ var entities []models.{{capitalize name}}
215
+ if err := c.ShouldBindJSON(&entities); err != nil {
216
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
217
+ return
218
+ }
219
+
220
+ if err := ctrl.DB.WithContext(ctx).Create(&entities).Error; err != nil {
221
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
222
+ return
223
+ }
224
+
225
+ // Dynamic Cache Invalidation (Tenant Aware)
226
+ cache.ClearPattern(tenantStr + ":{{capitalize name}}*")
227
+
228
+ // MQTT Event (Resilient)
229
+ resilience.Execute(func() (interface{}, error) {
230
+ messaging.PublishEvent(ctrl.Config.GoDuck.Messaging.MQTT.TopicPrefix, "BULK_CREATE", "{{capitalize name}}", entities, nil)
231
+ return nil, nil
232
+ })
233
+
234
+ c.JSON(http.StatusCreated, entities)
235
+ }
236
+
237
+ // BulkUpdate handles updating multiple entities in one transaction
238
+ func (ctrl *{{capitalize name}}Controller) BulkUpdate(c *gin.Context) {
239
+ tenant, _ := c.Get("tenantDB")
240
+ tenantStr := fmt.Sprintf("%v", tenant)
241
+ ctx := c.Request.Context()
242
+
243
+ var entities []models.{{capitalize name}}
244
+ if err := c.ShouldBindJSON(&entities); err != nil {
245
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
246
+ return
247
+ }
248
+
249
+ err := ctrl.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
250
+ for _, e := range entities {
251
+ if err := tx.Save(&e).Error; err != nil {
252
+ return err
253
+ }
254
+ cache.Delete(fmt.Sprintf("%s:{{capitalize name}}:%d", tenantStr, e.ID))
255
+ }
256
+ return nil
257
+ })
258
+
259
+ if err != nil {
260
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
261
+ return
262
+ }
263
+
264
+ // MQTT Event (Resilient)
265
+ resilience.Execute(func() (interface{}, error) {
266
+ messaging.PublishEvent(ctrl.Config.GoDuck.Messaging.MQTT.TopicPrefix, "BULK_UPDATE", "{{capitalize name}}", entities, nil)
267
+ return nil, nil
268
+ })
269
+
270
+ c.JSON(http.StatusOK, entities)
271
+ }
272
+
273
+ // BulkPatch handles partial updates for multiple entities
274
+ func (ctrl *{{capitalize name}}Controller) BulkPatch(c *gin.Context) {
275
+ tenant, _ := c.Get("tenantDB")
276
+ tenantStr := fmt.Sprintf("%v", tenant)
277
+ ctx := c.Request.Context()
278
+
279
+ var updates []struct {
280
+ ID uint `json:"id"`
281
+ Changes map[string]interface{} `json:"changes"`
282
+ }
283
+ if err := c.ShouldBindJSON(&updates); err != nil {
284
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
285
+ return
286
+ }
287
+
288
+ err := ctrl.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
289
+ for _, u := range updates {
290
+ if err := tx.Model(&models.{{capitalize name}}{}).Where("id = ?", u.ID).Updates(u.Changes).Error; err != nil {
291
+ return err
292
+ }
293
+ cache.Delete(fmt.Sprintf("%s:{{capitalize name}}:%d", tenantStr, u.ID))
294
+ }
295
+ return nil
296
+ })
297
+
298
+ if err != nil {
299
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
300
+ return
301
+ }
302
+
303
+ c.JSON(http.StatusOK, gin.H{"message": "Bulk patch completed successfully"})
304
+ }
305
+
208
306
  // Delete
209
307
  func (ctrl *{{capitalize name}}Controller) Delete(c *gin.Context) {
210
308
  id := c.Param("id")
@@ -163,10 +163,13 @@ api.GET("/rpc/:table", searchCtrl.GenericSearch)
163
163
  // {{name}} Routes
164
164
  {{toLowerCase name}}Ctrl := controllers.{{capitalize name}}Controller{DB: masterDB, Config: appConfig}
165
165
  api.POST("/{{toLowerCase name}}s", {{toLowerCase name}}Ctrl.Create)
166
+ api.POST("/{{toLowerCase name}}s/bulk", {{toLowerCase name}}Ctrl.BulkCreate)
166
167
  api.GET("/{{toLowerCase name}}s", {{toLowerCase name}}Ctrl.GetAll)
167
168
  api.GET("/{{toLowerCase name}}s/:id", {{toLowerCase name}}Ctrl.GetByID)
168
169
  api.PUT("/{{toLowerCase name}}s/:id", {{toLowerCase name}}Ctrl.Update)
170
+ api.PUT("/{{toLowerCase name}}s/bulk", {{toLowerCase name}}Ctrl.BulkUpdate)
169
171
  api.PATCH("/{{toLowerCase name}}s/:id", {{toLowerCase name}}Ctrl.Patch)
172
+ api.PATCH("/{{toLowerCase name}}s/bulk", {{toLowerCase name}}Ctrl.BulkPatch)
170
173
  api.DELETE("/{{toLowerCase name}}s/:id", {{toLowerCase name}}Ctrl.Delete)
171
174
  {{/each}}
172
175
  // go-duck-needle-add-route