go-duck-cli 1.0.0
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 +130 -0
- package/generators/cache.js +107 -0
- package/generators/config.js +173 -0
- package/generators/devops.js +212 -0
- package/generators/docs.js +74 -0
- package/generators/graphql.js +38 -0
- package/generators/kratos.js +157 -0
- package/generators/logger.js +68 -0
- package/generators/metering.js +143 -0
- package/generators/migrations.js +240 -0
- package/generators/mqtt.js +87 -0
- package/generators/multitenancy.js +130 -0
- package/generators/postgrest.js +115 -0
- package/generators/repository.js +28 -0
- package/generators/resilience.js +69 -0
- package/generators/security.js +168 -0
- package/generators/swagger.js +145 -0
- package/generators/telemetry.js +121 -0
- package/generators/websocket.js +162 -0
- package/index.js +592 -0
- package/package.json +23 -0
- package/parser/gdl.js +162 -0
- package/templates/application.yml.hbs +18 -0
- package/templates/docs/gin_bottle.png +0 -0
- package/templates/docs/index.html.hbs +226 -0
- package/templates/docs/intro.mp4 +0 -0
- package/templates/docs/kratos_mark.png +0 -0
- package/templates/docs/layout.hbs +106 -0
- package/templates/docs/logo.png +0 -0
- package/templates/docs/pages/audit.hbs +39 -0
- package/templates/docs/pages/cli.hbs +83 -0
- package/templates/docs/pages/gdl.hbs +223 -0
- package/templates/docs/pages/graphql.hbs +51 -0
- package/templates/docs/pages/grpc.hbs +100 -0
- package/templates/docs/pages/index.hbs +181 -0
- package/templates/docs/pages/integrations.hbs +83 -0
- package/templates/docs/pages/observability.hbs +34 -0
- package/templates/docs/pages/realtime.hbs +43 -0
- package/templates/docs/pages/rest.hbs +149 -0
- package/templates/docs/pages/security.hbs +31 -0
- package/templates/go/controller.go.hbs +236 -0
- package/templates/go/entity.go.hbs +34 -0
- package/templates/go/enum.go.hbs +7 -0
- package/templates/go/main.go.hbs +186 -0
- package/templates/graphql/resolver.go.hbs +50 -0
- package/templates/graphql/schema.graphql.hbs +64 -0
- package/templates/kratos/service.go.hbs +104 -0
- package/templates/proto/entity.proto.hbs +95 -0
- package/test_parser.js +9 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import Handlebars from 'handlebars';
|
|
5
|
+
|
|
6
|
+
export const generateGraphQLCode = async (config, entities, relationships, outputDir, enums = []) => {
|
|
7
|
+
const graphDir = path.join(outputDir, 'graph');
|
|
8
|
+
await fs.ensureDir(graphDir);
|
|
9
|
+
|
|
10
|
+
const templatesDir = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '../templates/graphql');
|
|
11
|
+
|
|
12
|
+
// 1. Generate schema.graphqls
|
|
13
|
+
const schemaTemplatePath = path.join(templatesDir, 'schema.graphql.hbs');
|
|
14
|
+
if (await fs.pathExists(schemaTemplatePath)) {
|
|
15
|
+
const schemaTemplateSource = await fs.readFile(schemaTemplatePath, 'utf8');
|
|
16
|
+
const schemaTemplate = Handlebars.compile(schemaTemplateSource);
|
|
17
|
+
const schemaContent = schemaTemplate({ entities, relationships, enums });
|
|
18
|
+
await fs.writeFile(path.join(graphDir, 'schema.graphqls'), schemaContent);
|
|
19
|
+
console.log(chalk.gray(' Generated GraphQL Schema: schema.graphqls'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 2. Generate resolver.go
|
|
23
|
+
const resolverTemplatePath = path.join(templatesDir, 'resolver.go.hbs');
|
|
24
|
+
if (await fs.pathExists(resolverTemplatePath)) {
|
|
25
|
+
const resolverTemplateSource = await fs.readFile(resolverTemplatePath, 'utf8');
|
|
26
|
+
const resolverTemplate = Handlebars.compile(resolverTemplateSource);
|
|
27
|
+
const resolverContent = resolverTemplate({
|
|
28
|
+
app_name: config.name,
|
|
29
|
+
entities,
|
|
30
|
+
relationships,
|
|
31
|
+
enums
|
|
32
|
+
});
|
|
33
|
+
await fs.writeFile(path.join(graphDir, 'resolver.go'), resolverContent);
|
|
34
|
+
console.log(chalk.gray(' Generated GraphQL Resolvers: resolver.go'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(chalk.green('✅ GraphQL code generated successfully!'));
|
|
38
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import Handlebars from 'handlebars';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
export const generateKratosCode = async (entities, projectRootDir, projectName, enums = []) => {
|
|
7
|
+
console.log(chalk.cyan('Generating Kratos gRPC Services...'));
|
|
8
|
+
|
|
9
|
+
const apiDir = path.join(projectRootDir, 'api', 'v1');
|
|
10
|
+
const serviceDir = path.join(projectRootDir, 'internal', 'service');
|
|
11
|
+
const serverDir = path.join(projectRootDir, 'internal', 'server');
|
|
12
|
+
|
|
13
|
+
await fs.ensureDir(apiDir);
|
|
14
|
+
await fs.ensureDir(serviceDir);
|
|
15
|
+
await fs.ensureDir(serverDir);
|
|
16
|
+
|
|
17
|
+
const protoTemplateSource = await fs.readFile(path.join(process.cwd(), 'templates', 'proto', 'entity.proto.hbs'), 'utf8');
|
|
18
|
+
const protoTemplate = Handlebars.compile(protoTemplateSource);
|
|
19
|
+
|
|
20
|
+
const serviceTemplateSource = await fs.readFile(path.join(process.cwd(), 'templates', 'kratos', 'service.go.hbs'), 'utf8');
|
|
21
|
+
const serviceTemplate = Handlebars.compile(serviceTemplateSource);
|
|
22
|
+
|
|
23
|
+
// Helpers for Proto types
|
|
24
|
+
Handlebars.registerHelper('toProtoType', (type) => {
|
|
25
|
+
const isEnum = enums.some(e => e.name === type);
|
|
26
|
+
// Map enums to string in proto to match the Go string enums for simpler POC
|
|
27
|
+
if (isEnum) return 'string';
|
|
28
|
+
|
|
29
|
+
const map = {
|
|
30
|
+
'String': 'string',
|
|
31
|
+
'Integer': 'int32',
|
|
32
|
+
'Long': 'int64',
|
|
33
|
+
'Float': 'float',
|
|
34
|
+
'BigDecimal': 'double',
|
|
35
|
+
'Boolean': 'bool',
|
|
36
|
+
'LocalDate': 'string',
|
|
37
|
+
'Instant': 'string',
|
|
38
|
+
'Text': 'string',
|
|
39
|
+
'JSON': 'string',
|
|
40
|
+
'JSONB': 'string'
|
|
41
|
+
};
|
|
42
|
+
return map[type] || 'string';
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
Handlebars.registerHelper('toGoCast', (type) => {
|
|
46
|
+
const isEnum = enums.some(e => e.name === type);
|
|
47
|
+
if (isEnum) return `models.${type}`;
|
|
48
|
+
|
|
49
|
+
const map = {
|
|
50
|
+
'Integer': 'int',
|
|
51
|
+
'Long': 'int64',
|
|
52
|
+
'Float': 'float64',
|
|
53
|
+
'BigDecimal': 'float64',
|
|
54
|
+
'Boolean': 'bool',
|
|
55
|
+
'JSON': 'datatypes.JSON',
|
|
56
|
+
'JSONB': 'datatypes.JSON'
|
|
57
|
+
};
|
|
58
|
+
return map[type] || '';
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
Handlebars.registerHelper('toProtoCast', (type) => {
|
|
62
|
+
const isEnum = enums.some(e => e.name === type);
|
|
63
|
+
if (isEnum) return 'string';
|
|
64
|
+
|
|
65
|
+
const map = {
|
|
66
|
+
'Integer': 'int32',
|
|
67
|
+
'Long': 'int64',
|
|
68
|
+
'Float': 'float32',
|
|
69
|
+
'BigDecimal': 'double',
|
|
70
|
+
'JSON': 'string',
|
|
71
|
+
'JSONB': 'string'
|
|
72
|
+
};
|
|
73
|
+
return map[type] || '';
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
Handlebars.registerHelper('add', (a, b) => {
|
|
77
|
+
return a + b;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
Handlebars.registerHelper('hasJson', (fields) => {
|
|
81
|
+
if (!fields || !Array.isArray(fields)) return false;
|
|
82
|
+
return fields.some(f => f.type === 'JSON' || f.type === 'JSONB');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
Handlebars.registerHelper('isJson', (type) => type === 'JSON' || type === 'JSONB');
|
|
86
|
+
|
|
87
|
+
for (const entity of entities) {
|
|
88
|
+
const context = {
|
|
89
|
+
name: entity.name,
|
|
90
|
+
capitalize: (s) => s.charAt(0).toUpperCase() + s.slice(1),
|
|
91
|
+
lower: (s) => s.toLowerCase(),
|
|
92
|
+
fields: entity.fields,
|
|
93
|
+
projectName,
|
|
94
|
+
enums
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// 1. Generate Proto
|
|
98
|
+
const protoContent = protoTemplate(context);
|
|
99
|
+
await fs.writeFile(path.join(apiDir, `${entity.name.toLowerCase()}.proto`), protoContent);
|
|
100
|
+
|
|
101
|
+
// 2. Generate Service Implementation
|
|
102
|
+
const serviceContent = serviceTemplate(context);
|
|
103
|
+
await fs.writeFile(path.join(serviceDir, `${entity.name.toLowerCase()}.go`), serviceContent);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 3. Generate Auth Middleware & gRPC Server with Kratos
|
|
107
|
+
await generateKratosServer(serverDir, projectName, entities);
|
|
108
|
+
|
|
109
|
+
console.log(chalk.green('✅ Kratos gRPC code generated successfully!'));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const generateKratosServer = async (serverDir, projectName, entities) => {
|
|
113
|
+
const grpcServerTemplate = `package server
|
|
114
|
+
|
|
115
|
+
import (
|
|
116
|
+
"context"
|
|
117
|
+
"github.com/go-kratos/kratos/v2/middleware/auth/jwt"
|
|
118
|
+
"github.com/go-kratos/kratos/v2/middleware/recovery"
|
|
119
|
+
"github.com/go-kratos/kratos/v2/transport/grpc"
|
|
120
|
+
"github.com/golang-jwt/jwt/v4"
|
|
121
|
+
v1 "{{projectName}}/api/v1"
|
|
122
|
+
"{{projectName}}/internal/service"
|
|
123
|
+
"{{projectName}}/internal/repository"
|
|
124
|
+
"{{projectName}}/config"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
func NewGRPCServer(conf *config.Config, repo *repository.Repository) *grpc.Server {
|
|
128
|
+
var opts = []grpc.ServerOption{
|
|
129
|
+
grpc.Middleware(
|
|
130
|
+
recovery.Recovery(),
|
|
131
|
+
jwt.Server(func(token *jwt.Token) (interface{}, error) {
|
|
132
|
+
return []byte(conf.GoDuck.Security.KeycloakSecret), nil
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
}
|
|
136
|
+
if conf.GoDuck.Server.GRPC.Addr != "" {
|
|
137
|
+
opts = append(opts, grpc.Address(conf.GoDuck.Server.GRPC.Addr))
|
|
138
|
+
}
|
|
139
|
+
srv := grpc.NewServer(opts...)
|
|
140
|
+
|
|
141
|
+
// Register Services
|
|
142
|
+
{{#each entities}}
|
|
143
|
+
v1.Register{{capitalize name}}ServiceServer(srv, service.New{{capitalize name}}Service(repo))
|
|
144
|
+
{{/each}}
|
|
145
|
+
// go-duck-needle-add-grpc-service
|
|
146
|
+
|
|
147
|
+
return srv
|
|
148
|
+
}
|
|
149
|
+
`;
|
|
150
|
+
const template = Handlebars.compile(grpcServerTemplate);
|
|
151
|
+
const content = template({
|
|
152
|
+
projectName,
|
|
153
|
+
entities,
|
|
154
|
+
capitalize: (s) => s.charAt(0).toUpperCase() + s.slice(1)
|
|
155
|
+
});
|
|
156
|
+
await fs.writeFile(path.join(serverDir, 'grpc.go'), content);
|
|
157
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
export const generateLoggerCode = async (config, outputDir) => {
|
|
6
|
+
const loggerDir = path.join(outputDir, 'logger');
|
|
7
|
+
await fs.ensureDir(loggerDir);
|
|
8
|
+
|
|
9
|
+
const loggerGo = `
|
|
10
|
+
package logger
|
|
11
|
+
|
|
12
|
+
import (
|
|
13
|
+
"log"
|
|
14
|
+
"os"
|
|
15
|
+
"{{app_name}}/config"
|
|
16
|
+
|
|
17
|
+
"github.com/DataDog/datadog-go/statsd"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
var (
|
|
21
|
+
Statsd *statsd.Client
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// InitLogger initializes the application logging and monitoring
|
|
25
|
+
func InitLogger(cfg *config.Config) {
|
|
26
|
+
if cfg.GoDuck.Logging.Datadog.Enabled {
|
|
27
|
+
log.Printf("Initializing Datadog Monitoring for service: %s", cfg.GoDuck.Logging.Datadog.Service)
|
|
28
|
+
|
|
29
|
+
// In a real implementation, you'd use a Datadog logging hook or library
|
|
30
|
+
// Here we initialize Statsd as an example of DD integration
|
|
31
|
+
client, err := statsd.New("127.0.0.1:8125")
|
|
32
|
+
if err == nil {
|
|
33
|
+
Statsd = client
|
|
34
|
+
Statsd.Namespace = cfg.GoDuck.Logging.Datadog.Service + "."
|
|
35
|
+
Statsd.Tags = []string{"environment:" + cfg.Environment.ActiveProfile}
|
|
36
|
+
} else {
|
|
37
|
+
log.Printf("Warning: Failed to initialize Datadog statsd: %v", err)
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
log.Println("Datadog logging is disabled. Using standard console output.")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Set standard logger output
|
|
44
|
+
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
45
|
+
log.SetOutput(os.Stdout)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Info logs information messages
|
|
49
|
+
func Info(format string, v ...interface{}) {
|
|
50
|
+
log.Printf("[INFO] "+format, v...)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Error logs error messages
|
|
54
|
+
func Error(format string, v ...interface{}) {
|
|
55
|
+
log.Printf("[ERROR] "+format, v...)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Trace metric (example of DD analytics)
|
|
59
|
+
func TraceMetric(name string, value float64, tags []string) {
|
|
60
|
+
if Statsd != nil {
|
|
61
|
+
Statsd.Gauge(name, value, tags, 1)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
await fs.writeFile(path.join(loggerDir, 'logger.go'), loggerGo.replace(/{{app_name}}/g, config.name));
|
|
67
|
+
console.log(chalk.gray(' Generated Datadog-ready Logger Package'));
|
|
68
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
export const generateMeteringCode = async (config, outputDir) => {
|
|
6
|
+
const middlewareDir = path.join(outputDir, 'middleware');
|
|
7
|
+
const modelsDir = path.join(outputDir, 'models');
|
|
8
|
+
const controllersDir = path.join(outputDir, 'controllers');
|
|
9
|
+
|
|
10
|
+
await fs.ensureDir(middlewareDir);
|
|
11
|
+
await fs.ensureDir(modelsDir);
|
|
12
|
+
await fs.ensureDir(controllersDir);
|
|
13
|
+
|
|
14
|
+
const meteringModel = `
|
|
15
|
+
package models
|
|
16
|
+
|
|
17
|
+
import (
|
|
18
|
+
"time"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
type APIUsage struct {
|
|
22
|
+
ID uint \`gorm:"primaryKey" json:"id"\`
|
|
23
|
+
UserID string \`json:"userId" gorm:"index:idx_user_api,unique"\`
|
|
24
|
+
APIPath string \`json:"apiPath" gorm:"index:idx_user_api,unique"\`
|
|
25
|
+
UsageCount int64 \`json:"usageCount"\`
|
|
26
|
+
MaxLimit int64 \`json:"maxLimit" gorm:"default:1000"\`
|
|
27
|
+
LastAccessed time.Time \`json:"lastAccessed"\`
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const meteringMiddleware = `
|
|
32
|
+
package middleware
|
|
33
|
+
|
|
34
|
+
import (
|
|
35
|
+
"net/http"
|
|
36
|
+
"time"
|
|
37
|
+
|
|
38
|
+
"github.com/gin-gonic/gin"
|
|
39
|
+
"gorm.io/gorm"
|
|
40
|
+
"{{app_name}}/models"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
func MeteringMiddleware(db *gorm.DB) gin.HandlerFunc {
|
|
44
|
+
return func(c *gin.Context) {
|
|
45
|
+
userID := c.GetHeader("X-Keycloak-Id")
|
|
46
|
+
if userID == "" {
|
|
47
|
+
c.Next()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
path := c.Request.URL.Path
|
|
52
|
+
var usage models.APIUsage
|
|
53
|
+
|
|
54
|
+
// Get usage and limit
|
|
55
|
+
result := db.Where("user_id = ? AND api_path = ?", userID, path).First(&usage)
|
|
56
|
+
if result.Error == gorm.ErrRecordNotFound {
|
|
57
|
+
usage = models.APIUsage{
|
|
58
|
+
UserID: userID,
|
|
59
|
+
APIPath: path,
|
|
60
|
+
UsageCount: 1,
|
|
61
|
+
MaxLimit: 1000, // Default limit
|
|
62
|
+
LastAccessed: time.Now(),
|
|
63
|
+
}
|
|
64
|
+
db.Create(&usage)
|
|
65
|
+
} else {
|
|
66
|
+
if usage.UsageCount >= usage.MaxLimit {
|
|
67
|
+
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
68
|
+
"error": "Usage limit exceeded",
|
|
69
|
+
"limit": usage.MaxLimit,
|
|
70
|
+
"usage": usage.UsageCount,
|
|
71
|
+
})
|
|
72
|
+
c.Abort()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
db.Model(&usage).Updates(map[string]interface{}{
|
|
76
|
+
"usage_count": usage.UsageCount + 1,
|
|
77
|
+
"last_accessed": time.Now(),
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
c.Next()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const meteringController = `
|
|
87
|
+
package controllers
|
|
88
|
+
|
|
89
|
+
import (
|
|
90
|
+
"net/http"
|
|
91
|
+
"{{app_name}}/models"
|
|
92
|
+
|
|
93
|
+
"github.com/gin-gonic/gin"
|
|
94
|
+
"gorm.io/gorm"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
type MeteringController struct {
|
|
98
|
+
DB *gorm.DB
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func (mc *MeteringController) SetLimit(c *gin.Context) {
|
|
102
|
+
var req struct {
|
|
103
|
+
UserID string \`json:"userId" binding:"required"\`
|
|
104
|
+
APIPath string \`json:"apiPath" binding:"required"\`
|
|
105
|
+
MaxLimit int64 \`json:"maxLimit" binding:"required"\`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if err := c.ShouldBindJSON(&req); err != nil {
|
|
109
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
var usage models.APIUsage
|
|
114
|
+
result := mc.DB.Where("user_id = ? AND api_path = ?", req.UserID, req.APIPath).First(&usage)
|
|
115
|
+
|
|
116
|
+
if result.Error == gorm.ErrRecordNotFound {
|
|
117
|
+
usage = models.APIUsage{
|
|
118
|
+
UserID: req.UserID,
|
|
119
|
+
APIPath: req.APIPath,
|
|
120
|
+
MaxLimit: req.MaxLimit,
|
|
121
|
+
UsageCount: 0,
|
|
122
|
+
}
|
|
123
|
+
mc.DB.Create(&usage)
|
|
124
|
+
} else {
|
|
125
|
+
mc.DB.Model(&usage).Update("max_limit", req.MaxLimit)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
c.JSON(http.StatusOK, gin.H{"message": "Limit updated successfully"})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func (mc *MeteringController) GetUsage(c *gin.Context) {
|
|
132
|
+
var usages []models.APIUsage
|
|
133
|
+
mc.DB.Find(&usages)
|
|
134
|
+
c.JSON(http.StatusOK, usages)
|
|
135
|
+
}
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
await fs.writeFile(path.join(modelsDir, 'api_usage.go'), meteringModel);
|
|
139
|
+
await fs.writeFile(path.join(middlewareDir, 'metering_middleware.go'), meteringMiddleware.replace('{{app_name}}', config.name));
|
|
140
|
+
await fs.writeFile(path.join(controllersDir, 'metering_controller.go'), meteringController.replace('{{app_name}}', config.name));
|
|
141
|
+
|
|
142
|
+
console.log(chalk.gray(' Generated Metering Model, Middleware & Controller'));
|
|
143
|
+
};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { toLiquibaseType } from '../parser/gdl.js';
|
|
5
|
+
|
|
6
|
+
export const generateLiquibaseChangelogs = async (entities, relationships, projectRootDir, delta = null, enums = []) => {
|
|
7
|
+
const migrationsDir = path.join(projectRootDir, 'migrations');
|
|
8
|
+
const liquibaseDir = path.join(migrationsDir, 'liquibase');
|
|
9
|
+
const changelogsDir = path.join(liquibaseDir, 'changelogs');
|
|
10
|
+
await fs.ensureDir(migrationsDir);
|
|
11
|
+
await fs.ensureDir(liquibaseDir);
|
|
12
|
+
await fs.ensureDir(changelogsDir);
|
|
13
|
+
|
|
14
|
+
const timestamp = new Date().toISOString().replace(/[-:T]/g, '').split('.')[0];
|
|
15
|
+
const dateStamp = timestamp.substring(0, 8); // YYYYMMDD
|
|
16
|
+
|
|
17
|
+
// -------------------------------------------------------
|
|
18
|
+
// STEP 1: Build changesets FIRST — decide on filename after
|
|
19
|
+
// -------------------------------------------------------
|
|
20
|
+
let changeSets = '';
|
|
21
|
+
|
|
22
|
+
const entitiesToCreate = delta ? delta.newEntities : entities;
|
|
23
|
+
const relationshipsToAdd = delta ? delta.newRelationships : relationships;
|
|
24
|
+
|
|
25
|
+
// Create New Tables
|
|
26
|
+
for (const entity of entitiesToCreate) {
|
|
27
|
+
let columns = `
|
|
28
|
+
<column name="id" type="BIGINT" autoIncrement="true">
|
|
29
|
+
<constraints primaryKey="true" nullable="false"/>
|
|
30
|
+
</column>`;
|
|
31
|
+
|
|
32
|
+
for (const field of entity.fields) {
|
|
33
|
+
const liqType = toLiquibaseType(field, enums);
|
|
34
|
+
const uniqueConstraint = field.unique ? ' <constraints nullable="' + (field.required ? 'false' : 'true') + '" unique="true"/>' : '';
|
|
35
|
+
if (field.unique) {
|
|
36
|
+
columns += `
|
|
37
|
+
<column name="${field.name.toLowerCase()}" type="${liqType}">
|
|
38
|
+
<constraints nullable="${field.required ? 'false' : 'true'}" unique="true"/>
|
|
39
|
+
</column>`;
|
|
40
|
+
} else {
|
|
41
|
+
columns += `
|
|
42
|
+
<column name="${field.name.toLowerCase()}" type="${liqType}">
|
|
43
|
+
<constraints nullable="${field.required ? 'false' : 'true'}"/>
|
|
44
|
+
</column>`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// @Audited entities get full audit columns (skip the simpler created_at/updated_at to avoid duplicates)
|
|
49
|
+
if (entity.annotation === '@Audited') {
|
|
50
|
+
columns += `
|
|
51
|
+
<column name="created_by" type="VARCHAR(255)"/>
|
|
52
|
+
<column name="created_date" type="TIMESTAMP"/>
|
|
53
|
+
<column name="last_modified_by" type="VARCHAR(255)"/>
|
|
54
|
+
<column name="last_modified_date" type="TIMESTAMP"/>
|
|
55
|
+
<column name="last_modified_user_id" type="VARCHAR(255)"/>`;
|
|
56
|
+
} else {
|
|
57
|
+
columns += `
|
|
58
|
+
<column name="created_at" type="TIMESTAMP"/>
|
|
59
|
+
<column name="updated_at" type="TIMESTAMP"/>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
changeSets += `
|
|
63
|
+
<changeSet id="${entity.name.toLowerCase()}-create-${timestamp}" author="go-duck">
|
|
64
|
+
<preConditions onFail="MARK_RAN"><not><tableExists tableName="${entity.name.toLowerCase()}"/></not></preConditions>
|
|
65
|
+
<createTable tableName="${entity.name.toLowerCase()}">
|
|
66
|
+
${columns}
|
|
67
|
+
</createTable>
|
|
68
|
+
</changeSet>
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Add New Fields to existing tables
|
|
73
|
+
if (delta && delta.newFields) {
|
|
74
|
+
for (const [entityName, fields] of Object.entries(delta.newFields)) {
|
|
75
|
+
if (fields.length === 0) continue;
|
|
76
|
+
let columnTags = '';
|
|
77
|
+
for (const field of fields) {
|
|
78
|
+
const liqType = toLiquibaseType(field, enums);
|
|
79
|
+
if (field.unique) {
|
|
80
|
+
columnTags += `
|
|
81
|
+
<column name="${field.name.toLowerCase()}" type="${liqType}">
|
|
82
|
+
<constraints nullable="${field.required ? 'false' : 'true'}" unique="true"/>
|
|
83
|
+
</column>`;
|
|
84
|
+
} else {
|
|
85
|
+
columnTags += `
|
|
86
|
+
<column name="${field.name.toLowerCase()}" type="${liqType}">
|
|
87
|
+
<constraints nullable="${field.required ? 'false' : 'true'}"/>
|
|
88
|
+
</column>`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
changeSets += `
|
|
92
|
+
<changeSet id="${entityName.toLowerCase()}-add-fields-${timestamp}" author="go-duck">
|
|
93
|
+
<addColumn tableName="${entityName.toLowerCase()}">
|
|
94
|
+
${columnTags}
|
|
95
|
+
</addColumn>
|
|
96
|
+
</changeSet>
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Foreign Keys + Index
|
|
102
|
+
for (const rel of relationshipsToAdd) {
|
|
103
|
+
if (rel.type === 'OneToMany') {
|
|
104
|
+
const childTable = rel.to.entity.toLowerCase();
|
|
105
|
+
const fkNullable = rel.required ? 'false' : 'true';
|
|
106
|
+
const fkCol = rel.to.field.toLowerCase() + '_id';
|
|
107
|
+
const parentTable = rel.from.entity.toLowerCase();
|
|
108
|
+
const fkName = `fk_${childTable}_${rel.to.field.toLowerCase()}`;
|
|
109
|
+
const idxName = `idx_${childTable}_${fkCol}`;
|
|
110
|
+
changeSets += `
|
|
111
|
+
<changeSet id="rel-${parentTable}-${childTable}-${timestamp}" author="go-duck">
|
|
112
|
+
<preConditions onFail="MARK_RAN">
|
|
113
|
+
<not>
|
|
114
|
+
<columnExists tableName="${childTable}" columnName="${fkCol}"/>
|
|
115
|
+
</not>
|
|
116
|
+
</preConditions>
|
|
117
|
+
<addColumn tableName="${childTable}">
|
|
118
|
+
<column name="${fkCol}" type="BIGINT">
|
|
119
|
+
<constraints nullable="${fkNullable}" foreignKeyName="${fkName}" referencedTableName="${parentTable}" referencedColumnNames="id"/>
|
|
120
|
+
</column>
|
|
121
|
+
</addColumn>
|
|
122
|
+
</changeSet>
|
|
123
|
+
|
|
124
|
+
<changeSet id="idx-${idxName}-${timestamp}" author="go-duck">
|
|
125
|
+
<preConditions onFail="MARK_RAN">
|
|
126
|
+
<not><indexExists indexName="${idxName}"/></not>
|
|
127
|
+
</preConditions>
|
|
128
|
+
<createIndex indexName="${idxName}" tableName="${childTable}">
|
|
129
|
+
<column name="${fkCol}"/>
|
|
130
|
+
</createIndex>
|
|
131
|
+
</changeSet>
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Support Tables (always on initial run, never on incremental)
|
|
137
|
+
if (!delta) {
|
|
138
|
+
changeSets += `
|
|
139
|
+
<changeSet id="management-tables-init" author="go-duck">
|
|
140
|
+
<preConditions onFail="MARK_RAN"><not><tableExists tableName="tenant_roles"/></not></preConditions>
|
|
141
|
+
<createTable tableName="tenant_roles">
|
|
142
|
+
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
|
143
|
+
<column name="role_name" type="VARCHAR(255)"><constraints nullable="false" unique="true"/></column>
|
|
144
|
+
<column name="db_name" type="VARCHAR(255)"><constraints nullable="false"/></column>
|
|
145
|
+
</createTable>
|
|
146
|
+
</changeSet>
|
|
147
|
+
|
|
148
|
+
<changeSet id="audit-log-table-init" author="go-duck">
|
|
149
|
+
<preConditions onFail="MARK_RAN"><not><tableExists tableName="audit_log"/></not></preConditions>
|
|
150
|
+
<createTable tableName="audit_log">
|
|
151
|
+
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
|
152
|
+
<column name="entity_name" type="VARCHAR(255)"><constraints nullable="false"/></column>
|
|
153
|
+
<column name="entity_id" type="VARCHAR(255)"><constraints nullable="false"/></column>
|
|
154
|
+
<column name="action" type="VARCHAR(50)"><constraints nullable="false"/></column>
|
|
155
|
+
<column name="previous_value" type="TEXT"/><column name="new_value" type="TEXT"/>
|
|
156
|
+
<column name="modified_by" type="VARCHAR(255)"/><column name="keycloak_id" type="VARCHAR(255)"/>
|
|
157
|
+
<column name="modified_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
|
158
|
+
<column name="client_ip" type="VARCHAR(50)"/>
|
|
159
|
+
</createTable>
|
|
160
|
+
</changeSet>
|
|
161
|
+
|
|
162
|
+
<changeSet id="api-usage-table-init" author="go-duck">
|
|
163
|
+
<preConditions onFail="MARK_RAN"><not><tableExists tableName="api_usage"/></not></preConditions>
|
|
164
|
+
<createTable tableName="api_usage">
|
|
165
|
+
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
|
166
|
+
<column name="user_id" type="VARCHAR(255)"><constraints nullable="false"/></column>
|
|
167
|
+
<column name="api_path" type="VARCHAR(255)"><constraints nullable="false"/></column>
|
|
168
|
+
<column name="usage_count" type="BIGINT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
|
169
|
+
<column name="max_limit" type="BIGINT" defaultValueNumeric="1000"><constraints nullable="false"/></column>
|
|
170
|
+
<column name="last_accessed" type="TIMESTAMP"/>
|
|
171
|
+
</createTable>
|
|
172
|
+
</changeSet>
|
|
173
|
+
`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -------------------------------------------------------
|
|
177
|
+
// STEP 2: Only write files if there are real changesets
|
|
178
|
+
// -------------------------------------------------------
|
|
179
|
+
if (changeSets.trim() === '') {
|
|
180
|
+
console.log(chalk.gray(' No database changes detected.'));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Build descriptive filename from what actually changed
|
|
185
|
+
const descParts = [];
|
|
186
|
+
if (!delta) {
|
|
187
|
+
const entityNames = entities.map(e => e.name.toLowerCase()).join('-');
|
|
188
|
+
descParts.push('init');
|
|
189
|
+
if (entityNames) descParts.push(entityNames);
|
|
190
|
+
} else {
|
|
191
|
+
if (delta.newEntities && delta.newEntities.length > 0)
|
|
192
|
+
descParts.push('create-' + delta.newEntities.map(e => e.name.toLowerCase()).join('-'));
|
|
193
|
+
if (delta.newFields && Object.keys(delta.newFields).length > 0)
|
|
194
|
+
descParts.push('add-fields-to-' + Object.keys(delta.newFields).map(n => n.toLowerCase()).join('-'));
|
|
195
|
+
if (delta.newRelationships && delta.newRelationships.length > 0)
|
|
196
|
+
descParts.push('add-relations');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const changelogFileName = `changelog-${dateStamp}-${descParts.join('__')}.xml`;
|
|
200
|
+
const changelogPath = path.join(changelogsDir, changelogFileName);
|
|
201
|
+
const masterPath = path.join(liquibaseDir, 'master.xml');
|
|
202
|
+
|
|
203
|
+
// Write the changelog XML
|
|
204
|
+
const changelogXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
205
|
+
<databaseChangeLog
|
|
206
|
+
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
207
|
+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
208
|
+
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
|
209
|
+
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
|
210
|
+
${changeSets}
|
|
211
|
+
</databaseChangeLog>
|
|
212
|
+
`;
|
|
213
|
+
await fs.writeFile(changelogPath, changelogXml);
|
|
214
|
+
|
|
215
|
+
// Register in master.xml (only now that the file exists)
|
|
216
|
+
let masterXml = '';
|
|
217
|
+
if (await fs.pathExists(masterPath)) {
|
|
218
|
+
masterXml = await fs.readFile(masterPath, 'utf8');
|
|
219
|
+
if (!masterXml.includes(changelogFileName)) {
|
|
220
|
+
const includeLine = ` <include file="changelogs/${changelogFileName}" relativeToChangelogFile="true"/>`;
|
|
221
|
+
masterXml = masterXml.replace('</databaseChangeLog>', `${includeLine}\n</databaseChangeLog>`);
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
masterXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
225
|
+
<databaseChangeLog
|
|
226
|
+
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
227
|
+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
228
|
+
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
|
229
|
+
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
|
230
|
+
|
|
231
|
+
<include file="changelogs/${changelogFileName}" relativeToChangelogFile="true"/>
|
|
232
|
+
</databaseChangeLog>
|
|
233
|
+
`;
|
|
234
|
+
}
|
|
235
|
+
await fs.writeFile(masterPath, masterXml);
|
|
236
|
+
|
|
237
|
+
console.log(chalk.gray(` Generated Incremental Changelog: ${changelogFileName}`));
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const _unused = null; // toLiquibaseType imported from parser/gdl.js
|