go-duck-cli 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/generators/docs.js +1 -0
- package/generators/multitenancy.js +85 -6
- package/generators/postgrest.js +32 -10
- package/generators/swagger.js +118 -16
- package/package.json +1 -1
- package/templates/docs/pages/multitenancy.hbs +83 -0
- package/templates/docs/pages/rest.hbs +78 -0
- package/templates/docs/pages/security.hbs +21 -1
- package/templates/go/controller.go.hbs +147 -44
- package/templates/go/main.go.hbs +4 -1
package/generators/docs.js
CHANGED
|
@@ -33,6 +33,7 @@ export const generateDocumentation = async (config, entities, outputDir, enums =
|
|
|
33
33
|
{ file: 'gdl', title: 'GDL Reference' },
|
|
34
34
|
{ file: 'cli', title: 'CLI & Code Injection' },
|
|
35
35
|
{ file: 'rest', title: 'REST & Search API' },
|
|
36
|
+
{ file: 'multitenancy', title: 'Multi-Tenancy' },
|
|
36
37
|
{ file: 'grpc', title: 'Kratos gRPC API' },
|
|
37
38
|
{ file: 'graphql', title: 'GraphQL Framework' },
|
|
38
39
|
{ file: 'realtime', title: 'WebSockets & MQTT' },
|
|
@@ -10,14 +10,76 @@ package middleware
|
|
|
10
10
|
import (
|
|
11
11
|
"fmt"
|
|
12
12
|
"net/http"
|
|
13
|
+
"sync"
|
|
13
14
|
|
|
14
15
|
"github.com/gin-gonic/gin"
|
|
16
|
+
"gorm.io/driver/postgres"
|
|
15
17
|
"gorm.io/gorm"
|
|
18
|
+
"{{app_name}}/config"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// TenantDBManager handles dynamic connection pooling for all tenants
|
|
22
|
+
type TenantDBManager struct {
|
|
23
|
+
masterDB *gorm.DB
|
|
24
|
+
configs *config.Config
|
|
25
|
+
conns map[string]*gorm.DB
|
|
26
|
+
mu sync.RWMutex
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var (
|
|
30
|
+
manager *TenantDBManager
|
|
31
|
+
once sync.Once
|
|
16
32
|
)
|
|
17
33
|
|
|
18
|
-
func
|
|
34
|
+
func GetTenantManager(db *gorm.DB, cfg *config.Config) *TenantDBManager {
|
|
35
|
+
once.Do(func() {
|
|
36
|
+
manager = &TenantDBManager{
|
|
37
|
+
masterDB: db,
|
|
38
|
+
configs: cfg,
|
|
39
|
+
conns: make(map[string]*gorm.DB),
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
return manager
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func (m *TenantDBManager) GetDB(dbName string) (*gorm.DB, error) {
|
|
46
|
+
m.mu.RLock()
|
|
47
|
+
if db, ok := m.conns[dbName]; ok {
|
|
48
|
+
m.mu.RUnlock()
|
|
49
|
+
return db, nil
|
|
50
|
+
}
|
|
51
|
+
m.mu.RUnlock()
|
|
52
|
+
|
|
53
|
+
m.mu.Lock()
|
|
54
|
+
defer m.mu.Unlock()
|
|
55
|
+
|
|
56
|
+
// Double check
|
|
57
|
+
if db, ok := m.conns[dbName]; ok {
|
|
58
|
+
return db, nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Dynamic Connection Opening
|
|
62
|
+
ds := m.configs.GoDuck.Datasource
|
|
63
|
+
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=UTC",
|
|
64
|
+
ds.Host, ds.Username, ds.Password, dbName, ds.Port)
|
|
65
|
+
|
|
66
|
+
newDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
|
67
|
+
if err != nil {
|
|
68
|
+
return nil, err
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
m.conns[dbName] = newDB
|
|
72
|
+
return newDB, nil
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TenantMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
76
|
+
mgr := GetTenantManager(db, cfg)
|
|
77
|
+
|
|
19
78
|
return func(c *gin.Context) {
|
|
20
|
-
// 1.
|
|
79
|
+
// 1. Identification (Hint from Header)
|
|
80
|
+
requestedTenant := c.GetHeader("X-Tenant-ID")
|
|
81
|
+
|
|
82
|
+
// 2. Authorization (Extracted from JWT by JWTMiddleware)
|
|
21
83
|
userRolesInterface, exists := c.Get("UserRoles")
|
|
22
84
|
if !exists {
|
|
23
85
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "No roles found in token"})
|
|
@@ -32,17 +94,34 @@ func TenantMiddleware(db *gorm.DB) gin.HandlerFunc {
|
|
|
32
94
|
return
|
|
33
95
|
}
|
|
34
96
|
|
|
35
|
-
//
|
|
97
|
+
// 3. Resolution (Which DB is this role authorized to access?)
|
|
36
98
|
var dbName string
|
|
37
99
|
err := db.Raw("SELECT db_name FROM tenant_roles WHERE role_name IN ? LIMIT 1", roles).Scan(&dbName).Error
|
|
38
100
|
|
|
39
101
|
if err != nil || dbName == "" {
|
|
40
|
-
|
|
41
|
-
|
|
102
|
+
c.JSON(http.StatusForbidden, gin.H{"error": "Security: No tenant context mapped to user roles"})
|
|
103
|
+
c.Abort()
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 4. Security Check (Prevent Cross-Tenant Spoofing)
|
|
108
|
+
if requestedTenant != "" && requestedTenant != dbName {
|
|
109
|
+
c.JSON(http.StatusForbidden, gin.H{"error": "Security Breach: Header/Token tenant mismatch"})
|
|
110
|
+
c.Abort()
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 5. Dynamic Switching (Get or Create the DB Connection)
|
|
115
|
+
tenantConn, err := mgr.GetDB(dbName)
|
|
116
|
+
if err != nil {
|
|
117
|
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve tenant database connection"})
|
|
118
|
+
c.Abort()
|
|
119
|
+
return
|
|
42
120
|
}
|
|
43
121
|
|
|
44
|
-
//
|
|
122
|
+
// 6. Inject Live Connection into Context
|
|
45
123
|
c.Set("tenantDB", dbName)
|
|
124
|
+
c.Set("tenantDBConn", tenantConn)
|
|
46
125
|
c.Next()
|
|
47
126
|
}
|
|
48
127
|
}
|
package/generators/postgrest.js
CHANGED
|
@@ -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(
|
|
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(
|
|
70
|
+
query = query.Where(processedKey+" = ?", target)
|
|
52
71
|
case "neq":
|
|
53
|
-
query = query.Where(
|
|
72
|
+
query = query.Where(processedKey+" <> ?", target)
|
|
54
73
|
case "gt":
|
|
55
|
-
query = query.Where(
|
|
74
|
+
query = query.Where(processedKey+" > ?", target)
|
|
56
75
|
case "gte":
|
|
57
|
-
query = query.Where(
|
|
76
|
+
query = query.Where(processedKey+" >= ?", target)
|
|
58
77
|
case "lt":
|
|
59
|
-
query = query.Where(
|
|
78
|
+
query = query.Where(processedKey+" < ?", target)
|
|
60
79
|
case "lte":
|
|
61
|
-
query = query.Where(
|
|
80
|
+
query = query.Where(processedKey+" <= ?", target)
|
|
62
81
|
case "like":
|
|
63
|
-
query = query.Where(
|
|
82
|
+
query = query.Where(processedKey+" LIKE ?", "%"+target+"%")
|
|
64
83
|
case "ilike":
|
|
65
|
-
query = query.Where(
|
|
84
|
+
query = query.Where(processedKey+" ILIKE ?", "%"+target+"%")
|
|
66
85
|
case "in":
|
|
67
86
|
list := strings.Split(target, ",")
|
|
68
|
-
query = query.Where(
|
|
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
|
}
|
package/generators/swagger.js
CHANGED
|
@@ -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
|
-
|
|
69
|
-
{ name: '
|
|
70
|
-
{ name: '
|
|
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: [
|
|
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
|
|
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
|
-
|
|
114
|
-
{ name: '
|
|
115
|
-
{ name: '
|
|
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: {
|
|
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: ['
|
|
124
|
-
summary: '
|
|
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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<div class="mb-10 text-center lg:text-left border-b border-slate-200 pb-8">
|
|
2
|
+
<div class="inline-flex items-center px-3 py-1 rounded-full bg-blue-100 text-blue-700 text-xs font-semibold tracking-wide uppercase mb-4">
|
|
3
|
+
Enterprise Architecture
|
|
4
|
+
</div>
|
|
5
|
+
<h1 class="text-4xl lg:text-5xl font-extrabold text-slate-900 tracking-tight leading-tight mb-4">Dynamic Multi-Tenancy</h1>
|
|
6
|
+
<p class="text-lg lg:text-xl text-slate-600 max-w-2xl leading-relaxed">Secure, high-performance database-per-tenant isolation with real-time connection hot-swapping.</p>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<section class="mb-12">
|
|
10
|
+
<h2 class="text-2xl font-bold text-slate-800 mb-6 flex items-center">
|
|
11
|
+
<span class="w-8 h-8 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center mr-3 text-sm">
|
|
12
|
+
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
|
|
13
|
+
</span>
|
|
14
|
+
The Dual-DB Architecture
|
|
15
|
+
</h2>
|
|
16
|
+
<p class="mb-6 text-slate-600 leading-relaxed">GO-DUCK separates management logic from customer data. Your application maintains a persistent connection to a <strong>Master Database</strong> while dynamically "Relocating" per-request traffic to isolated <strong>Tenant Databases</strong>.</p>
|
|
17
|
+
|
|
18
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
|
19
|
+
<div class="p-6 rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
20
|
+
<div class="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center mb-4">
|
|
21
|
+
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
|
22
|
+
</div>
|
|
23
|
+
<h3 class="text-xl font-bold text-slate-900 mb-2">Master Registry</h3>
|
|
24
|
+
<p class="text-slate-600 text-sm leading-relaxed">The <code>tenant_roles</code> table in the Master DB acts as the "Grand Concierge," mapping authenticated user roles to physical database names at runtime.</p>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="p-6 rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
27
|
+
<div class="w-12 h-12 bg-emerald-100 rounded-xl flex items-center justify-center mb-4">
|
|
28
|
+
<svg class="w-6 h-6 text-emerald-600" 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>
|
|
29
|
+
</div>
|
|
30
|
+
<h3 class="text-xl font-bold text-slate-900 mb-2">Hot-Swapping Pools</h3>
|
|
31
|
+
<p class="text-slate-600 text-sm leading-relaxed">The <code>TenantDBManager</code> uses lazy loading to open and cache connection pools for tenants only when they are active, ensuring minimal resource overhead.</p>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</section>
|
|
35
|
+
|
|
36
|
+
<section class="mb-12">
|
|
37
|
+
<h2 class="text-2xl font-bold text-slate-800 mb-6 flex items-center">
|
|
38
|
+
<span class="w-8 h-8 rounded-lg bg-rose-100 text-rose-600 flex items-center justify-center mr-3 text-sm">
|
|
39
|
+
<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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 00-2 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
|
|
40
|
+
</span>
|
|
41
|
+
Security & Anti-Spoofing
|
|
42
|
+
</h2>
|
|
43
|
+
<p class="mb-6 text-slate-600 leading-relaxed">Header-based tenancy (<code>X-Tenant-ID</code>) is convenient but dangerous. GO-DUCK solves this by cryptographically validating the tenant context against the JWT.</p>
|
|
44
|
+
|
|
45
|
+
<div class="bg-slate-900 rounded-2xl p-6 shadow-xl mb-6">
|
|
46
|
+
<h4 class="text-emerald-400 font-mono text-sm mb-4">// Internal Verification Loop</h4>
|
|
47
|
+
<pre class="text-slate-300 font-mono text-sm leading-relaxed">
|
|
48
|
+
1. Extract Roles from signed Keycloak JWT
|
|
49
|
+
2. Lookup authorized DB in Master mapping table
|
|
50
|
+
3. Compare against X-Tenant-ID header
|
|
51
|
+
4. If Mismatch: Return 403 Forbidden (Security Breach)
|
|
52
|
+
5. If Match: Activate Dynamic Connection Pool
|
|
53
|
+
</pre>
|
|
54
|
+
</div>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
<section class="mb-12">
|
|
58
|
+
<h2 class="text-2xl font-bold text-slate-800 mb-6 flex items-center">
|
|
59
|
+
<span class="w-8 h-8 rounded-lg bg-amber-100 text-amber-600 flex items-center justify-center mr-3 text-sm">
|
|
60
|
+
<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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path></svg>
|
|
61
|
+
</span>
|
|
62
|
+
Tenant Provisioning API
|
|
63
|
+
</h2>
|
|
64
|
+
<p class="mb-6 text-slate-600 leading-relaxed">Adding a new customer is a single atomic operation. Use the management API to provision a side-by-side database instantly.</p>
|
|
65
|
+
|
|
66
|
+
<div class="bg-[#1e1e1e] rounded-xl overflow-hidden shadow-lg mb-4">
|
|
67
|
+
<div class="bg-[#2d2d2d] px-4 py-2 border-b border-[#404040] flex items-center">
|
|
68
|
+
<span class="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400 text-[10px] font-bold mr-3">POST</span>
|
|
69
|
+
<span class="text-xs text-slate-300 font-mono">/management/db/create</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="p-5 text-sm font-mono text-slate-300">
|
|
72
|
+
{
|
|
73
|
+
"role": "ROLE_ACME_CORP",
|
|
74
|
+
"db_name": "acme_isolated_db"
|
|
75
|
+
}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<ul class="space-y-3 text-slate-600 text-sm">
|
|
79
|
+
<li class="flex items-center"><svg class="w-4 h-4 text-emerald-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Executing physical <code>CREATE DATABASE</code></li>
|
|
80
|
+
<li class="flex items-center"><svg class="w-4 h-4 text-emerald-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Mapping role to DB in <code>tenant_roles</code> Master table</li>
|
|
81
|
+
<li class="flex items-center"><svg class="w-4 h-4 text-emerald-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Triggering automated Liquibase migrations on new DB</li>
|
|
82
|
+
</ul>
|
|
83
|
+
</section>
|
|
@@ -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">
|
|
@@ -9,7 +9,27 @@
|
|
|
9
9
|
<p class="text-indigo-900"><strong>Golden Rule:</strong> In <code>application-dev.yml</code>, ensure your Keycloak Realm, ClientID, and Secret are accurately synced with the local running Docker Keycloak image.</p>
|
|
10
10
|
</div>
|
|
11
11
|
|
|
12
|
-
<
|
|
12
|
+
<h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">2. Tenant Context Integrity</h2>
|
|
13
|
+
<p class="mb-4">
|
|
14
|
+
GO-DUCK implements <strong>Enterprise-Grade Data Isolation</strong>. While the <code>X-Tenant-ID</code> header is required for request context, the system is immune to "Tenant Spoofing" attacks.
|
|
15
|
+
</p>
|
|
16
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
17
|
+
<div class="bg-slate-50 p-5 rounded-xl border border-slate-200">
|
|
18
|
+
<h4 class="font-bold text-slate-900 mb-2">Internal Cross-Verification</h4>
|
|
19
|
+
<p class="text-xs text-slate-600 leading-relaxed">
|
|
20
|
+
The <code>TenantMiddleware</code> extracts authorized roles from the signed JWT and performs a server-side lookup of the valid DB context. It then <strong>cross-checks</strong> this against the <code>X-Tenant-ID</code> header.
|
|
21
|
+
</p>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="bg-rose-50 p-5 rounded-xl border border-rose-200">
|
|
24
|
+
<h4 class="font-bold text-rose-900 mb-2">Spoof Protection</h4>
|
|
25
|
+
<p class="text-xs text-rose-700 leading-relaxed">
|
|
26
|
+
If a user attempts to access <code>Tenant-B</code> by modifying headers while their JWT is only valid for <code>Tenant-A</code>, the request is immediately aborted with a <code>403 Forbidden - Security Breach</code> error.
|
|
27
|
+
<a href="multitenancy.html" class="text-rose-600 font-bold hover:underline mt-2 inline-block">Learn more about isolation →</a>
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<h3 class="font-semibold mb-2 mt-6">3. Burst Protection (Rate Limiting)</h3>
|
|
13
33
|
<p class="mb-4">Using the standard <code>x/time/rate</code> package, a Token Bucket rate limiter is attached to the Gin Engine globally to mitigate DDOS vectors and abusive scripting.</p>
|
|
14
34
|
<pre><code class="language-yaml"># Inside your application-prod.yml
|
|
15
35
|
go-duck:
|
|
@@ -21,20 +21,20 @@ Config *config.Config
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// Create{{capitalize name}}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
var entity models.{{capitalize name}}
|
|
30
|
-
if err := c.ShouldBindJSON(&entity); err != nil {
|
|
31
|
-
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
if err :=
|
|
35
|
-
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
36
|
-
return
|
|
37
|
-
}
|
|
24
|
+
db := ctrl.DB
|
|
25
|
+
if tdb, exists := c.Get("tenantDBConn"); exists {
|
|
26
|
+
db = tdb.(*gorm.DB)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var entity models.{{capitalize name}}
|
|
30
|
+
if err := c.ShouldBindJSON(&entity); err != nil {
|
|
31
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if err := db.WithContext(ctx).Create(&entity).Error; err != nil {
|
|
35
|
+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
38
|
|
|
39
39
|
// Dynamic Cache Invalidation (Tenant Aware)
|
|
40
40
|
cache.ClearPattern(tenantStr + ":{{capitalize name}}*")
|
|
@@ -50,9 +50,13 @@ c.JSON(http.StatusCreated, entity)
|
|
|
50
50
|
|
|
51
51
|
// GetAll{{capitalize name}}s (with filtering, pagination, and lazy/eager loading)
|
|
52
52
|
func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
db := ctrl.DB
|
|
54
|
+
if tdb, exists := c.Get("tenantDBConn"); exists {
|
|
55
|
+
db = tdb.(*gorm.DB)
|
|
56
|
+
}
|
|
57
|
+
var entities []models.{{capitalize name}}
|
|
58
|
+
ctx := c.Request.Context()
|
|
59
|
+
query := db.WithContext(ctx)
|
|
56
60
|
|
|
57
61
|
// 1. Pagination
|
|
58
62
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "0"))
|
|
@@ -104,8 +108,12 @@ c.JSON(http.StatusOK, entity)
|
|
|
104
108
|
return
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
// 2. Fallback to DB (With Context for Tracing)
|
|
108
|
-
|
|
111
|
+
// 2. Fallback to DB (With Context for Tracing)
|
|
112
|
+
db := ctrl.DB
|
|
113
|
+
if tdb, exists := c.Get("tenantDBConn"); exists {
|
|
114
|
+
db = tdb.(*gorm.DB)
|
|
115
|
+
}
|
|
116
|
+
query := db.WithContext(ctx)
|
|
109
117
|
if c.Query("eager") == "true" {
|
|
110
118
|
{{#each relationships}}
|
|
111
119
|
query = query.Preload("{{capitalize from.field}}")
|
|
@@ -128,14 +136,13 @@ c.JSON(http.StatusOK, entity)
|
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
// Update (PUT) - Full Update
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
ctx := c.Request.Context()
|
|
139
|
+
db := ctrl.DB
|
|
140
|
+
if tdb, exists := c.Get("tenantDBConn"); exists {
|
|
141
|
+
db = tdb.(*gorm.DB)
|
|
142
|
+
}
|
|
136
143
|
|
|
137
|
-
var entity models.{{capitalize name}}
|
|
138
|
-
if err :=
|
|
144
|
+
var entity models.{{capitalize name}}
|
|
145
|
+
if err := db.WithContext(ctx).First(&entity, id).Error; err != nil {
|
|
139
146
|
c.JSON(http.StatusNotFound, gin.H{"error": "Resource not found"})
|
|
140
147
|
return
|
|
141
148
|
}
|
|
@@ -148,7 +155,7 @@ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
|
148
155
|
return
|
|
149
156
|
}
|
|
150
157
|
|
|
151
|
-
if err :=
|
|
158
|
+
if err := db.WithContext(ctx).Save(&entity).Error; err != nil {
|
|
152
159
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
153
160
|
return
|
|
154
161
|
}
|
|
@@ -166,14 +173,13 @@ c.JSON(http.StatusOK, entity)
|
|
|
166
173
|
}
|
|
167
174
|
|
|
168
175
|
// Patch (PATCH) - Partial Update
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
ctx := c.Request.Context()
|
|
176
|
+
db := ctrl.DB
|
|
177
|
+
if tdb, exists := c.Get("tenantDBConn"); exists {
|
|
178
|
+
db = tdb.(*gorm.DB)
|
|
179
|
+
}
|
|
174
180
|
|
|
175
|
-
var entity models.{{capitalize name}}
|
|
176
|
-
if err :=
|
|
181
|
+
var entity models.{{capitalize name}}
|
|
182
|
+
if err := db.WithContext(ctx).First(&entity, id).Error; err != nil {
|
|
177
183
|
c.JSON(http.StatusNotFound, gin.H{"error": "Resource not found"})
|
|
178
184
|
return
|
|
179
185
|
}
|
|
@@ -185,13 +191,13 @@ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
|
185
191
|
return
|
|
186
192
|
}
|
|
187
193
|
|
|
188
|
-
if err :=
|
|
194
|
+
if err := db.WithContext(ctx).Model(&entity).Updates(updates).Error; err != nil {
|
|
189
195
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
190
196
|
return
|
|
191
197
|
}
|
|
192
198
|
|
|
193
199
|
// Fetch updated
|
|
194
|
-
|
|
200
|
+
db.WithContext(ctx).First(&entity, id)
|
|
195
201
|
|
|
196
202
|
// Cache Invalidation (Tenant Aware)
|
|
197
203
|
cache.Delete(fmt.Sprintf("%s:{{capitalize name}}:%s", tenantStr, id))
|
|
@@ -205,20 +211,117 @@ return nil, nil
|
|
|
205
211
|
c.JSON(http.StatusOK, gin.H{"message": "Updated successfully", "data": entity})
|
|
206
212
|
}
|
|
207
213
|
|
|
214
|
+
// BulkCreate handles creating multiple entities in one transaction
|
|
215
|
+
db := ctrl.DB
|
|
216
|
+
if tdb, exists := c.Get("tenantDBConn"); exists {
|
|
217
|
+
db = tdb.(*gorm.DB)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
var entities []models.{{capitalize name}}
|
|
221
|
+
if err := c.ShouldBindJSON(&entities); err != nil {
|
|
222
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if err := db.WithContext(ctx).Create(&entities).Error; err != nil {
|
|
227
|
+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Dynamic Cache Invalidation (Tenant Aware)
|
|
232
|
+
cache.ClearPattern(tenantStr + ":{{capitalize name}}*")
|
|
233
|
+
|
|
234
|
+
// MQTT Event (Resilient)
|
|
235
|
+
resilience.Execute(func() (interface{}, error) {
|
|
236
|
+
messaging.PublishEvent(ctrl.Config.GoDuck.Messaging.MQTT.TopicPrefix, "BULK_CREATE", "{{capitalize name}}", entities, nil)
|
|
237
|
+
return nil, nil
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
c.JSON(http.StatusCreated, entities)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// BulkUpdate handles updating multiple entities in one transaction
|
|
244
|
+
db := ctrl.DB
|
|
245
|
+
if tdb, exists := c.Get("tenantDBConn"); exists {
|
|
246
|
+
db = tdb.(*gorm.DB)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
var entities []models.{{capitalize name}}
|
|
250
|
+
if err := c.ShouldBindJSON(&entities); err != nil {
|
|
251
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
256
|
+
for _, e := range entities {
|
|
257
|
+
if err := tx.Save(&e).Error; err != nil {
|
|
258
|
+
return err
|
|
259
|
+
}
|
|
260
|
+
cache.Delete(fmt.Sprintf("%s:{{capitalize name}}:%d", tenantStr, e.ID))
|
|
261
|
+
}
|
|
262
|
+
return nil
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
if err != nil {
|
|
266
|
+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// MQTT Event (Resilient)
|
|
271
|
+
resilience.Execute(func() (interface{}, error) {
|
|
272
|
+
messaging.PublishEvent(ctrl.Config.GoDuck.Messaging.MQTT.TopicPrefix, "BULK_UPDATE", "{{capitalize name}}", entities, nil)
|
|
273
|
+
return nil, nil
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
c.JSON(http.StatusOK, entities)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// BulkPatch handles partial updates for multiple entities
|
|
280
|
+
db := ctrl.DB
|
|
281
|
+
if tdb, exists := c.Get("tenantDBConn"); exists {
|
|
282
|
+
db = tdb.(*gorm.DB)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
var updates []struct {
|
|
286
|
+
ID uint `json:"id"`
|
|
287
|
+
Changes map[string]interface{} `json:"changes"`
|
|
288
|
+
}
|
|
289
|
+
if err := c.ShouldBindJSON(&updates); err != nil {
|
|
290
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
295
|
+
for _, u := range updates {
|
|
296
|
+
if err := tx.Model(&models.{{capitalize name}}{}).Where("id = ?", u.ID).Updates(u.Changes).Error; err != nil {
|
|
297
|
+
return err
|
|
298
|
+
}
|
|
299
|
+
cache.Delete(fmt.Sprintf("%s:{{capitalize name}}:%d", tenantStr, u.ID))
|
|
300
|
+
}
|
|
301
|
+
return nil
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
if err != nil {
|
|
305
|
+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
c.JSON(http.StatusOK, gin.H{"message": "Bulk patch completed successfully"})
|
|
310
|
+
}
|
|
311
|
+
|
|
208
312
|
// Delete
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
ctx := c.Request.Context()
|
|
313
|
+
db := ctrl.DB
|
|
314
|
+
if tdb, exists := c.Get("tenantDBConn"); exists {
|
|
315
|
+
db = tdb.(*gorm.DB)
|
|
316
|
+
}
|
|
214
317
|
|
|
215
|
-
var entity models.{{capitalize name}}
|
|
216
|
-
if err :=
|
|
318
|
+
var entity models.{{capitalize name}}
|
|
319
|
+
if err := db.WithContext(ctx).First(&entity, id).Error; err != nil {
|
|
217
320
|
c.JSON(http.StatusNotFound, gin.H{"error": "Resource not found"})
|
|
218
321
|
return
|
|
219
322
|
}
|
|
220
323
|
|
|
221
|
-
if err :=
|
|
324
|
+
if err := db.WithContext(ctx).Delete(&entity).Error; err != nil {
|
|
222
325
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
223
326
|
return
|
|
224
327
|
}
|
package/templates/go/main.go.hbs
CHANGED
|
@@ -143,7 +143,7 @@ mgmt.POST("/db/create", management.CreateDatabaseAndMigrate(masterDB))
|
|
|
143
143
|
// 9. Secured Application APIs
|
|
144
144
|
api := r.Group("/api")
|
|
145
145
|
api.Use(middleware.JWTMiddleware())
|
|
146
|
-
api.Use(middleware.TenantMiddleware(masterDB))
|
|
146
|
+
api.Use(middleware.TenantMiddleware(masterDB, appConfig))
|
|
147
147
|
api.Use(middleware.AuditMiddleware(masterDB))
|
|
148
148
|
api.Use(middleware.MeteringMiddleware(masterDB))
|
|
149
149
|
{
|
|
@@ -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
|