go-duck-cli 1.2.0 β 1.2.2
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 +43 -5
- package/generators/ai_docs.js +21 -8
- package/generators/config.js +13 -7
- package/generators/elasticsearch.js +3 -4
- package/generators/migrations.js +75 -2
- package/generators/outbox.js +1 -0
- package/generators/postman.js +156 -6
- package/index.js +107 -7
- package/package.json +1 -1
- package/parser/gdl.js +2 -0
- package/templates/docs/index.html.hbs +26 -2
- package/templates/docs/pages/cli.hbs +20 -4
- package/templates/docs/pages/gdl-annotations.hbs +17 -0
- package/templates/docs/pages/rest.hbs +21 -0
- package/templates/go/controller.go.hbs +107 -20
- package/templates/go/main.go.hbs +16 -2
- package/templates/go/router.go.hbs +15 -0
- package/templates/kratos/service.go.hbs +25 -2
- package/templates/proto/entity.proto.hbs +1 -0
package/README.md
CHANGED
|
@@ -43,9 +43,9 @@ But speed without strength is a house made of cards. In the digital forge of the
|
|
|
43
43
|
|
|
44
44
|
Thus, the **GDL (Go-Duck Language)** was hatched. A single, simple tongue that could command entire legions of code. From that day forth, every developer who whispered GDL into the CLI would see their architecture evolveβbringing the Gopher's speed, the Duck's wisdom, the Gin's clarity, and the Kratos' strength into a single, unified masterpiece.
|
|
45
45
|
|
|
46
|
-
## π¦ The
|
|
46
|
+
## π¦ The 410% Elite Status: Milestone Surpassed
|
|
47
47
|
|
|
48
|
-
GO-DUCK has officially reached the **
|
|
48
|
+
GO-DUCK has officially reached the **410% Achievement Status**, evolving from a code generator into a **High-Velocity Distributed Orchestrator.** This elite status confirms that the framework has surpassed the original 350% milestone with industrial-grade Universal Storage extensions, real-time remote bootstrapping, WSO2 integration, and full-spectrum GDL evolution.
|
|
49
49
|
|
|
50
50
|
| Milestone Component | Status | Technical Value Add |
|
|
51
51
|
| :--- | :--- | :--- |
|
|
@@ -57,11 +57,15 @@ GO-DUCK has officially reached the **375% Achievement Status**, evolving from a
|
|
|
57
57
|
| **Super Admin Security Boundary** | π **ELITE (+13%)** | Strict isolation between Business and Infrastructure Control APIs. |
|
|
58
58
|
| **Silo Discovery & Privacy Proxy** | π **ELITE (+10%)** | Silo discovery API with physical DB name masking. |
|
|
59
59
|
| **Universal Storage Mesh** | π **ELITE (+25%)** | Dynamic Hot-Swapping Registry and Distributed Cross-Scan API retrieval. |
|
|
60
|
-
| **
|
|
60
|
+
| **WSO2 API Gateway Integration** | π **ELITE (+15%)** | Automated OpenAPI registration & proxy mapping. |
|
|
61
|
+
| **Full-Spectrum GDL Evolution** | π **ELITE (+15%)** | Native DROP/ALTER migrations with dead-code purging. |
|
|
62
|
+
| **TOTAL ACHIEVEMENT STATUS** | π **410%** | **ELITE STATUS CONFIRMED.** π |
|
|
61
63
|
|
|
62
|
-
### β¨ Primary Features (The
|
|
64
|
+
### β¨ Primary Features (The 410% Core)
|
|
63
65
|
|
|
64
66
|
* **Federated Multi-Tenancy**: Side-by-side **Database-per-Tenant isolation** with a **Master-Tenant Registry** (Role β DB β Opaque UUID).
|
|
67
|
+
* **Multi-Protocol REST**: Dynamically switchable REST rendering using standard `json` or high-performance `messagepack` based on configuration.
|
|
68
|
+
* **gRPC-Web Native Proxy**: Natively wraps Kratos gRPC to automatically serve a web proxy (e.g. on port `9090`) to allow direct frontend Protobuf interaction.
|
|
65
69
|
* **Industrial-Grade Parallel Harvester**: Asynchronous goroutine-based data aggregation across multiple silos with `?federated=true` opt-in.
|
|
66
70
|
* **Precision Harvesting**: Surgical multi-silo selection via comma-separated `X-Tenant-ID` headers.
|
|
67
71
|
* **Super Admin Security Boundaries**: Strict architectural separation between Business APIs and Infrastructure Control APIs.
|
|
@@ -70,9 +74,12 @@ GO-DUCK has officially reached the **375% Achievement Status**, evolving from a
|
|
|
70
74
|
* **Distributed Saga Consistency**: Integrated **Transactional Outbox** pattern and background workers in every silo to guarantee eventual consistency across the federation.
|
|
71
75
|
* **Zero-Trust Identity Registry**: Decoupled mapping layer ensuring physical database names and internal IDs never leak to the client.
|
|
72
76
|
* **Universal Storage Mesh**: Dynamic Multi-Provider Registry allowing hot-swapping at runtime via `?provider=` queries, alongside Distributed Cross-Scan endpoints to auto-locate files across AWS, GCS, SFTP, and GitHub lakes.
|
|
73
|
-
* **Spring-style Elasticsearch Search**: Real-time sync for entities marked with `@Searchable`, supporting
|
|
77
|
+
* **Spring-style Elasticsearch Search**: Real-time sync for entities marked with `@Searchable`, supporting native `query_string` syntax (wildcards like `*`, booleans, ranges, and fuzzy matching).
|
|
74
78
|
* **SaaS Quota Engine**: Redis-backed API bandwidth tracking with dynamic, hierarchical limits (User vs. Role mapping).
|
|
75
79
|
* **Resilience Layer**: Sony/Gobreaker Integration + Zero-Trust Distributed Redis Rate Limiter.
|
|
80
|
+
* **Comprehensive GDL Schema Evolution**: Detects structural deltas intelligently and automatically generates Goose SQL for dropped entities (`DROP TABLE`), dropped fields (`DROP COLUMN`), and altered fields (`ALTER TYPE`, constraints) to keep databases in absolute sync.
|
|
81
|
+
* **Dead-Code Purging**: Automatically cleans up orphaned Go models and controllers when removing entities from the `.gdl` blueprint.
|
|
82
|
+
* **Saga Outbox GORM Stability**: Generates robust, compile-ready `outbox_worker.go` components with native GORM package resolution.
|
|
76
83
|
|
|
77
84
|
### πΊοΈ System Topology & Identity Registry
|
|
78
85
|
|
|
@@ -151,6 +158,37 @@ docker-compose up -d
|
|
|
151
158
|
go run main.go
|
|
152
159
|
```
|
|
153
160
|
|
|
161
|
+
## π Calling the Multi-Protocol Endpoints
|
|
162
|
+
|
|
163
|
+
GO-DUCK automatically exposes your microservice across three distinct ports to serve different architectural needs without code duplication:
|
|
164
|
+
|
|
165
|
+
1. **REST API (Port 8080 by default)**
|
|
166
|
+
Standard HTTP endpoints for web browsers and legacy clients.
|
|
167
|
+
- **JSON (Default):** Perfect for general use.
|
|
168
|
+
- **MessagePack (High Performance):** To switch to binary MessagePack, update your `config.yaml` before generation:
|
|
169
|
+
```yaml
|
|
170
|
+
server:
|
|
171
|
+
rest:
|
|
172
|
+
port: 8080
|
|
173
|
+
protocol: "messagepack"
|
|
174
|
+
```
|
|
175
|
+
Clients must send the `Accept: application/msgpack` and `Content-Type: application/msgpack` headers to consume this endpoint correctly.
|
|
176
|
+
- **Pagination & Sorting:** All `GetAll` endpoints support pagination via `?page=1&size=20`, and dynamic sorting via `?sort=field,asc` or `?sort=field,desc` (e.g. `?sort=name,desc`).
|
|
177
|
+
|
|
178
|
+
2. **Native Kratos gRPC (Port 9000 by default)**
|
|
179
|
+
Pure HTTP/2 Protobuf communication. Used strictly for internal **backend-to-backend** communication (e.g., Microservice A calling Microservice B) where maximum throughput is required.
|
|
180
|
+
|
|
181
|
+
3. **gRPC-Web Proxy (Port 9090 by default)**
|
|
182
|
+
Web browsers cannot speak raw HTTP/2 gRPC. This port acts as an HTTP/1.1 proxy bridge, allowing frontend frameworks (React, Angular, Vue) to interact directly with the Protobuf gRPC contracts.
|
|
183
|
+
To disable or change the proxy port, update your `config.yaml`:
|
|
184
|
+
```yaml
|
|
185
|
+
server:
|
|
186
|
+
grpc:
|
|
187
|
+
web_enabled: true
|
|
188
|
+
web_port: 9090
|
|
189
|
+
```
|
|
190
|
+
*Frontend Tip: Use the `grpc-web` npm package to generate a frontend client that communicates with this port!*
|
|
191
|
+
|
|
154
192
|
## Usage
|
|
155
193
|
|
|
156
194
|
The `go-duck-cli` has two main commands: `create` and `import-gdl`.
|
package/generators/ai_docs.js
CHANGED
|
@@ -52,7 +52,7 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
|
|
|
52
52
|
for (const entity of entities) {
|
|
53
53
|
const routeName = entity.name.toLowerCase() + 's';
|
|
54
54
|
endpointsContent += `\n#### ${entity.name}\n`;
|
|
55
|
-
endpointsContent += `- \`GET /api/${routeName}\` (Pagination, e.g. \`?page=1&size=10&eager=true\`)\n`;
|
|
55
|
+
endpointsContent += `- \`GET /api/${routeName}\` (Pagination & dynamic sorting, e.g. \`?page=1&size=10&eager=true&sort=id,asc\`)\n`;
|
|
56
56
|
endpointsContent += `- \`GET /api/${routeName}/:id\`\n`;
|
|
57
57
|
endpointsContent += `- \`POST /api/${routeName}\`\n`;
|
|
58
58
|
endpointsContent += `- \`PUT /api/${routeName}/:id\`\n`;
|
|
@@ -88,6 +88,7 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
|
|
|
88
88
|
if (entity.isSearchable) entitiesContent += `- \`@Searchable\`\n`;
|
|
89
89
|
if (entity.isAudited) entitiesContent += `- \`@Audited\`\n`;
|
|
90
90
|
if (entity.isFederated) entitiesContent += `- \`@Federated\`\n`;
|
|
91
|
+
if (entity.isDelete) entitiesContent += `- \`@Delete\`\n`;
|
|
91
92
|
|
|
92
93
|
entitiesContent += `\nFields:\n`;
|
|
93
94
|
for (const field of entity.fields) {
|
|
@@ -101,16 +102,20 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
|
|
|
101
102
|
// 4. PROTOCOLS.md
|
|
102
103
|
let protoContent = `# Additional Network Protocols\n\n`;
|
|
103
104
|
protoContent += `## GraphQL Surface\n`;
|
|
104
|
-
protoContent += `- **Endpoint**: \`POST /
|
|
105
|
-
protoContent += `- **Playground**:
|
|
105
|
+
protoContent += `- **Endpoint**: \`POST /graphql\` (Secured via JWT and Tenant headers)\n`;
|
|
106
|
+
protoContent += `- **Playground**: GraphQL Playground UI is enabled on the server.\n`;
|
|
107
|
+
protoContent += `- **Queries**: \`list[Entity]s(page: Int, size: Int)\`, \`get[Entity](id: ID!)\`\n`;
|
|
108
|
+
protoContent += `- **Mutations**: \`create[Entity](input: CreateInput)\`, \`update[Entity](id: ID!, input: UpdateInput)\`, \`delete[Entity](id: ID!)\`\n`;
|
|
109
|
+
protoContent += `- **Postman Collection Integration**: The auto-generated Postman collection includes a comprehensive **GraphQL Federation Layer** folder containing test queries and mutations for all entities.\n\n`;
|
|
106
110
|
|
|
107
111
|
protoContent += `## WebSocket Engine\n`;
|
|
108
|
-
protoContent += `- **Endpoint**: \`ws[s]://host/ws\`\n`;
|
|
109
|
-
protoContent += `- **Format**: JSON Envelope Payload \`{ "type": "...", "payload": {...} }\`\n\n`;
|
|
112
|
+
protoContent += `- **Endpoint**: \`ws[s]://host/ws?token={JWT}\`\n`;
|
|
113
|
+
protoContent += `- **Format**: JSON Envelope Payload with HMAC-SHA256 digital signature: \`{ "type": "...", "payload": {...} }\`\n\n`;
|
|
110
114
|
|
|
111
|
-
protoContent += `## gRPC
|
|
112
|
-
protoContent += `- **Port**: \`9000\` (
|
|
113
|
-
protoContent += `- **
|
|
115
|
+
protoContent += `## gRPC & gRPC-Web Engine\n`;
|
|
116
|
+
protoContent += `- **Native Kratos gRPC Port**: \`9000\` (TCP)\n`;
|
|
117
|
+
protoContent += `- **gRPC-Web Proxy Port**: \`9090\` (HTTP/1.1 for web clients)\n`;
|
|
118
|
+
protoContent += `- **Proto Definitions**: Located in \`api/v1/*.proto\`\n`;
|
|
114
119
|
|
|
115
120
|
await fs.writeFile(path.join(aiDocsDir, 'PROTOCOLS.md'), protoContent);
|
|
116
121
|
|
|
@@ -118,6 +123,14 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
|
|
|
118
123
|
let agentInstructions = `# LLM / AI AGENT INSTRUCTIONS π€\n\n`;
|
|
119
124
|
agentInstructions += `Welcome to the generated codebase for **${appName}**.\n\n`;
|
|
120
125
|
agentInstructions += `This application was generated by the GO-DUCK-CLI (An advanced Evolutionary Go Code Generator).\n\n`;
|
|
126
|
+
agentInstructions += `### High-Velocity Commands:\n`;
|
|
127
|
+
agentInstructions += `- **Build Project**: \`go build ./...\`\n`;
|
|
128
|
+
agentInstructions += `- **Protobuf Compilation**: \`./generate.sh\` (or \`.\\generate.bat\` on Windows)\n`;
|
|
129
|
+
agentInstructions += `- **Local Dependencies & Dev Boot**: \`docker-compose up -d && go run main.go\`\n\n`;
|
|
130
|
+
agentInstructions += `### GDL Evolution & Schema Deletions:\n`;
|
|
131
|
+
agentInstructions += `- **Snapshot Merging (Multi-File GDL)**: The generator implements stateful snapshot merging. Running \`import-gdl\` on a single GDL file will NOT wipe out other entities; previous snapshots are retrieved from the \`.go-duck/\` state folder and merged automatically.\n`;
|
|
132
|
+
agentInstructions += `- **Altering/Dropping Fields**: To add, drop, or edit fields within an entity, update the fields inline in the GDL entity block. Running \`import-gdl\` generates targeted Goose SQL column-level migrations.\n`;
|
|
133
|
+
agentInstructions += `- **Dropping Entities**: To completely delete an entity, its database table, all generated code files, and its snapshot, append the \`@Delete\` annotation above the entity definition block and run \`import-gdl\`.\n\n`;
|
|
121
134
|
agentInstructions += `### How to navigate this system:\n`;
|
|
122
135
|
agentInstructions += `The full system specifications and dynamically generated blueprints have been exported for you in the \`docs/ai/\` folder. Look there before attempting any code modifications.\n\n`;
|
|
123
136
|
agentInstructions += `- **[docs/ai/ARCHITECTURE.md](docs/ai/ARCHITECTURE.md)**: Describes the databases, caching layers, configuration structure, and middleware enabled in this project.\n`;
|
package/generators/config.js
CHANGED
|
@@ -25,13 +25,16 @@ type Config struct {
|
|
|
25
25
|
Description string \`mapstructure:"description"\`
|
|
26
26
|
|
|
27
27
|
Server struct {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
REST struct {
|
|
29
|
+
Port int \`mapstructure:"port"\`
|
|
30
|
+
Protocol string \`mapstructure:"protocol"\`
|
|
31
|
+
} \`mapstructure:"rest"\`
|
|
31
32
|
GRPC struct {
|
|
32
|
-
Addr
|
|
33
|
-
Network
|
|
34
|
-
Timeout
|
|
33
|
+
Addr string \`mapstructure:"addr"\`
|
|
34
|
+
Network string \`mapstructure:"network"\`
|
|
35
|
+
Timeout time.Duration \`mapstructure:"timeout"\`
|
|
36
|
+
WebEnabled bool \`mapstructure:"web_enabled"\`
|
|
37
|
+
WebPort int \`mapstructure:"web_port"\`
|
|
35
38
|
} \`mapstructure:"grpc"\`
|
|
36
39
|
CORS struct {
|
|
37
40
|
AllowOrigins []string \`mapstructure:"allow-origins"\`
|
|
@@ -237,7 +240,8 @@ func LoadConfig() (*Config, error) {
|
|
|
237
240
|
v.AddConfigPath(".")
|
|
238
241
|
|
|
239
242
|
// Default values
|
|
240
|
-
v.SetDefault("go-duck.server.port", 8080)
|
|
243
|
+
v.SetDefault("go-duck.server.rest.port", 8080)
|
|
244
|
+
v.SetDefault("go-duck.server.rest.protocol", "json")
|
|
241
245
|
v.SetDefault("go-duck.security.rate-limit.rps", 100.0)
|
|
242
246
|
v.SetDefault("go-duck.security.rate-limit.burst", 200)
|
|
243
247
|
v.SetDefault("go-duck.logging.datadog.enabled", false)
|
|
@@ -254,6 +258,8 @@ func LoadConfig() (*Config, error) {
|
|
|
254
258
|
v.SetDefault("go-duck.server.grpc.addr", ":9000")
|
|
255
259
|
v.SetDefault("go-duck.server.grpc.network", "tcp")
|
|
256
260
|
v.SetDefault("go-duck.server.grpc.timeout", "1s")
|
|
261
|
+
v.SetDefault("go-duck.server.grpc.web_enabled", false)
|
|
262
|
+
v.SetDefault("go-duck.server.grpc.web_port", 9090)
|
|
257
263
|
v.SetDefault("go-duck.resilience.circuit-breaker.enabled", true)
|
|
258
264
|
v.SetDefault("go-duck.resilience.circuit-breaker.failure-threshold", 5)
|
|
259
265
|
v.SetDefault("go-duck.resilience.circuit-breaker.timeout", "60s")
|
|
@@ -156,10 +156,9 @@ func ExecuteSearch(ctx context.Context, entityName string, queryStr string, cfg
|
|
|
156
156
|
} else {
|
|
157
157
|
query = map[string]interface{}{
|
|
158
158
|
"query": map[string]interface{}{
|
|
159
|
-
"
|
|
160
|
-
"query":
|
|
161
|
-
"
|
|
162
|
-
"fuzziness": "AUTO",
|
|
159
|
+
"query_string": map[string]interface{}{
|
|
160
|
+
"query": queryStr,
|
|
161
|
+
"analyze_wildcard": true,
|
|
163
162
|
},
|
|
164
163
|
},
|
|
165
164
|
}
|
package/generators/migrations.js
CHANGED
|
@@ -79,7 +79,13 @@ DROP TABLE IF EXISTS api_usage CASCADE;
|
|
|
79
79
|
|
|
80
80
|
const entitiesToCreate = delta ? delta.newEntities : entities;
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
const hasNewFields = delta && delta.newFields && Object.keys(delta.newFields).length > 0;
|
|
83
|
+
const hasNewRelationships = delta && delta.newRelationships && delta.newRelationships.length > 0;
|
|
84
|
+
const hasDeletedEntities = delta && delta.deletedEntities && delta.deletedEntities.length > 0;
|
|
85
|
+
const hasDeletedFields = delta && delta.deletedFields && Object.keys(delta.deletedFields).length > 0;
|
|
86
|
+
const hasAlteredFields = delta && delta.alteredFields && Object.keys(delta.alteredFields).length > 0;
|
|
87
|
+
|
|
88
|
+
if (entitiesToCreate.length === 0 && !hasNewFields && !hasNewRelationships && !hasDeletedEntities && !hasDeletedFields && !hasAlteredFields) {
|
|
83
89
|
return;
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -125,6 +131,70 @@ DROP TABLE IF EXISTS api_usage CASCADE;
|
|
|
125
131
|
}
|
|
126
132
|
}
|
|
127
133
|
|
|
134
|
+
// Delete Entities
|
|
135
|
+
if (delta && delta.deletedEntities) {
|
|
136
|
+
for (const entity of delta.deletedEntities) {
|
|
137
|
+
sqlUp += `DROP TABLE IF EXISTS ${entity.name.toLowerCase()} CASCADE;\n\n`;
|
|
138
|
+
sqlDown += `-- Cannot easily reverse DROP TABLE ${entity.name.toLowerCase()}\n`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Delete Fields
|
|
143
|
+
if (delta && delta.deletedFields) {
|
|
144
|
+
for (const [entityName, fields] of Object.entries(delta.deletedFields)) {
|
|
145
|
+
for (const field of fields) {
|
|
146
|
+
sqlUp += `ALTER TABLE ${entityName.toLowerCase()} DROP COLUMN IF EXISTS ${toSnakeCase(field.name)};\n`;
|
|
147
|
+
|
|
148
|
+
let sqlType = toLiquibaseType(field, enums);
|
|
149
|
+
if (sqlType === 'JSON') sqlType = 'JSONB';
|
|
150
|
+
sqlDown += `ALTER TABLE ${entityName.toLowerCase()} ADD COLUMN IF NOT EXISTS ${toSnakeCase(field.name)} ${sqlType};\n`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Alter Fields
|
|
156
|
+
if (delta && delta.alteredFields) {
|
|
157
|
+
for (const [entityName, alterations] of Object.entries(delta.alteredFields)) {
|
|
158
|
+
for (const alt of alterations) {
|
|
159
|
+
const fieldName = toSnakeCase(alt.new.name);
|
|
160
|
+
|
|
161
|
+
// Type changes
|
|
162
|
+
if (alt.new.type !== alt.old.type) {
|
|
163
|
+
let newSqlType = toLiquibaseType(alt.new, enums);
|
|
164
|
+
if (newSqlType === 'JSON') newSqlType = 'JSONB';
|
|
165
|
+
let oldSqlType = toLiquibaseType(alt.old, enums);
|
|
166
|
+
if (oldSqlType === 'JSON') oldSqlType = 'JSONB';
|
|
167
|
+
|
|
168
|
+
sqlUp += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} TYPE ${newSqlType} USING ${fieldName}::${newSqlType};\n`;
|
|
169
|
+
sqlDown += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} TYPE ${oldSqlType} USING ${fieldName}::${oldSqlType};\n`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Required (NOT NULL) changes
|
|
173
|
+
if (!!alt.new.required !== !!alt.old.required) {
|
|
174
|
+
if (alt.new.required) {
|
|
175
|
+
sqlUp += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} SET NOT NULL;\n`;
|
|
176
|
+
sqlDown += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} DROP NOT NULL;\n`;
|
|
177
|
+
} else {
|
|
178
|
+
sqlUp += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} DROP NOT NULL;\n`;
|
|
179
|
+
sqlDown += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} SET NOT NULL;\n`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Unique changes
|
|
184
|
+
if (!!alt.new.unique !== !!alt.old.unique) {
|
|
185
|
+
const constraintName = `${entityName.toLowerCase()}_${fieldName}_key`;
|
|
186
|
+
if (alt.new.unique) {
|
|
187
|
+
sqlUp += `ALTER TABLE ${entityName.toLowerCase()} ADD CONSTRAINT ${constraintName} UNIQUE (${fieldName});\n`;
|
|
188
|
+
sqlDown += `ALTER TABLE ${entityName.toLowerCase()} DROP CONSTRAINT IF EXISTS ${constraintName};\n`;
|
|
189
|
+
} else {
|
|
190
|
+
sqlUp += `ALTER TABLE ${entityName.toLowerCase()} DROP CONSTRAINT IF EXISTS ${constraintName};\n`;
|
|
191
|
+
sqlDown += `ALTER TABLE ${entityName.toLowerCase()} ADD CONSTRAINT ${constraintName} UNIQUE (${fieldName});\n`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
128
198
|
sqlUp += '-- +goose StatementEnd\n\n';
|
|
129
199
|
sqlDown += '-- +goose StatementEnd\n';
|
|
130
200
|
|
|
@@ -133,7 +203,10 @@ DROP TABLE IF EXISTS api_usage CASCADE;
|
|
|
133
203
|
descParts.push('initial_schema');
|
|
134
204
|
} else {
|
|
135
205
|
if (delta.newEntities?.length > 0) descParts.push('create_' + delta.newEntities.map(e => e.name.toLowerCase()).join('_'));
|
|
136
|
-
if (
|
|
206
|
+
if (hasNewFields) descParts.push('add_fields');
|
|
207
|
+
if (hasDeletedEntities) descParts.push('drop_tables');
|
|
208
|
+
if (hasDeletedFields) descParts.push('drop_fields');
|
|
209
|
+
if (hasAlteredFields) descParts.push('alter_fields');
|
|
137
210
|
}
|
|
138
211
|
|
|
139
212
|
const fileName = `${timestamp}_${descParts.join('_')}.sql`;
|
package/generators/outbox.js
CHANGED
package/generators/postman.js
CHANGED
|
@@ -169,11 +169,25 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
169
169
|
});
|
|
170
170
|
|
|
171
171
|
// 3. GraphQL
|
|
172
|
-
|
|
172
|
+
const generateGqlFieldsInput = (fields) => {
|
|
173
|
+
return fields.map(f => {
|
|
174
|
+
if (f.type === 'String' || f.type === 'Text') return `${f.name}: "Sample ${f.name}"`;
|
|
175
|
+
if (f.type === 'Integer' || f.type === 'Long') return `${f.name}: 100`;
|
|
176
|
+
if (f.type === 'Float' || f.type === 'BigDecimal') return `${f.name}: 99.99`;
|
|
177
|
+
if (f.type === 'Boolean') return `${f.name}: true`;
|
|
178
|
+
if (f.type === 'LocalDate') return `${f.name}: "2024-01-01"`;
|
|
179
|
+
if (f.type === 'Instant') return `${f.name}: "2024-01-01T12:00:00Z"`;
|
|
180
|
+
if (f.type === 'JSON' || f.type === 'JSONB') return `${f.name}: "{\\"attribute\\": \\"example_value\\"}"`;
|
|
181
|
+
return `${f.name}: "test"`;
|
|
182
|
+
}).join(',\n ');
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const graphqlFolder = {
|
|
173
186
|
name: "3. GraphQL Federation Layer",
|
|
187
|
+
description: "Federated GraphQL operations for all GDL entities.",
|
|
174
188
|
item: [
|
|
175
189
|
{
|
|
176
|
-
name: "
|
|
190
|
+
name: "GraphQL Endpoint Playground",
|
|
177
191
|
request: {
|
|
178
192
|
method: "POST",
|
|
179
193
|
header: [
|
|
@@ -184,7 +198,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
184
198
|
body: {
|
|
185
199
|
mode: "graphql",
|
|
186
200
|
graphql: {
|
|
187
|
-
query:
|
|
201
|
+
query: "query {\n __schema {\n types {\n name\n }\n }\n}",
|
|
188
202
|
variables: ""
|
|
189
203
|
}
|
|
190
204
|
},
|
|
@@ -198,7 +212,143 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
198
212
|
}
|
|
199
213
|
}
|
|
200
214
|
]
|
|
201
|
-
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
for (const entity of entities) {
|
|
218
|
+
const capitalized = entity.name.charAt(0).toUpperCase() + entity.name.slice(1);
|
|
219
|
+
const entityGqlFolder = {
|
|
220
|
+
name: `${capitalized} GraphQL`,
|
|
221
|
+
item: [
|
|
222
|
+
{
|
|
223
|
+
name: `List ${capitalized}s (GraphQL Query)`,
|
|
224
|
+
request: {
|
|
225
|
+
method: "POST",
|
|
226
|
+
header: [
|
|
227
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
228
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" },
|
|
229
|
+
{ key: "Content-Type", value: "application/json" }
|
|
230
|
+
],
|
|
231
|
+
body: {
|
|
232
|
+
mode: "graphql",
|
|
233
|
+
graphql: {
|
|
234
|
+
query: `query {\n list${capitalized}s(page: 1, size: 10) {\n role\n items {\n id\n ${entity.fields.map(f => f.name).join('\n ')}\n createdAt\n updatedAt\n }\n }\n}`,
|
|
235
|
+
variables: ""
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
url: {
|
|
239
|
+
raw: "http://{{host}}:{{port}}/graphql",
|
|
240
|
+
protocol: "http",
|
|
241
|
+
host: ["{{host}}"],
|
|
242
|
+
port: "{{port}}",
|
|
243
|
+
path: ["graphql"]
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: `Get ${capitalized} by ID (GraphQL Query)`,
|
|
249
|
+
request: {
|
|
250
|
+
method: "POST",
|
|
251
|
+
header: [
|
|
252
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
253
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" },
|
|
254
|
+
{ key: "Content-Type", value: "application/json" }
|
|
255
|
+
],
|
|
256
|
+
body: {
|
|
257
|
+
mode: "graphql",
|
|
258
|
+
graphql: {
|
|
259
|
+
query: `query {\n get${capitalized}(id: "1") {\n role\n data {\n id\n ${entity.fields.map(f => f.name).join('\n ')}\n createdAt\n updatedAt\n }\n }\n}`,
|
|
260
|
+
variables: ""
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
url: {
|
|
264
|
+
raw: "http://{{host}}:{{port}}/graphql",
|
|
265
|
+
protocol: "http",
|
|
266
|
+
host: ["{{host}}"],
|
|
267
|
+
port: "{{port}}",
|
|
268
|
+
path: ["graphql"]
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: `Create ${capitalized} (GraphQL Mutation)`,
|
|
274
|
+
request: {
|
|
275
|
+
method: "POST",
|
|
276
|
+
header: [
|
|
277
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
278
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" },
|
|
279
|
+
{ key: "Content-Type", value: "application/json" }
|
|
280
|
+
],
|
|
281
|
+
body: {
|
|
282
|
+
mode: "graphql",
|
|
283
|
+
graphql: {
|
|
284
|
+
query: `mutation {\n create${capitalized}(input: {\n ${generateGqlFieldsInput(entity.fields)}\n }) {\n role\n data {\n id\n ${entity.fields.map(f => f.name).join('\n ')}\n createdAt\n updatedAt\n }\n }\n}`,
|
|
285
|
+
variables: ""
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
url: {
|
|
289
|
+
raw: "http://{{host}}:{{port}}/graphql",
|
|
290
|
+
protocol: "http",
|
|
291
|
+
host: ["{{host}}"],
|
|
292
|
+
port: "{{port}}",
|
|
293
|
+
path: ["graphql"]
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: `Update ${capitalized} (GraphQL Mutation)`,
|
|
299
|
+
request: {
|
|
300
|
+
method: "POST",
|
|
301
|
+
header: [
|
|
302
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
303
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" },
|
|
304
|
+
{ key: "Content-Type", value: "application/json" }
|
|
305
|
+
],
|
|
306
|
+
body: {
|
|
307
|
+
mode: "graphql",
|
|
308
|
+
graphql: {
|
|
309
|
+
query: `mutation {\n update${capitalized}(id: "1", input: {\n ${generateGqlFieldsInput(entity.fields)}\n }) {\n role\n data {\n id\n ${entity.fields.map(f => f.name).join('\n ')}\n createdAt\n updatedAt\n }\n }\n}`,
|
|
310
|
+
variables: ""
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
url: {
|
|
314
|
+
raw: "http://{{host}}:{{port}}/graphql",
|
|
315
|
+
protocol: "http",
|
|
316
|
+
host: ["{{host}}"],
|
|
317
|
+
port: "{{port}}",
|
|
318
|
+
path: ["graphql"]
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: `Delete ${capitalized} (GraphQL Mutation)`,
|
|
324
|
+
request: {
|
|
325
|
+
method: "POST",
|
|
326
|
+
header: [
|
|
327
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
328
|
+
{ key: "X-Tenant-ID", value: "{{tenant}}" },
|
|
329
|
+
{ key: "Content-Type", value: "application/json" }
|
|
330
|
+
],
|
|
331
|
+
body: {
|
|
332
|
+
mode: "graphql",
|
|
333
|
+
graphql: {
|
|
334
|
+
query: `mutation {\n delete${capitalized}(id: "1")\n}`,
|
|
335
|
+
variables: ""
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
url: {
|
|
339
|
+
raw: "http://{{host}}:{{port}}/graphql",
|
|
340
|
+
protocol: "http",
|
|
341
|
+
host: ["{{host}}"],
|
|
342
|
+
port: "{{port}}",
|
|
343
|
+
path: ["graphql"]
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
]
|
|
348
|
+
};
|
|
349
|
+
graphqlFolder.item.push(entityGqlFolder);
|
|
350
|
+
}
|
|
351
|
+
collection.item.push(graphqlFolder);
|
|
202
352
|
|
|
203
353
|
// 4. REST Entities
|
|
204
354
|
const restFolder = { name: "4. Standard REST & Deep Search", item: [] };
|
|
@@ -240,7 +390,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
240
390
|
request: {
|
|
241
391
|
method: "GET",
|
|
242
392
|
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" } ] }
|
|
393
|
+
url: { raw: `http://{{host}}:{{port}}/open/api/${name}s?page=1&size=10&eager=true&sort=id,asc`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", "api", `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" }, { key: "sort", value: "id,asc" } ] }
|
|
244
394
|
}
|
|
245
395
|
});
|
|
246
396
|
publicItems.push({
|
|
@@ -275,7 +425,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
275
425
|
request: {
|
|
276
426
|
method: "GET",
|
|
277
427
|
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" } ] }
|
|
428
|
+
url: { raw: `http://{{host}}:{{port}}/api/${name}s?page=1&size=10&eager=true&sort=id,asc`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["api", `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" }, { key: "sort", value: "id,asc" } ] }
|
|
279
429
|
}
|
|
280
430
|
},
|
|
281
431
|
{
|
package/index.js
CHANGED
|
@@ -357,7 +357,7 @@ const getPreviousEntities = async (outputDir) => {
|
|
|
357
357
|
return entities;
|
|
358
358
|
};
|
|
359
359
|
|
|
360
|
-
const generateEntities = async (gdlPath, outputDir, config) => {
|
|
360
|
+
const generateEntities = async (gdlPath, outputDir, config, isImport = false) => {
|
|
361
361
|
let entities = [];
|
|
362
362
|
let relationships = [];
|
|
363
363
|
let enums = [];
|
|
@@ -365,7 +365,7 @@ const generateEntities = async (gdlPath, outputDir, config) => {
|
|
|
365
365
|
|
|
366
366
|
if (!await fs.pathExists(gdlPath)) {
|
|
367
367
|
console.log(chalk.yellow(`β οΈ GDL path not found: ${gdlPath} - Executing Zero-Entity Infrastructure Scaffold.`));
|
|
368
|
-
const delta = { newEntities: [], newFields: {}, newRelationships: [] };
|
|
368
|
+
const delta = { newEntities: [], newFields: {}, newRelationships: [], deletedEntities: [], deletedFields: {}, alteredFields: {} };
|
|
369
369
|
await generateLiquibaseChangelogs(entities, relationships, outputDir, delta, enums);
|
|
370
370
|
return { entities, relationships, enums, openEntities };
|
|
371
371
|
}
|
|
@@ -377,7 +377,7 @@ const generateEntities = async (gdlPath, outputDir, config) => {
|
|
|
377
377
|
|
|
378
378
|
if (files.length === 0) {
|
|
379
379
|
console.log(chalk.yellow(`β οΈ No .gdl files found in: ${gdlPath} - Executing Zero-Entity Infrastructure Scaffold.`));
|
|
380
|
-
const delta = { newEntities: [], newFields: {}, newRelationships: [] };
|
|
380
|
+
const delta = { newEntities: [], newFields: {}, newRelationships: [], deletedEntities: [], deletedFields: {}, alteredFields: {} };
|
|
381
381
|
await generateLiquibaseChangelogs(entities, relationships, outputDir, delta, enums);
|
|
382
382
|
return { entities, relationships, enums, openEntities };
|
|
383
383
|
}
|
|
@@ -399,10 +399,54 @@ const generateEntities = async (gdlPath, outputDir, config) => {
|
|
|
399
399
|
console.log(chalk.green(`β
Parsed ${entities.length} entities, ${relationships.length} relationships, ${enums.length} enums, and detected ${openEntities.length} open rules`));
|
|
400
400
|
|
|
401
401
|
const previousEntities = await getPreviousEntities(outputDir);
|
|
402
|
+
|
|
403
|
+
// Any parsed entity with @Delete is mapped as a deletion
|
|
404
|
+
const deletedParsed = entities.filter(e => e.isDelete);
|
|
405
|
+
|
|
406
|
+
// Remove deleted entities from active entities array
|
|
407
|
+
entities = entities.filter(e => !e.isDelete);
|
|
408
|
+
|
|
409
|
+
// Filter out relationships & open rule entries that involve deleted entities
|
|
410
|
+
relationships = relationships.filter(rel =>
|
|
411
|
+
!deletedParsed.some(d => d.name === rel.from.entity || d.name === rel.to.entity)
|
|
412
|
+
);
|
|
413
|
+
openEntities = openEntities.filter(oe =>
|
|
414
|
+
!deletedParsed.some(d => d.name === oe.name)
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
if (isImport) {
|
|
418
|
+
for (const prev of previousEntities) {
|
|
419
|
+
if (!entities.some(e => e.name === prev.name) && !deletedParsed.some(d => d.name === prev.name)) {
|
|
420
|
+
entities.push(prev);
|
|
421
|
+
if (prev.relationships) {
|
|
422
|
+
for (const r of prev.relationships) {
|
|
423
|
+
const exists = relationships.some(rel =>
|
|
424
|
+
(rel.from.entity === r.from.entity && rel.from.field === r.from.field && rel.to.entity === r.to.entity && rel.to.field === r.to.field) ||
|
|
425
|
+
(rel.from.entity === r.to.entity && rel.from.field === r.to.field && rel.to.entity === r.from.entity && rel.to.field === r.from.field)
|
|
426
|
+
);
|
|
427
|
+
if (!exists) relationships.push(r);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (prev.enums) {
|
|
431
|
+
for (const en of prev.enums) {
|
|
432
|
+
if (!enums.some(e => e.name === en.name)) enums.push(en);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (prev.openEntities) {
|
|
436
|
+
for (const oe of prev.openEntities) {
|
|
437
|
+
if (!openEntities.some(o => o.name === oe.name)) openEntities.push(oe);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
402
443
|
const delta = {
|
|
403
444
|
newEntities: [],
|
|
404
445
|
newFields: {},
|
|
405
|
-
newRelationships: []
|
|
446
|
+
newRelationships: [],
|
|
447
|
+
deletedEntities: [],
|
|
448
|
+
deletedFields: {},
|
|
449
|
+
alteredFields: {}
|
|
406
450
|
};
|
|
407
451
|
|
|
408
452
|
// Calculate Delta for Incremental Migrations
|
|
@@ -416,6 +460,42 @@ const generateEntities = async (gdlPath, outputDir, config) => {
|
|
|
416
460
|
if (newFields.length > 0) {
|
|
417
461
|
delta.newFields[entity.name] = newFields;
|
|
418
462
|
}
|
|
463
|
+
|
|
464
|
+
// Check for deleted fields
|
|
465
|
+
const deletedFields = prev.fields.filter(pf => !entity.fields.some(f => f.name === pf.name));
|
|
466
|
+
if (deletedFields.length > 0) {
|
|
467
|
+
delta.deletedFields[entity.name] = deletedFields;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Check for altered fields
|
|
471
|
+
const alteredFields = [];
|
|
472
|
+
for (const f of entity.fields) {
|
|
473
|
+
const pf = prev.fields.find(p => p.name === f.name);
|
|
474
|
+
if (pf) {
|
|
475
|
+
if (pf.type !== f.type || !!pf.required !== !!f.required || !!pf.unique !== !!f.unique) {
|
|
476
|
+
alteredFields.push({ new: f, old: pf });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (alteredFields.length > 0) {
|
|
481
|
+
delta.alteredFields[entity.name] = alteredFields;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Check for deleted entities
|
|
487
|
+
if (isImport) {
|
|
488
|
+
for (const d of deletedParsed) {
|
|
489
|
+
const prev = previousEntities.find(e => e.name === d.name);
|
|
490
|
+
if (prev) {
|
|
491
|
+
delta.deletedEntities.push(prev);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
for (const prev of previousEntities) {
|
|
496
|
+
if (!entities.some(e => e.name === prev.name)) {
|
|
497
|
+
delta.deletedEntities.push(prev);
|
|
498
|
+
}
|
|
419
499
|
}
|
|
420
500
|
}
|
|
421
501
|
|
|
@@ -481,6 +561,21 @@ const generateEntities = async (gdlPath, outputDir, config) => {
|
|
|
481
561
|
await saveEntitySnapshot(outputDir, entity);
|
|
482
562
|
}
|
|
483
563
|
|
|
564
|
+
// Clean up files for deleted entities
|
|
565
|
+
if (delta && delta.deletedEntities) {
|
|
566
|
+
for (const deleted of delta.deletedEntities) {
|
|
567
|
+
const modelFile = path.join(outputDir, 'models', `${deleted.name.toLowerCase()}.go`);
|
|
568
|
+
const controllerFile = path.join(outputDir, 'controllers', `${deleted.name.toLowerCase()}_controller.go`);
|
|
569
|
+
const snapshotFile = path.join(outputDir, '.go-duck', `${deleted.name.toLowerCase()}.json`);
|
|
570
|
+
|
|
571
|
+
if (await fs.pathExists(modelFile)) await fs.remove(modelFile);
|
|
572
|
+
if (await fs.pathExists(controllerFile)) await fs.remove(controllerFile);
|
|
573
|
+
if (await fs.pathExists(snapshotFile)) await fs.remove(snapshotFile);
|
|
574
|
+
|
|
575
|
+
console.log(chalk.red(` - Removed Entity & Controller: ${deleted.name}`));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
484
579
|
// Generate Incremental Changelogs!
|
|
485
580
|
await generateLiquibaseChangelogs(entities, relationships, outputDir, delta, enums);
|
|
486
581
|
console.log(chalk.green('β
Goose SQL incremental migrations updated!'));
|
|
@@ -717,7 +812,7 @@ require (
|
|
|
717
812
|
await generateResilienceCode(config, absoluteOutputDir);
|
|
718
813
|
await generateTelemetryCode(config, absoluteOutputDir);
|
|
719
814
|
await generateDeploymentArtifacts(config, absoluteOutputDir);
|
|
720
|
-
const { entities, relationships, enums, openEntities } = await generateEntities(gdlPath, absoluteOutputDir, config);
|
|
815
|
+
const { entities, relationships, enums, openEntities } = await generateEntities(gdlPath, absoluteOutputDir, config, true);
|
|
721
816
|
await generateKratosCode(entities, absoluteOutputDir, config.name, enums);
|
|
722
817
|
|
|
723
818
|
await generateRepositoryCode(absoluteOutputDir);
|
|
@@ -783,13 +878,18 @@ const generateYAMLConfigs = async (config, outputDir) => {
|
|
|
783
878
|
const extendedConfig = {
|
|
784
879
|
...cleanConfig,
|
|
785
880
|
server: {
|
|
786
|
-
|
|
881
|
+
rest: {
|
|
882
|
+
port: cleanConfig.server?.rest?.port || cleanConfig.server?.port || 8080,
|
|
883
|
+
protocol: cleanConfig.server?.rest?.protocol || 'json'
|
|
884
|
+
},
|
|
787
885
|
'read-timeout': cleanConfig.server?.['read-timeout'] || '30s',
|
|
788
886
|
'write-timeout': cleanConfig.server?.['write-timeout'] || '30s',
|
|
789
887
|
grpc: {
|
|
790
888
|
addr: cleanConfig.server?.grpc?.addr || ':9000',
|
|
791
889
|
network: cleanConfig.server?.grpc?.network || 'tcp',
|
|
792
|
-
timeout: cleanConfig.server?.grpc?.timeout || '1s'
|
|
890
|
+
timeout: cleanConfig.server?.grpc?.timeout || '1s',
|
|
891
|
+
web_enabled: cleanConfig.server?.grpc?.web_enabled ?? true,
|
|
892
|
+
web_port: cleanConfig.server?.grpc?.web_port || 9090
|
|
793
893
|
},
|
|
794
894
|
cors: {
|
|
795
895
|
'allow-origins': cleanConfig.server?.cors?.['allow-origins'] || ['*'],
|
package/package.json
CHANGED
package/parser/gdl.js
CHANGED
|
@@ -179,6 +179,7 @@ export const parseGDL = async (filePath) => {
|
|
|
179
179
|
const isSearchable = annotation?.includes('@Searchable');
|
|
180
180
|
const isDocument = annotation?.includes('@Document') || annotation?.includes('@isDocument');
|
|
181
181
|
const isEmbedded = annotation?.includes('@Embed');
|
|
182
|
+
const isDelete = annotation?.includes('@Delete');
|
|
182
183
|
|
|
183
184
|
const fields = parseFields(block.fieldBlock);
|
|
184
185
|
|
|
@@ -190,6 +191,7 @@ export const parseGDL = async (filePath) => {
|
|
|
190
191
|
isSearchable,
|
|
191
192
|
isDocument,
|
|
192
193
|
isEmbedded,
|
|
194
|
+
isDelete,
|
|
193
195
|
fields
|
|
194
196
|
});
|
|
195
197
|
}
|
|
@@ -69,7 +69,8 @@ go run main.go</code></pre>
|
|
|
69
69
|
<h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">1. REST APIs & Generic Search</h2>
|
|
70
70
|
<p class="mb-4">The application provides standard RESTful CRUD endpoints for all generated entities (e.g., {{#if entities.length}}{{entities.[0].name}}{{else}}Entity{{/if}}).</p>
|
|
71
71
|
|
|
72
|
-
<h3 class="font-semibold mb-2">Standard CRUD:</h3>
|
|
72
|
+
<h3 class="font-semibold mb-2">Standard CRUD (Multi-Protocol):</h3>
|
|
73
|
+
<p class="mb-2 text-sm text-gray-600">The REST API natively supports JSON and MessagePack based on your <code>application.yml</code> settings.</p>
|
|
73
74
|
<pre><code class="language-http">GET /api/{{#if entities.length}}{{toLowerCase entities.[0].name}}s{{else}}entities{{/if}}?page=1&pageSize=10
|
|
74
75
|
POST /api/{{#if entities.length}}{{toLowerCase entities.[0].name}}s{{else}}entities{{/if}}
|
|
75
76
|
PUT /api/{{#if entities.length}}{{toLowerCase entities.[0].name}}s{{else}}entities{{/if}}/:id
|
|
@@ -81,6 +82,17 @@ DELETE /api/{{#if entities.length}}{{toLowerCase entities.[0].name}}s{{else}}ent
|
|
|
81
82
|
-H "Authorization: Bearer YOUR_JWT" \
|
|
82
83
|
-H "X-Tenant-ID: tenant_1"</code></pre>
|
|
83
84
|
<p class="text-sm text-gray-500 mt-2">Supported operators: <code>eq, neq, gt, gte, lt, lte, like, ilike</code></p>
|
|
85
|
+
|
|
86
|
+
<h3 class="font-semibold mb-2 mt-6">Elasticsearch Global Search (Spring-style):</h3>
|
|
87
|
+
<p class="mb-2">For entities marked with <code>@Searchable</code>, use the native Elasticsearch endpoint for advanced queries (wildcards, booleans, and ranges).</p>
|
|
88
|
+
<pre><code class="language-bash">curl -G "http://localhost:{{serverPort}}/api/search/{{#if entities.length}}{{toLowerCase entities.[0].name}}{{else}}entity{{/if}}" \
|
|
89
|
+
--data-urlencode "q=name:John AND age:>18" \
|
|
90
|
+
-H "Authorization: Bearer YOUR_JWT"</code></pre>
|
|
91
|
+
<ul class="text-sm text-gray-500 mt-2 list-disc pl-5">
|
|
92
|
+
<li><strong>Wildcards:</strong> <code>q=name*</code> (matches "name15", "name_abc")</li>
|
|
93
|
+
<li><strong>Boolean Logic:</strong> <code>q=status:PUBLISHED AND (author:John OR author:Jane)</code></li>
|
|
94
|
+
<li><strong>Ranges:</strong> <code>q=age:[18 TO 30]</code> or <code>q=created_at:>2023-01-01</code></li>
|
|
95
|
+
</ul>
|
|
84
96
|
</section>
|
|
85
97
|
|
|
86
98
|
<!-- Audit & Metering -->
|
|
@@ -127,6 +139,17 @@ mosquitto_sub -h localhost -p {{mqttPort}} -t "go-duck/events/#" -u dev_user -P
|
|
|
127
139
|
<p class="text-sm mt-2">Example topic: <code>go-duck/events/{{#if entities.length}}{{toLowerCase entities.[0].name}}{{else}}entity{{/if}}/CREATE</code></p>
|
|
128
140
|
</section>
|
|
129
141
|
|
|
142
|
+
<!-- gRPC-Web -->
|
|
143
|
+
<section id="grpc-web" class="content-section">
|
|
144
|
+
<h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">gRPC & gRPC-Web</h2>
|
|
145
|
+
<p class="mb-4">The Kratos gRPC engine powers blazing fast service-to-service communication. For frontend developers, we automatically start a <strong>gRPC-Web Proxy</strong>.</p>
|
|
146
|
+
<ul class="list-disc pl-6 space-y-2 mb-4">
|
|
147
|
+
<li>The core Kratos gRPC server runs on <code>:9000</code> (configurable).</li>
|
|
148
|
+
<li>The <strong>gRPC-Web Proxy</strong> runs on <code>:9090</code>, translating HTTP/1.1 calls to HTTP/2.</li>
|
|
149
|
+
<li>Frontend apps (React/Angular) can directly call Protobuf endpoints using standard <code>grpc-web</code> generated clients without hitting the REST APIs!</li>
|
|
150
|
+
</ul>
|
|
151
|
+
</section>
|
|
152
|
+
|
|
130
153
|
<!-- Kratos gRPC -->
|
|
131
154
|
<section id="grpc-kratos" class="content-section">
|
|
132
155
|
<h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">Kratos Secured gRPC APIs</h2>
|
|
@@ -212,7 +235,8 @@ res, err := client.Get{{#if entities.length}}{{capitalize entities.[0].name}}{{e
|
|
|
212
235
|
<p class="mb-4">Running <code>go-duck import-gdl</code> calculates stateful differences using the `.go-duck/` snapshot folder. It generates atomic, Go-embedded timestamped Goose SQL migrations in <code>migrations/sql/</code>.</p>
|
|
213
236
|
<ul class="list-disc pl-6 space-y-2">
|
|
214
237
|
<li>The application natively compiles SQL inside the Go binary via <code>go:embed</code>.</li>
|
|
215
|
-
<li>
|
|
238
|
+
<li><strong>Full-Spectrum Schema Evolution:</strong> The CLI detects complex deltas, generating SQL for dropped entities (`DROP TABLE`), dropped fields (`DROP COLUMN`), and altered fields (`ALTER TYPE`, constraints).</li>
|
|
239
|
+
<li><strong>Dead-Code Purging:</strong> The CLI natively tracks and deletes orphaned `.go` models, controllers, and state snapshots for entities you remove from the `.gdl` blueprint.</li>
|
|
216
240
|
<li><strong>Needle Support:</strong> We inject `// go-duck-needle-*` markers in <code>main.go</code> and <code>grpc.go</code> for safe evolutionary code additions without destroying manual updates.</li>
|
|
217
241
|
</ul>
|
|
218
242
|
</section>
|
|
@@ -44,17 +44,33 @@ go-duck import-gdl my_new_schema.gdl -o ./MY_APP</code></pre>
|
|
|
44
44
|
<span class="w-8 h-8 rounded-lg bg-emerald-100 text-emerald-600 flex items-center justify-center mr-3 text-sm">
|
|
45
45
|
<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="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"></path></svg>
|
|
46
46
|
</span>
|
|
47
|
-
2.
|
|
47
|
+
2. Schema Evolution & the `.go-duck/` State
|
|
48
48
|
</h2>
|
|
49
|
-
<p class="mb-4 text-slate-600 leading-relaxed font-medium">"If I run the generator
|
|
49
|
+
<p class="mb-4 text-slate-600 leading-relaxed font-medium">"If I run the generator to import a new GDL, will it overwrite my database or delete existing entities?" <strong>No, thanks to stateful evolution.</strong></p>
|
|
50
50
|
|
|
51
|
-
<p class="mb-6 text-slate-600 leading-relaxed">The generator maintains a stateful snapshot of every entity it has ever generated inside the hidden <code class="bg-slate-100 px-1 py-0.5 rounded text-slate-800 font-mono text-sm">.go-duck/</code> directory at the root of your target project. When you run <code>import-gdl</code>, the
|
|
51
|
+
<p class="mb-6 text-slate-600 leading-relaxed">The generator maintains a stateful snapshot of every entity it has ever generated inside the hidden <code class="bg-slate-100 px-1 py-0.5 rounded text-slate-800 font-mono text-sm">.go-duck/</code> directory at the root of your target project. When you run <code>import-gdl</code>, the CLI intelligently merges existing entities with newly parsed entities and executes targeted diff operations:</p>
|
|
52
|
+
|
|
53
|
+
<!-- EVOLUTION TYPE CARDS -->
|
|
54
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
55
|
+
<div class="p-6 bg-slate-50 border border-slate-200 rounded-2xl">
|
|
56
|
+
<span class="text-xs font-bold text-indigo-600 uppercase tracking-wide block mb-2 font-mono">1. Snapshot Merging</span>
|
|
57
|
+
<p class="text-xs text-slate-600 leading-relaxed m-0">Supports splitting your entities into multiple GDL files. Unspecified active models are loaded from the <code>.go-duck/</code> folder and merged with new entities, keeping active routers and endpoints in sync.</p>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="p-6 bg-slate-50 border border-slate-200 rounded-2xl">
|
|
60
|
+
<span class="text-xs font-bold text-emerald-600 uppercase tracking-wide block mb-2 font-mono">2. Column Alterations</span>
|
|
61
|
+
<p class="text-xs text-slate-600 leading-relaxed m-0">Adding, dropping, or modifying fields inside entity blocks generates specific <code>ADD COLUMN</code> or <code>DROP COLUMN</code> SQL statements in a timestamped Goose migration file.</p>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="p-6 bg-slate-50 border border-slate-200 rounded-2xl">
|
|
64
|
+
<span class="text-xs font-bold text-rose-600 uppercase tracking-wide block mb-2 font-mono">3. Complete Purging</span>
|
|
65
|
+
<p class="text-xs text-slate-600 leading-relaxed m-0">Marking an entity with the <code>@Delete</code> annotation automatically triggers a database <code>DROP TABLE</code> SQL migration, purges all generated Go/Protobuf code files, and clears its snapshot.</p>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
52
68
|
|
|
53
69
|
<div class="bg-rose-50 border border-rose-200 p-5 mb-6 rounded-xl flex items-start shadow-sm shadow-rose-100/50">
|
|
54
70
|
<svg class="w-6 h-6 text-rose-500 mt-0.5 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
|
55
71
|
<div>
|
|
56
72
|
<h4 class="font-bold text-rose-900 mb-1">Warning</h4>
|
|
57
|
-
<p class="text-rose-800 text-sm leading-relaxed"><strong>Never delete `.go-duck/`</strong> unless you are intentionally wiping the database and starting configuration completely from
|
|
73
|
+
<p class="text-rose-800 text-sm leading-relaxed"><strong>Never manually delete `.go-duck/`</strong> unless you are intentionally wiping the entire database state and starting configuration completely from scratch.</p>
|
|
58
74
|
</div>
|
|
59
75
|
</div>
|
|
60
76
|
</section>
|
|
@@ -100,6 +100,23 @@
|
|
|
100
100
|
<p class="text-[11px] text-indigo-900 m-0 font-medium font-mono leading-tight">Architectural Impact: Scaffolds routes into the <code class="text-indigo-600 text-[10px]">/api/open/*</code> group, bypassing the OIDC Validator middleware chain.</p>
|
|
101
101
|
</div>
|
|
102
102
|
</div>
|
|
103
|
+
|
|
104
|
+
<!-- @Delete -->
|
|
105
|
+
<div class="p-10 bg-rose-50 border border-rose-100 rounded-[3rem] shadow-sm transition-all duration-300 hover:shadow-rose-200 group relative overflow-hidden">
|
|
106
|
+
<div class="absolute top-0 right-0 p-6 opacity-[0.03] group-hover:opacity-[0.07] transition-opacity">
|
|
107
|
+
<svg class="w-32 h-32 text-rose-900" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="flex items-center justify-between mb-8">
|
|
110
|
+
<code class="text-rose-700 font-mono font-black text-2xl group-hover:scale-105 transition-transform">@Delete</code>
|
|
111
|
+
<div class="w-12 h-12 bg-white rounded-2xl flex items-center justify-center text-2xl shadow-sm">ποΈ</div>
|
|
112
|
+
</div>
|
|
113
|
+
<h4 class="text-lg font-black text-slate-900 mb-4 m-0 uppercase tracking-tighter">Entity Deletion</h4>
|
|
114
|
+
<p class="text-sm text-slate-700 leading-relaxed m-0 italic mb-6">Triggers full entity cleanup. Upon import, the generator purges all generated source code (models, controllers, repositories) and generates a database <code>DROP TABLE</code> migration.</p>
|
|
115
|
+
<div class="p-4 bg-white/50 rounded-2xl border border-rose-200 border-dashed">
|
|
116
|
+
<span class="text-[10px] font-bold text-rose-400 uppercase tracking-widest block mb-2 font-mono">Architectural Impact</span>
|
|
117
|
+
<p class="text-[11px] text-rose-900 m-0 font-medium font-mono leading-tight">Removes snapshots from .go-duck/, wipes Go files, and scaffolds drop SQL statements.</p>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
103
120
|
</div>
|
|
104
121
|
|
|
105
122
|
<!-- COMPLEX EXAMPLE -->
|
|
@@ -129,6 +129,27 @@
|
|
|
129
129
|
</div>
|
|
130
130
|
</section>
|
|
131
131
|
|
|
132
|
+
<!-- PAGINATION & SORTING -->
|
|
133
|
+
<section class="mb-20">
|
|
134
|
+
<h2 class="text-3xl font-black text-slate-900 mb-8 tracking-tight italic underline decoration-indigo-600 underline-offset-8">Pagination & Dynamic Sorting</h2>
|
|
135
|
+
<div class="p-8 bg-slate-50 border border-slate-200 rounded-[2.5rem] shadow-sm">
|
|
136
|
+
<p class="text-slate-600 mb-6">List endpoints support pagination and dynamic sorting for both relational (PostgreSQL) and document-based (MongoDB) silos.</p>
|
|
137
|
+
|
|
138
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
139
|
+
<div class="p-6 bg-white rounded-2xl border border-slate-100 shadow-sm border-l-4 border-l-indigo-600">
|
|
140
|
+
<h4 class="font-bold text-slate-900 mb-2">Pagination</h4>
|
|
141
|
+
<p class="text-xs text-slate-500 mb-4">Specify the page number (1-indexed) and limit size via standard query parameters. The response contains total count in the <code>X-Total-Count</code> header.</p>
|
|
142
|
+
<code class="text-xs font-mono font-bold text-indigo-700 bg-indigo-50 px-3 py-1 rounded-full">GET /api/v1/car?page=1&size=20</code>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="p-6 bg-white rounded-2xl border border-slate-100 shadow-sm border-l-4 border-l-emerald-600">
|
|
145
|
+
<h4 class="font-bold text-slate-900 mb-2">Dynamic Sorting</h4>
|
|
146
|
+
<p class="text-xs text-slate-500 mb-4">Sort fields dynamically in ascending (default or <code>asc</code>) or descending (<code>desc</code>) order. The sorting format is <code>?sort=fieldname,direction</code>.</p>
|
|
147
|
+
<code class="text-xs font-mono font-bold text-emerald-700 bg-emerald-50 px-3 py-1 rounded-full">GET /api/v1/car?sort=price,desc</code>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</section>
|
|
152
|
+
|
|
132
153
|
<!-- RPC OPERATORS -->
|
|
133
154
|
<section class="mb-10 text-center">
|
|
134
155
|
<h2 class="text-2xl font-black text-slate-900 mb-6 italic italic underline decoration-indigo-200 underline-offset-8 decoration-8 font-serif uppercase tracking-widest">GORM Filter Reference</h2>
|
|
@@ -3,6 +3,7 @@ package controllers
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
5
5
|
"strconv"
|
|
6
|
+
"strings"
|
|
6
7
|
{{#if isSearchable}}
|
|
7
8
|
"context"
|
|
8
9
|
{{/if}}
|
|
@@ -22,6 +23,8 @@ import (
|
|
|
22
23
|
"{{app_name}}/internal/search"
|
|
23
24
|
{{/if}}
|
|
24
25
|
"github.com/gin-gonic/gin"
|
|
26
|
+
"github.com/gin-gonic/gin/binding"
|
|
27
|
+
"github.com/gin-gonic/gin/render"
|
|
25
28
|
{{#if isDocument}}
|
|
26
29
|
"go.mongodb.org/mongo-driver/bson"
|
|
27
30
|
"go.mongodb.org/mongo-driver/mongo"
|
|
@@ -44,9 +47,16 @@ type {{capitalize name}}Controller struct {
|
|
|
44
47
|
func (ctrl *{{capitalize name}}Controller) Create(c *gin.Context) {
|
|
45
48
|
ctx := c.Request.Context()
|
|
46
49
|
var entity models.{{capitalize name}}
|
|
47
|
-
if
|
|
48
|
-
c.
|
|
49
|
-
|
|
50
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
51
|
+
if err := c.ShouldBindWith(&entity, binding.MsgPack); err != nil {
|
|
52
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
if err := c.ShouldBindJSON(&entity); err != nil {
|
|
57
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
58
|
+
return
|
|
59
|
+
}
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
{{#if isDocument}}
|
|
@@ -138,7 +148,11 @@ func (ctrl *{{capitalize name}}Controller) Create(c *gin.Context) {
|
|
|
138
148
|
}
|
|
139
149
|
{{/if}}
|
|
140
150
|
|
|
141
|
-
|
|
151
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
152
|
+
c.Render(http.StatusCreated, render.MsgPack{Data: entity})
|
|
153
|
+
} else {
|
|
154
|
+
c.JSON(http.StatusCreated, entity)
|
|
155
|
+
}
|
|
142
156
|
}
|
|
143
157
|
|
|
144
158
|
// GetAll handles parallel read aggregation for both SQL and Mongo
|
|
@@ -146,6 +160,7 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
|
|
|
146
160
|
ctx := c.Request.Context()
|
|
147
161
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "0"))
|
|
148
162
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
|
163
|
+
sortParam := c.DefaultQuery("sort", "")
|
|
149
164
|
|
|
150
165
|
offset := 0
|
|
151
166
|
if page > 0 {
|
|
@@ -169,6 +184,15 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
|
|
|
169
184
|
defer wg.Done()
|
|
170
185
|
var entities []models.{{capitalize name}}
|
|
171
186
|
opts := options.Find().SetSkip(int64(offset)).SetLimit(int64(size))
|
|
187
|
+
if sortParam != "" {
|
|
188
|
+
parts := strings.Split(sortParam, ",")
|
|
189
|
+
field := parts[0]
|
|
190
|
+
order := 1
|
|
191
|
+
if len(parts) > 1 && strings.ToLower(parts[1]) == "desc" {
|
|
192
|
+
order = -1
|
|
193
|
+
}
|
|
194
|
+
opts.SetSort(bson.D{bson.E{Key: field, Value: order}})
|
|
195
|
+
}
|
|
172
196
|
cursor, err := db.Collection("{{toLowerCase name}}s").Find(ctx, bson.M{}, opts)
|
|
173
197
|
if err == nil {
|
|
174
198
|
cursor.All(ctx, &entities)
|
|
@@ -179,7 +203,11 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
|
|
|
179
203
|
}(role, siloDB)
|
|
180
204
|
}
|
|
181
205
|
wg.Wait()
|
|
182
|
-
|
|
206
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
207
|
+
c.Render(http.StatusOK, render.MsgPack{Data: federatedData})
|
|
208
|
+
} else {
|
|
209
|
+
c.JSON(http.StatusOK, federatedData)
|
|
210
|
+
}
|
|
183
211
|
return
|
|
184
212
|
}
|
|
185
213
|
{{/if}}
|
|
@@ -200,13 +228,26 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
|
|
|
200
228
|
|
|
201
229
|
var entities []models.{{capitalize name}}
|
|
202
230
|
opts := options.Find().SetSkip(int64(offset)).SetLimit(int64(size))
|
|
231
|
+
if sortParam != "" {
|
|
232
|
+
parts := strings.Split(sortParam, ",")
|
|
233
|
+
field := parts[0]
|
|
234
|
+
order := 1
|
|
235
|
+
if len(parts) > 1 && strings.ToLower(parts[1]) == "desc" {
|
|
236
|
+
order = -1
|
|
237
|
+
}
|
|
238
|
+
opts.SetSort(bson.D{bson.E{Key: field, Value: order}})
|
|
239
|
+
}
|
|
203
240
|
cursor, err := tenantDB.Collection("{{toLowerCase name}}s").Find(ctx, filter, opts)
|
|
204
241
|
if err != nil {
|
|
205
242
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
206
243
|
return
|
|
207
244
|
}
|
|
208
245
|
cursor.All(ctx, &entities)
|
|
209
|
-
|
|
246
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
247
|
+
c.Render(http.StatusOK, render.MsgPack{Data: entities})
|
|
248
|
+
} else {
|
|
249
|
+
c.JSON(http.StatusOK, entities)
|
|
250
|
+
}
|
|
210
251
|
|
|
211
252
|
{{else}}
|
|
212
253
|
// π¦ GORM Path
|
|
@@ -227,7 +268,11 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
|
|
|
227
268
|
go func(r string, db *gorm.DB) {
|
|
228
269
|
defer wg.Done()
|
|
229
270
|
var entities []models.{{capitalize name}}
|
|
230
|
-
|
|
271
|
+
query := db.WithContext(ctx)
|
|
272
|
+
if sortParam != "" {
|
|
273
|
+
query = query.Order(strings.ReplaceAll(sortParam, ",", " "))
|
|
274
|
+
}
|
|
275
|
+
if err := query.Offset(offset).Limit(size).Find(&entities).Error; err == nil {
|
|
231
276
|
mu.Lock()
|
|
232
277
|
federatedData[r] = entities
|
|
233
278
|
mu.Unlock()
|
|
@@ -235,7 +280,11 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
|
|
|
235
280
|
}(role, siloDB)
|
|
236
281
|
}
|
|
237
282
|
wg.Wait()
|
|
238
|
-
|
|
283
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
284
|
+
c.Render(http.StatusOK, render.MsgPack{Data: federatedData})
|
|
285
|
+
} else {
|
|
286
|
+
c.JSON(http.StatusOK, federatedData)
|
|
287
|
+
}
|
|
239
288
|
return
|
|
240
289
|
}
|
|
241
290
|
{{/if}}
|
|
@@ -248,11 +297,19 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
|
|
|
248
297
|
c.Header("X-Total-Count", strconv.FormatInt(totalCount, 10))
|
|
249
298
|
|
|
250
299
|
var entities []models.{{capitalize name}}
|
|
251
|
-
|
|
300
|
+
query := tenantDB.WithContext(ctx)
|
|
301
|
+
if sortParam != "" {
|
|
302
|
+
query = query.Order(strings.ReplaceAll(sortParam, ",", " "))
|
|
303
|
+
}
|
|
304
|
+
if err := query.Offset(offset).Limit(size).Find(&entities).Error; err != nil {
|
|
252
305
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
253
306
|
return
|
|
254
307
|
}
|
|
255
|
-
|
|
308
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
309
|
+
c.Render(http.StatusOK, render.MsgPack{Data: entities})
|
|
310
|
+
} else {
|
|
311
|
+
c.JSON(http.StatusOK, entities)
|
|
312
|
+
}
|
|
256
313
|
{{/if}}
|
|
257
314
|
}
|
|
258
315
|
|
|
@@ -269,7 +326,11 @@ func (ctrl *{{capitalize name}}Controller) GetByID(c *gin.Context) {
|
|
|
269
326
|
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
|
270
327
|
return
|
|
271
328
|
}
|
|
272
|
-
|
|
329
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
330
|
+
c.Render(http.StatusOK, render.MsgPack{Data: entity})
|
|
331
|
+
} else {
|
|
332
|
+
c.JSON(http.StatusOK, entity)
|
|
333
|
+
}
|
|
273
334
|
{{else}}
|
|
274
335
|
db, _ := c.Get("tenantDBConn")
|
|
275
336
|
tenantDB := db.(*gorm.DB)
|
|
@@ -278,7 +339,11 @@ func (ctrl *{{capitalize name}}Controller) GetByID(c *gin.Context) {
|
|
|
278
339
|
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
|
279
340
|
return
|
|
280
341
|
}
|
|
281
|
-
|
|
342
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
343
|
+
c.Render(http.StatusOK, render.MsgPack{Data: entity})
|
|
344
|
+
} else {
|
|
345
|
+
c.JSON(http.StatusOK, entity)
|
|
346
|
+
}
|
|
282
347
|
{{/if}}
|
|
283
348
|
}
|
|
284
349
|
|
|
@@ -286,9 +351,16 @@ func (ctrl *{{capitalize name}}Controller) Update(c *gin.Context) {
|
|
|
286
351
|
ctx := c.Request.Context()
|
|
287
352
|
id := c.Param("id")
|
|
288
353
|
var entity models.{{capitalize name}}
|
|
289
|
-
if
|
|
290
|
-
c.
|
|
291
|
-
|
|
354
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
355
|
+
if err := c.ShouldBindWith(&entity, binding.MsgPack); err != nil {
|
|
356
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
if err := c.ShouldBindJSON(&entity); err != nil {
|
|
361
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
362
|
+
return
|
|
363
|
+
}
|
|
292
364
|
}
|
|
293
365
|
|
|
294
366
|
{{#if isDocument}}
|
|
@@ -320,7 +392,11 @@ func (ctrl *{{capitalize name}}Controller) Update(c *gin.Context) {
|
|
|
320
392
|
messaging.PublishEvent(ctrl.Config.GoDuck.Messaging.MQTT.TopicPrefix, "tenant", "UPDATE", "{{capitalize name}}", entity, nil)
|
|
321
393
|
{{/if}}
|
|
322
394
|
|
|
323
|
-
|
|
395
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
396
|
+
c.Render(http.StatusOK, render.MsgPack{Data: entity})
|
|
397
|
+
} else {
|
|
398
|
+
c.JSON(http.StatusOK, entity)
|
|
399
|
+
}
|
|
324
400
|
}
|
|
325
401
|
|
|
326
402
|
func (ctrl *{{capitalize name}}Controller) Delete(c *gin.Context) {
|
|
@@ -356,9 +432,16 @@ func (ctrl *{{capitalize name}}Controller) Patch(c *gin.Context) {
|
|
|
356
432
|
ctx := c.Request.Context()
|
|
357
433
|
id := c.Param("id")
|
|
358
434
|
var updates map[string]interface{}
|
|
359
|
-
if
|
|
360
|
-
c.
|
|
361
|
-
|
|
435
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
436
|
+
if err := c.ShouldBindWith(&updates, binding.MsgPack); err != nil {
|
|
437
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
} else {
|
|
441
|
+
if err := c.ShouldBindJSON(&updates); err != nil {
|
|
442
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
443
|
+
return
|
|
444
|
+
}
|
|
362
445
|
}
|
|
363
446
|
|
|
364
447
|
{{#if isDocument}}
|
|
@@ -383,7 +466,11 @@ func (ctrl *{{capitalize name}}Controller) Patch(c *gin.Context) {
|
|
|
383
466
|
messaging.PublishEvent(ctrl.Config.GoDuck.Messaging.MQTT.TopicPrefix, "tenant", "PATCH", "{{capitalize name}}", updates, nil)
|
|
384
467
|
{{/if}}
|
|
385
468
|
|
|
386
|
-
|
|
469
|
+
if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
|
|
470
|
+
c.Render(http.StatusOK, render.MsgPack{Data: gin.H{"message": "Patch successful"}})
|
|
471
|
+
} else {
|
|
472
|
+
c.JSON(http.StatusOK, gin.H{"message": "Patch successful"})
|
|
473
|
+
}
|
|
387
474
|
}
|
|
388
475
|
|
|
389
476
|
func (ctrl *{{capitalize name}}Controller) BulkCreate(c *gin.Context) { c.JSON(http.StatusNotImplemented, gin.H{"error": "Bulk operations use primary silo isolation by default."}) }
|
package/templates/go/main.go.hbs
CHANGED
|
@@ -14,6 +14,8 @@ import (
|
|
|
14
14
|
"{{app_name}}/integrations/wso2"
|
|
15
15
|
"gorm.io/driver/postgres"
|
|
16
16
|
"gorm.io/gorm"
|
|
17
|
+
"net/http"
|
|
18
|
+
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
|
17
19
|
)
|
|
18
20
|
|
|
19
21
|
func main() {
|
|
@@ -40,6 +42,18 @@ func main() {
|
|
|
40
42
|
go func() {
|
|
41
43
|
grpcSrv := server.NewGRPCServer(appConfig, repo)
|
|
42
44
|
logger.Info("Starting Kratos gRPC server on %s", appConfig.GoDuck.Server.GRPC.Addr)
|
|
45
|
+
|
|
46
|
+
if appConfig.GoDuck.Server.GRPC.WebEnabled {
|
|
47
|
+
wrappedGrpc := grpcweb.WrapServer(grpcSrv.Server)
|
|
48
|
+
go func() {
|
|
49
|
+
webPort := fmt.Sprintf(":%d", appConfig.GoDuck.Server.GRPC.WebPort)
|
|
50
|
+
logger.Info("Starting gRPC-Web Proxy on %s", webPort)
|
|
51
|
+
if err := http.ListenAndServe(webPort, wrappedGrpc); err != nil {
|
|
52
|
+
logger.Error("Failed to start gRPC-Web Proxy: %v", err)
|
|
53
|
+
}
|
|
54
|
+
}()
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
if err := grpcSrv.Start(context.Background()); err != nil {
|
|
44
58
|
logger.Error("Failed to start Kratos gRPC server: %v", err)
|
|
45
59
|
}
|
|
@@ -56,7 +70,7 @@ func main() {
|
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
// 6. Start HTTP Server
|
|
59
|
-
port := fmt.Sprintf(":%d", appConfig.GoDuck.Server.Port)
|
|
60
|
-
logger.Info("Starting HTTP server on %s", port)
|
|
73
|
+
port := fmt.Sprintf(":%d", appConfig.GoDuck.Server.REST.Port)
|
|
74
|
+
logger.Info("Starting HTTP REST server on %s", port)
|
|
61
75
|
r.Run(port)
|
|
62
76
|
}
|
|
@@ -115,6 +115,21 @@ func SetupRouter(appConfig *config.Config) *gin.Engine {
|
|
|
115
115
|
log.Printf("Warning: Failed to connect to fallback database for migration: %v", err)
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
// 10c. Migrate all provisioned tenant silos
|
|
120
|
+
var tenantRoles []models.TenantRole
|
|
121
|
+
if err := masterDB.Find(&tenantRoles).Error; err == nil {
|
|
122
|
+
mgr := middleware.GetTenantManager(masterDB, appConfig)
|
|
123
|
+
for _, tenant := range tenantRoles {
|
|
124
|
+
if tenantDB, err := mgr.GetDB(tenant.DBName); err == nil {
|
|
125
|
+
if err := migrations.RunGoNativeMigrationsForTenant(tenantDB); err != nil {
|
|
126
|
+
log.Printf("Warning: Failed to run migrations on tenant %s silo %s: %v", tenant.RoleName, tenant.DBName, err)
|
|
127
|
+
} else {
|
|
128
|
+
log.Printf("β
Migrated tenant silo: %s", tenant.DBName)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
118
133
|
}
|
|
119
134
|
}
|
|
120
135
|
|
|
@@ -2,6 +2,7 @@ package service
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"strings"
|
|
5
6
|
{{#if needFmt}}
|
|
6
7
|
"fmt"
|
|
7
8
|
{{/if}}
|
|
@@ -283,6 +284,15 @@ func (s *{{capitalize name}}Service) List{{capitalize name}}(ctx context.Context
|
|
|
283
284
|
for role, db := range siloConns {
|
|
284
285
|
var results []models.{{capitalize name}}
|
|
285
286
|
opts := options.Find().SetSkip(int64((req.Page - 1) * req.PageSize)).SetLimit(int64(req.PageSize))
|
|
287
|
+
if req.Sort != "" {
|
|
288
|
+
parts := strings.Split(req.Sort, ",")
|
|
289
|
+
field := parts[0]
|
|
290
|
+
order := 1
|
|
291
|
+
if len(parts) > 1 && strings.ToLower(parts[1]) == "desc" {
|
|
292
|
+
order = -1
|
|
293
|
+
}
|
|
294
|
+
opts.SetSort(bson.D{bson.E{Key: field, Value: order}})
|
|
295
|
+
}
|
|
286
296
|
cursor, err := db.Collection("{{toLowerCase name}}s").Find(ctx, bson.M{}, opts)
|
|
287
297
|
if err == nil {
|
|
288
298
|
cursor.All(ctx, &results)
|
|
@@ -300,6 +310,15 @@ func (s *{{capitalize name}}Service) List{{capitalize name}}(ctx context.Context
|
|
|
300
310
|
if ok {
|
|
301
311
|
var results []models.{{capitalize name}}
|
|
302
312
|
opts := options.Find().SetSkip(int64((req.Page - 1) * req.PageSize)).SetLimit(int64(req.PageSize))
|
|
313
|
+
if req.Sort != "" {
|
|
314
|
+
parts := strings.Split(req.Sort, ",")
|
|
315
|
+
field := parts[0]
|
|
316
|
+
order := 1
|
|
317
|
+
if len(parts) > 1 && strings.ToLower(parts[1]) == "desc" {
|
|
318
|
+
order = -1
|
|
319
|
+
}
|
|
320
|
+
opts.SetSort(bson.D{bson.E{Key: field, Value: order}})
|
|
321
|
+
}
|
|
303
322
|
cursor, err := db.Collection("{{toLowerCase name}}s").Find(ctx, bson.M{}, opts)
|
|
304
323
|
if err == nil {
|
|
305
324
|
cursor.All(ctx, &results)
|
|
@@ -319,7 +338,9 @@ func (s *{{capitalize name}}Service) List{{capitalize name}}(ctx context.Context
|
|
|
319
338
|
for role, db := range siloConns {
|
|
320
339
|
var results []models.{{capitalize name}}
|
|
321
340
|
query := db.WithContext(ctx).Model(&models.{{capitalize name}}{})
|
|
322
|
-
|
|
341
|
+
if req.Sort != "" {
|
|
342
|
+
query = query.Order(strings.ReplaceAll(req.Sort, ",", " "))
|
|
343
|
+
}
|
|
323
344
|
var count int64
|
|
324
345
|
query.Count(&count)
|
|
325
346
|
total += count
|
|
@@ -338,7 +359,9 @@ func (s *{{capitalize name}}Service) List{{capitalize name}}(ctx context.Context
|
|
|
338
359
|
if ok {
|
|
339
360
|
var results []models.{{capitalize name}}
|
|
340
361
|
query := db.WithContext(ctx).Model(&models.{{capitalize name}}{})
|
|
341
|
-
|
|
362
|
+
if req.Sort != "" {
|
|
363
|
+
query = query.Order(strings.ReplaceAll(req.Sort, ",", " "))
|
|
364
|
+
}
|
|
342
365
|
var count int64
|
|
343
366
|
query.Count(&count)
|
|
344
367
|
total = count
|