@nakedev/go-scaffold 0.1.2 → 0.1.3
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 +75 -0
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +59 -1
- package/dist/commands/method.js +3 -0
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +19 -2
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +66 -3
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/openapi-patcher.js +16 -0
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/version.js +24 -0
- package/package.json +2 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/Makefile.hbs +30 -5
- package/templates/create/base/README.md.hbs +36 -8
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +13 -0
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/handler.go.hbs +20 -3
- package/templates/generate/module/handler_test.go.hbs +49 -6
- package/templates/generate/module/minimal/handler.go.hbs +21 -3
- package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- package/dist/utils/module-paths.js +0 -33
|
@@ -11,11 +11,17 @@ import (
|
|
|
11
11
|
"time"
|
|
12
12
|
|
|
13
13
|
"{{goModule}}/internal/platform/database"
|
|
14
|
+
{{#if observability}}
|
|
15
|
+
"{{goModule}}/internal/platform/telemetry"
|
|
16
|
+
{{/if}}
|
|
14
17
|
"{{goModule}}/internal/shared/config"
|
|
15
18
|
"{{goModule}}/internal/shared/middleware"
|
|
16
19
|
// go-scaffold:imports
|
|
17
20
|
|
|
18
21
|
"github.com/gin-gonic/gin"
|
|
22
|
+
{{#if observability}}
|
|
23
|
+
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
24
|
+
{{/if}}
|
|
19
25
|
)
|
|
20
26
|
|
|
21
27
|
func main() {
|
|
@@ -23,7 +29,17 @@ func main() {
|
|
|
23
29
|
|
|
24
30
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
|
|
25
31
|
slog.SetDefault(logger)
|
|
32
|
+
// go-scaffold:config-checks
|
|
26
33
|
|
|
34
|
+
{{#if observability}}
|
|
35
|
+
shutdownTelemetry, err := telemetry.Init(context.Background(), "{{projectName}}", cfg.OTELExporterEndpoint)
|
|
36
|
+
if err != nil {
|
|
37
|
+
logger.Error("init telemetry", "error", err)
|
|
38
|
+
os.Exit(1)
|
|
39
|
+
}
|
|
40
|
+
defer func() { _ = shutdownTelemetry(context.Background()) }()
|
|
41
|
+
|
|
42
|
+
{{/if}}
|
|
27
43
|
db, err := database.Open(cfg)
|
|
28
44
|
if err != nil {
|
|
29
45
|
logger.Error("open db", "error", err)
|
|
@@ -35,6 +51,7 @@ func main() {
|
|
|
35
51
|
logger.Error("db handle", "error", err)
|
|
36
52
|
os.Exit(1)
|
|
37
53
|
}
|
|
54
|
+
// go-scaffold:platform-init
|
|
38
55
|
|
|
39
56
|
if cfg.AutoMigrate {
|
|
40
57
|
// ponytail: AutoMigrate is for dev only (add-only, locks the table once data grows)
|
|
@@ -45,10 +62,13 @@ func main() {
|
|
|
45
62
|
logger.Error("migrate", "error", err)
|
|
46
63
|
os.Exit(1)
|
|
47
64
|
}
|
|
65
|
+
} else if err := database.CheckMigrationVersion(db); err != nil {
|
|
66
|
+
logger.Error("migration version check", "error", err)
|
|
67
|
+
os.Exit(1)
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
r := gin.New()
|
|
51
|
-
r.Use(gin.Recovery(), middleware.RequestID(), middleware.Logger(logger), middleware.Error())
|
|
71
|
+
r.Use(gin.Recovery(), middleware.CORS(cfg.CORSAllowedOrigins), middleware.RequestID(), middleware.Logger(logger), middleware.Error(!cfg.IsProd()){{#if observability}}, middleware.Metrics(), middleware.Tracing("{{projectName}}"){{/if}})
|
|
52
72
|
|
|
53
73
|
// liveness = is the process up / readiness = ready for traffic (can it reach the DB)
|
|
54
74
|
r.GET("/livez", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
|
|
@@ -57,8 +77,12 @@ func main() {
|
|
|
57
77
|
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})
|
|
58
78
|
return
|
|
59
79
|
}
|
|
80
|
+
// go-scaffold:readyz-checks
|
|
60
81
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
61
82
|
})
|
|
83
|
+
{{#if observability}}
|
|
84
|
+
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
|
85
|
+
{{/if}}
|
|
62
86
|
{{#if openapiDocs}}
|
|
63
87
|
// hand-written spec at docs/openapi.yaml, split across sibling files (common/, health/,
|
|
64
88
|
// <domain>/) via relative $ref — serve the whole tree under one prefix so a client that
|
|
@@ -99,6 +123,7 @@ func main() {
|
|
|
99
123
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
100
124
|
logger.Error("shutdown", "error", err)
|
|
101
125
|
}
|
|
126
|
+
// go-scaffold:shutdown
|
|
102
127
|
logger.Info("stopped")
|
|
103
128
|
}
|
|
104
129
|
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
package database
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"regexp"
|
|
6
|
+
"strconv"
|
|
7
|
+
|
|
8
|
+
{{#if observability}}
|
|
9
|
+
"{{goModule}}/internal/platform/telemetry"
|
|
10
|
+
{{/if}}
|
|
4
11
|
"{{goModule}}/internal/shared/config"
|
|
12
|
+
"{{goModule}}/migrations"
|
|
5
13
|
|
|
6
14
|
"gorm.io/driver/postgres"
|
|
7
15
|
"gorm.io/gorm"
|
|
@@ -16,6 +24,12 @@ func Open(cfg config.Config) (*gorm.DB, error) {
|
|
|
16
24
|
return nil, err
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
{{#if observability}}
|
|
28
|
+
if err := db.Use(telemetry.NewGormPlugin()); err != nil {
|
|
29
|
+
return nil, err
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
{{/if}}
|
|
19
33
|
sqlDB, err := db.DB()
|
|
20
34
|
if err != nil {
|
|
21
35
|
return nil, err
|
|
@@ -26,3 +40,42 @@ func Open(cfg config.Config) (*gorm.DB, error) {
|
|
|
26
40
|
|
|
27
41
|
return db, nil
|
|
28
42
|
}
|
|
43
|
+
|
|
44
|
+
var migrationVersionRe = regexp.MustCompile(`^(\d+)_.*\.up\.sql$`)
|
|
45
|
+
|
|
46
|
+
// CheckMigrationVersion fails fast if the DB's applied schema version (the
|
|
47
|
+
// golang-migrate CLI's own schema_migrations table) doesn't match the newest
|
|
48
|
+
// migration file baked into this binary — instead of booting against a stale
|
|
49
|
+
// or half-applied schema and failing later on whatever query hits the
|
|
50
|
+
// missing column first. Only meaningful when AutoMigrate is off (prod); call
|
|
51
|
+
// this from that branch only.
|
|
52
|
+
func CheckMigrationVersion(db *gorm.DB) error {
|
|
53
|
+
entries, err := migrations.FS.ReadDir(".")
|
|
54
|
+
if err != nil {
|
|
55
|
+
return fmt.Errorf("read embedded migrations: %w", err)
|
|
56
|
+
}
|
|
57
|
+
var latest int
|
|
58
|
+
for _, e := range entries {
|
|
59
|
+
m := migrationVersionRe.FindStringSubmatch(e.Name())
|
|
60
|
+
if m == nil {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if v, _ := strconv.Atoi(m[1]); v > latest {
|
|
64
|
+
latest = v
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
var version int
|
|
69
|
+
var dirty bool
|
|
70
|
+
row := db.Raw("SELECT version, dirty FROM schema_migrations").Row()
|
|
71
|
+
if err := row.Scan(&version, &dirty); err != nil {
|
|
72
|
+
return fmt.Errorf("read schema_migrations (did you run `make migrate-up`?): %w", err)
|
|
73
|
+
}
|
|
74
|
+
if dirty {
|
|
75
|
+
return fmt.Errorf("schema_migrations is dirty at version %d — a previous migration failed partway; fix it before starting the app", version)
|
|
76
|
+
}
|
|
77
|
+
if version != latest {
|
|
78
|
+
return fmt.Errorf("DB schema is at migration %d, this binary expects %d — run `make migrate-up`", version, latest)
|
|
79
|
+
}
|
|
80
|
+
return nil
|
|
81
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
package config
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"fmt"
|
|
4
5
|
"os"
|
|
5
6
|
"strconv"
|
|
7
|
+
"strings"
|
|
6
8
|
"time"
|
|
7
9
|
)
|
|
8
10
|
|
|
9
11
|
// Config loads from env (with dev-friendly defaults).
|
|
10
12
|
type Config struct {
|
|
13
|
+
AppEnv string // "development" | "production" — the one source of truth for env-gated behavior; see IsProd (validated in Load)
|
|
11
14
|
Port string
|
|
12
15
|
DBDSN string
|
|
13
16
|
LogLevel string
|
|
@@ -15,10 +18,22 @@ type Config struct {
|
|
|
15
18
|
DBMaxOpenConns int
|
|
16
19
|
DBMaxIdleConns int
|
|
17
20
|
DBConnMaxLifetime time.Duration
|
|
21
|
+
|
|
22
|
+
CORSAllowedOrigins []string
|
|
23
|
+
{{#if observability}}
|
|
24
|
+
OTELExporterEndpoint string
|
|
25
|
+
{{/if}}
|
|
26
|
+
// go-scaffold:config-fields
|
|
18
27
|
}
|
|
19
28
|
|
|
29
|
+
// IsProd reports whether env-gated production behavior should be active
|
|
30
|
+
// (e.g. hiding internal error details from responses). Everything that
|
|
31
|
+
// isn't APP_ENV=production is treated the same way.
|
|
32
|
+
func (c Config) IsProd() bool { return c.AppEnv == "production" }
|
|
33
|
+
|
|
20
34
|
func Load() Config {
|
|
21
|
-
|
|
35
|
+
cfg := Config{
|
|
36
|
+
AppEnv: env("APP_ENV", "development"),
|
|
22
37
|
Port: env("PORT", "8080"),
|
|
23
38
|
DBDSN: env("DB_DSN", "postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable"),
|
|
24
39
|
LogLevel: env("LOG_LEVEL", "info"),
|
|
@@ -26,7 +41,23 @@ func Load() Config {
|
|
|
26
41
|
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 10),
|
|
27
42
|
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 10),
|
|
28
43
|
DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MIN", 5)) * time.Minute,
|
|
44
|
+
|
|
45
|
+
CORSAllowedOrigins: envList("CORS_ALLOWED_ORIGINS", "http://localhost:3000"),
|
|
46
|
+
{{#if observability}}
|
|
47
|
+
OTELExporterEndpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
|
|
48
|
+
{{/if}}
|
|
49
|
+
// go-scaffold:config-load
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fail closed on an unknown APP_ENV: an unrecognized value (typo, stale var)
|
|
53
|
+
// would otherwise be treated as non-prod and silently disable the prod guard
|
|
54
|
+
// (leaking error details). Panics here — the one place the binary loads
|
|
55
|
+
// config — so a bad value is caught at boot, not discovered in prod later.
|
|
56
|
+
if cfg.AppEnv != "development" && cfg.AppEnv != "production" {
|
|
57
|
+
panic(fmt.Sprintf("invalid APP_ENV %q — must be development or production", cfg.AppEnv))
|
|
29
58
|
}
|
|
59
|
+
|
|
60
|
+
return cfg
|
|
30
61
|
}
|
|
31
62
|
|
|
32
63
|
func env(k, def string) string {
|
|
@@ -36,6 +67,17 @@ func env(k, def string) string {
|
|
|
36
67
|
return def
|
|
37
68
|
}
|
|
38
69
|
|
|
70
|
+
func envList(k, def string) []string {
|
|
71
|
+
raw := strings.Split(env(k, def), ",")
|
|
72
|
+
out := make([]string, 0, len(raw))
|
|
73
|
+
for _, v := range raw {
|
|
74
|
+
if v = strings.TrimSpace(v); v != "" {
|
|
75
|
+
out = append(out, v)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
39
81
|
func envInt(k string, def int) int {
|
|
40
82
|
if v := os.Getenv(k); v != "" {
|
|
41
83
|
if n, err := strconv.Atoi(v); err == nil {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"slices"
|
|
6
|
+
|
|
7
|
+
"github.com/gin-gonic/gin"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// CORS allows a fixed set of browser origins to call the API with
|
|
11
|
+
// credentials (cookies) — "*" can't be combined with Allow-Credentials per
|
|
12
|
+
// the fetch spec, so the origin is echoed back only when it's in
|
|
13
|
+
// allowedOrigins.
|
|
14
|
+
func CORS(allowedOrigins []string) gin.HandlerFunc {
|
|
15
|
+
return func(c *gin.Context) {
|
|
16
|
+
c.Header("Vary", "Origin")
|
|
17
|
+
if origin := c.GetHeader("Origin"); slices.Contains(allowedOrigins, origin) {
|
|
18
|
+
c.Header("Access-Control-Allow-Origin", origin)
|
|
19
|
+
c.Header("Access-Control-Allow-Credentials", "true")
|
|
20
|
+
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
21
|
+
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
22
|
+
}
|
|
23
|
+
if c.Request.Method == http.MethodOptions {
|
|
24
|
+
c.AbortWithStatus(http.StatusNoContent)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
c.Next()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -11,7 +11,12 @@ import (
|
|
|
11
11
|
|
|
12
12
|
// Error reads the last error attached via c.Error() and renders it once, so
|
|
13
13
|
// handlers only ever do c.Error(err); return — no c.JSON per call site.
|
|
14
|
-
|
|
14
|
+
//
|
|
15
|
+
// exposeDetail controls whether an error's Details (a validation field map,
|
|
16
|
+
// or an unexpected error's real message) is echoed back in the response body
|
|
17
|
+
// — wire this to !cfg.IsProd() so a caller hitting the API directly never
|
|
18
|
+
// learns field names/shape from prod responses; devs still get it locally.
|
|
19
|
+
func Error(exposeDetail bool) gin.HandlerFunc {
|
|
15
20
|
return func(c *gin.Context) {
|
|
16
21
|
c.Next()
|
|
17
22
|
|
|
@@ -25,6 +30,13 @@ func Error() gin.HandlerFunc {
|
|
|
25
30
|
// Unexpected error: log the real thing, answer the client generically.
|
|
26
31
|
slog.Error("unhandled error", "error", err.Err, "request_id", c.GetString(RequestIDKey))
|
|
27
32
|
appErr = apperror.NewInternal()
|
|
33
|
+
if exposeDetail {
|
|
34
|
+
appErr.Details = err.Err.Error()
|
|
35
|
+
}
|
|
36
|
+
} else if !exposeDetail {
|
|
37
|
+
// Known AppError (e.g. VALIDATION_ERROR): still strip Details in
|
|
38
|
+
// prod, so a direct API call doesn't get field-level hints.
|
|
39
|
+
appErr.Details = nil
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
appErr.RequestID = c.GetString(RequestIDKey)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Package migrations embeds the migration SQL files so the running binary
|
|
2
|
+
// can check its own schema version without shelling out to the migrate CLI.
|
|
3
|
+
package migrations
|
|
4
|
+
|
|
5
|
+
import "embed"
|
|
6
|
+
|
|
7
|
+
// ponytail: pattern is "*", not "*.sql" — a fresh project has zero .sql files
|
|
8
|
+
// until the first `generate module`, and "*.sql" fails to compile ("no
|
|
9
|
+
// matching files") until one exists. "*" always matches at least .gitkeep, so
|
|
10
|
+
// this builds from `create` onward; the harmless cost is embedding .gitkeep
|
|
11
|
+
// and this file itself alongside real migrations, which CheckMigrationVersion
|
|
12
|
+
// already skips over (regex requires a numeric prefix and .up.sql suffix).
|
|
13
|
+
//
|
|
14
|
+
//go:embed *
|
|
15
|
+
var FS embed.FS
|
|
@@ -91,6 +91,28 @@ Scalar/Swagger UI/Redoc to follow them).
|
|
|
91
91
|
comment-generated (swaggo) if hand-updates start drifting.
|
|
92
92
|
{{/if}}
|
|
93
93
|
|
|
94
|
+
{{#if observability}}
|
|
95
|
+
## 9. Observability
|
|
96
|
+
|
|
97
|
+
**Decision:** Prometheus metrics (`GET /metrics`, request count + latency
|
|
98
|
+
per route) always-on when this feature is enabled; OpenTelemetry tracing
|
|
99
|
+
(Gin + GORM) exports via OTLP/HTTP only when `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
100
|
+
is set — empty means no exporter is created and no network calls are made.
|
|
101
|
+
**Rationale:** Metrics have no external dependency to turn on (Prometheus
|
|
102
|
+
scrapes the app, the app never dials out) so there's no reason to gate them
|
|
103
|
+
further. Tracing needs a collector to be useful, so it stays off until one's
|
|
104
|
+
actually configured, instead of trying to dial a collector that isn't there.
|
|
105
|
+
This is a `create`-time choice (unlike `add auth`/`add rbac`), not something
|
|
106
|
+
layered on afterward — flip it by hand (`internal/shared/middleware/{metrics,tracing}.go`,
|
|
107
|
+
`internal/platform/telemetry/tracing.go`, wiring in `cmd/api/main.go` and
|
|
108
|
+
`internal/platform/database`) if the project needs it later. Both the Gin and
|
|
109
|
+
GORM tracing hooks are hand-rolled against the OTel SDK directly, not the
|
|
110
|
+
official `otelgin`/`gorm.io/plugin/opentelemetry` contrib packages — those
|
|
111
|
+
pull in a newer Gin (→ HTTP/3/quic-go) and every DB driver they support
|
|
112
|
+
tracing for (MySQL, ClickHouse, MongoDB), respectively, for a Postgres-only
|
|
113
|
+
project that only wants request/query spans.
|
|
114
|
+
|
|
115
|
+
{{/if}}
|
|
94
116
|
## Evolution Notes
|
|
95
117
|
|
|
96
118
|
- `go-scaffold generate module <name>` adds a new domain package and
|
|
@@ -18,3 +18,18 @@ UnprocessableEntityError:
|
|
|
18
18
|
content:
|
|
19
19
|
application/json:
|
|
20
20
|
schema: { $ref: './schemas.yaml#/Error' }
|
|
21
|
+
UnauthorizedError:
|
|
22
|
+
description: missing, invalid, or expired credentials
|
|
23
|
+
content:
|
|
24
|
+
application/json:
|
|
25
|
+
schema: { $ref: './schemas.yaml#/Error' }
|
|
26
|
+
ForbiddenError:
|
|
27
|
+
description: authenticated but lacking the required permission
|
|
28
|
+
content:
|
|
29
|
+
application/json:
|
|
30
|
+
schema: { $ref: './schemas.yaml#/Error' }
|
|
31
|
+
TooManyRequestsError:
|
|
32
|
+
description: rate limit exceeded, retry later
|
|
33
|
+
content:
|
|
34
|
+
application/json:
|
|
35
|
+
schema: { $ref: './schemas.yaml#/Error' }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
get:
|
|
2
|
+
summary: Prometheus metrics
|
|
3
|
+
description: Text-format metrics for Prometheus to scrape — not part of the JSON API surface.
|
|
4
|
+
operationId: getMetrics
|
|
5
|
+
tags: [observability]
|
|
6
|
+
security: []
|
|
7
|
+
responses:
|
|
8
|
+
"200":
|
|
9
|
+
description: ok
|
|
10
|
+
content:
|
|
11
|
+
text/plain:
|
|
12
|
+
schema: { type: string }
|
|
@@ -19,6 +19,10 @@ paths:
|
|
|
19
19
|
$ref: './health/health-livez.yaml'
|
|
20
20
|
/readyz:
|
|
21
21
|
$ref: './health/health-readyz.yaml'
|
|
22
|
+
{{#if observability}}
|
|
23
|
+
/metrics:
|
|
24
|
+
$ref: './observability/metrics.yaml'
|
|
25
|
+
{{/if}}
|
|
22
26
|
# go-scaffold:paths
|
|
23
27
|
|
|
24
28
|
components:
|
|
@@ -31,3 +35,12 @@ components:
|
|
|
31
35
|
NotFoundError: { $ref: './common/responses.yaml#/NotFoundError' }
|
|
32
36
|
ConflictError: { $ref: './common/responses.yaml#/ConflictError' }
|
|
33
37
|
UnprocessableEntityError: { $ref: './common/responses.yaml#/UnprocessableEntityError' }
|
|
38
|
+
UnauthorizedError: { $ref: './common/responses.yaml#/UnauthorizedError' }
|
|
39
|
+
ForbiddenError: { $ref: './common/responses.yaml#/ForbiddenError' }
|
|
40
|
+
TooManyRequestsError: { $ref: './common/responses.yaml#/TooManyRequestsError' }
|
|
41
|
+
securitySchemes:
|
|
42
|
+
bearerAuth:
|
|
43
|
+
type: http
|
|
44
|
+
scheme: bearer
|
|
45
|
+
bearerFormat: JWT
|
|
46
|
+
description: "access token from POST /auth/login or /auth/register, sent as `Authorization: Bearer <token>`"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- Graceful shutdown, structured logging, `/livez` + `/readyz`: always enabled
|
|
24
24
|
- Docker Compose (local Postgres): `{{#if docker}}enabled{{else}}disabled{{/if}}`
|
|
25
25
|
- OpenAPI docs (`docs/openapi.yaml`, whole `docs/` tree served at `/docs`): `{{#if openapiDocs}}enabled{{else}}disabled{{/if}}`
|
|
26
|
+
- Metrics + tracing (Prometheus `/metrics`, OpenTelemetry for Gin + GORM): `{{#if observability}}enabled{{else}}disabled{{/if}}`
|
|
26
27
|
- API route prefix: `{{#if apiPrefix}}/{{apiPrefix}}{{else}}(none){{/if}}`
|
|
27
28
|
- CI (`.github/workflows/ci.yml` — build, vet, gofmt check, golangci-lint, `go test` with a real Postgres service): always enabled
|
|
28
29
|
|
|
@@ -34,5 +35,7 @@
|
|
|
34
35
|
`false` in prod and run `migrate up` as a deploy step instead
|
|
35
36
|
{{#if openapiDocs}}- `docs/openapi.yaml` is hand-written, not generated — update it whenever an
|
|
36
37
|
endpoint or DTO changes
|
|
38
|
+
{{/if}}{{#if observability}}- `OTEL_EXPORTER_OTLP_ENDPOINT` unset (dev default) means tracing exports
|
|
39
|
+
nowhere — set it to a real OTLP/HTTP collector address to see traces
|
|
37
40
|
{{/if}}- Versions above are what `go-scaffold create` pinned in `go.mod` — bump them
|
|
38
41
|
by hand (`go get -u` + `go mod tidy`) as the stack evolves
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strconv"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/gin-gonic/gin"
|
|
8
|
+
"github.com/prometheus/client_golang/prometheus"
|
|
9
|
+
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
var (
|
|
13
|
+
httpRequestsTotal = promauto.NewCounterVec(
|
|
14
|
+
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests"},
|
|
15
|
+
[]string{"method", "path", "status"},
|
|
16
|
+
)
|
|
17
|
+
httpRequestDuration = promauto.NewHistogramVec(
|
|
18
|
+
prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request duration in seconds"},
|
|
19
|
+
[]string{"method", "path", "status"},
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// Metrics records request count and latency per route for GET /metrics to
|
|
24
|
+
// expose to Prometheus. Uses c.FullPath() (the route template, e.g.
|
|
25
|
+
// "/v1/invoices/:id") rather than the raw request path — a UUID or other
|
|
26
|
+
// path param would otherwise create one label series per distinct value,
|
|
27
|
+
// growing unbounded with traffic instead of staying one series per route.
|
|
28
|
+
func Metrics() gin.HandlerFunc {
|
|
29
|
+
return func(c *gin.Context) {
|
|
30
|
+
start := time.Now()
|
|
31
|
+
c.Next()
|
|
32
|
+
|
|
33
|
+
path := c.FullPath()
|
|
34
|
+
if path == "" {
|
|
35
|
+
path = "unmatched" // no route matched (404) — one shared label, not the raw unbounded path
|
|
36
|
+
}
|
|
37
|
+
status := strconv.Itoa(c.Writer.Status())
|
|
38
|
+
httpRequestsTotal.WithLabelValues(c.Request.Method, path, status).Inc()
|
|
39
|
+
httpRequestDuration.WithLabelValues(c.Request.Method, path, status).Observe(time.Since(start).Seconds())
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/gin-gonic/gin"
|
|
5
|
+
"go.opentelemetry.io/otel"
|
|
6
|
+
"go.opentelemetry.io/otel/attribute"
|
|
7
|
+
"go.opentelemetry.io/otel/codes"
|
|
8
|
+
"go.opentelemetry.io/otel/propagation"
|
|
9
|
+
"go.opentelemetry.io/otel/trace"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Tracing starts one span per request, propagating an incoming W3C
|
|
13
|
+
// traceparent header (if any) so a request already being traced upstream
|
|
14
|
+
// stays part of the same trace, not a new root.
|
|
15
|
+
//
|
|
16
|
+
// Hand-rolled instead of the official otelgin contrib package: pulling it in
|
|
17
|
+
// forces this project's gin dependency to a newer minimum version than the
|
|
18
|
+
// one this scaffold pins, which itself drags in HTTP/3 (quic-go) — weight
|
|
19
|
+
// unrelated to tracing. Gin's middleware surface (Next/Writer.Status/Errors)
|
|
20
|
+
// is small enough that duplicating just the part actually used isn't worth
|
|
21
|
+
// that.
|
|
22
|
+
func Tracing(serviceName string) gin.HandlerFunc {
|
|
23
|
+
tracer := otel.Tracer(serviceName)
|
|
24
|
+
return func(c *gin.Context) {
|
|
25
|
+
ctx := otel.GetTextMapPropagator().Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header))
|
|
26
|
+
|
|
27
|
+
spanName := c.FullPath()
|
|
28
|
+
if spanName == "" {
|
|
29
|
+
spanName = c.Request.URL.Path // no route matched (404) — still traced, just without a route template
|
|
30
|
+
}
|
|
31
|
+
ctx, span := tracer.Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindServer))
|
|
32
|
+
defer span.End()
|
|
33
|
+
c.Request = c.Request.WithContext(ctx)
|
|
34
|
+
|
|
35
|
+
c.Next()
|
|
36
|
+
|
|
37
|
+
span.SetAttributes(
|
|
38
|
+
attribute.String("http.method", c.Request.Method),
|
|
39
|
+
attribute.Int("http.status_code", c.Writer.Status()),
|
|
40
|
+
)
|
|
41
|
+
if len(c.Errors) > 0 {
|
|
42
|
+
span.RecordError(c.Errors.Last())
|
|
43
|
+
span.SetStatus(codes.Error, c.Errors.String())
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Package telemetry wires OpenTelemetry tracing — it talks to a real
|
|
2
|
+
// external system (the OTLP collector), so it lives in platform/, not
|
|
3
|
+
// shared/, same split rule as internal/platform/database.
|
|
4
|
+
package telemetry
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"context"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"go.opentelemetry.io/otel"
|
|
11
|
+
"go.opentelemetry.io/otel/attribute"
|
|
12
|
+
"go.opentelemetry.io/otel/codes"
|
|
13
|
+
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
|
14
|
+
"go.opentelemetry.io/otel/propagation"
|
|
15
|
+
"go.opentelemetry.io/otel/sdk/resource"
|
|
16
|
+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
|
17
|
+
"go.opentelemetry.io/otel/trace"
|
|
18
|
+
"gorm.io/gorm"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// Init wires a global TracerProvider that exports spans via OTLP/HTTP to
|
|
22
|
+
// endpoint. An empty endpoint (the default — see OTEL_EXPORTER_OTLP_ENDPOINT
|
|
23
|
+
// in .env.example) skips exporter setup entirely and returns a no-op
|
|
24
|
+
// shutdown: tracing quietly does nothing instead of trying to dial a
|
|
25
|
+
// collector that isn't there, same "off unless configured" shape as the
|
|
26
|
+
// rest of this app's optional integrations.
|
|
27
|
+
func Init(ctx context.Context, serviceName, endpoint string) (shutdown func(context.Context) error, err error) {
|
|
28
|
+
noop := func(context.Context) error { return nil }
|
|
29
|
+
if endpoint == "" {
|
|
30
|
+
return noop, nil
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
exporter, err := otlptracehttp.New(ctx, otlptracehttp.WithEndpoint(endpoint), otlptracehttp.WithInsecure())
|
|
34
|
+
if err != nil {
|
|
35
|
+
return noop, err
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
res, err := resource.New(ctx, resource.WithAttributes(attribute.String("service.name", serviceName)))
|
|
39
|
+
if err != nil {
|
|
40
|
+
return noop, err
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter), sdktrace.WithResource(res))
|
|
44
|
+
otel.SetTracerProvider(tp)
|
|
45
|
+
otel.SetTextMapPropagator(propagation.TraceContext{})
|
|
46
|
+
|
|
47
|
+
return func(shutdownCtx context.Context) error {
|
|
48
|
+
ctx, cancel := context.WithTimeout(shutdownCtx, 5*time.Second)
|
|
49
|
+
defer cancel()
|
|
50
|
+
return tp.Shutdown(ctx)
|
|
51
|
+
}, nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tracerName = "gorm"
|
|
55
|
+
|
|
56
|
+
// gormPlugin wraps every GORM operation (query, create, update, delete, row,
|
|
57
|
+
// raw) in a span — a hand-rolled GORM callback plugin instead of
|
|
58
|
+
// gorm.io/plugin/opentelemetry: that module's go.mod pulls in every driver
|
|
59
|
+
// it supports tracing for (MySQL, ClickHouse, MongoDB) plus their own
|
|
60
|
+
// transitive trees (gRPC, QUIC, ...) as indirect requires, even though this
|
|
61
|
+
// project only ever uses Postgres. GORM's callback API is small enough that
|
|
62
|
+
// replicating just "start a span before, end it after, record the error if
|
|
63
|
+
// any" isn't worth that weight.
|
|
64
|
+
type gormPlugin struct{}
|
|
65
|
+
|
|
66
|
+
// NewGormPlugin returns a gorm.Plugin — wire it in via db.Use(...).
|
|
67
|
+
func NewGormPlugin() gorm.Plugin { return gormPlugin{} }
|
|
68
|
+
|
|
69
|
+
func (gormPlugin) Name() string { return "otel-tracing" }
|
|
70
|
+
|
|
71
|
+
// Initialize registers a before/after pair on each of GORM's six operation
|
|
72
|
+
// callbacks (create/query/update/delete/row/raw) — spelled out individually,
|
|
73
|
+
// not looped, because db.Callback().Create() etc. return GORM's unexported
|
|
74
|
+
// *processor type: a package outside gorm can chain its methods but can't
|
|
75
|
+
// name the type, so it can't be held in a slice/struct field to loop over.
|
|
76
|
+
func (gormPlugin) Initialize(db *gorm.DB) error {
|
|
77
|
+
before := func(op string) func(*gorm.DB) {
|
|
78
|
+
return func(tx *gorm.DB) {
|
|
79
|
+
ctx, span := otel.Tracer(tracerName).Start(tx.Statement.Context, "gorm."+op)
|
|
80
|
+
span.SetAttributes(attribute.String("db.sql.table", tx.Statement.Table))
|
|
81
|
+
tx.Statement.Context = ctx
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
after := func(tx *gorm.DB) {
|
|
85
|
+
span := trace.SpanFromContext(tx.Statement.Context)
|
|
86
|
+
if tx.Error != nil {
|
|
87
|
+
span.RecordError(tx.Error)
|
|
88
|
+
span.SetStatus(codes.Error, tx.Error.Error())
|
|
89
|
+
}
|
|
90
|
+
span.End()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if err := db.Callback().Create().Before("gorm:create").Register("otel:before_create", before("create")); err != nil {
|
|
94
|
+
return err
|
|
95
|
+
}
|
|
96
|
+
if err := db.Callback().Create().After("gorm:create").Register("otel:after_create", after); err != nil {
|
|
97
|
+
return err
|
|
98
|
+
}
|
|
99
|
+
if err := db.Callback().Query().Before("gorm:query").Register("otel:before_query", before("query")); err != nil {
|
|
100
|
+
return err
|
|
101
|
+
}
|
|
102
|
+
if err := db.Callback().Query().After("gorm:query").Register("otel:after_query", after); err != nil {
|
|
103
|
+
return err
|
|
104
|
+
}
|
|
105
|
+
if err := db.Callback().Update().Before("gorm:update").Register("otel:before_update", before("update")); err != nil {
|
|
106
|
+
return err
|
|
107
|
+
}
|
|
108
|
+
if err := db.Callback().Update().After("gorm:update").Register("otel:after_update", after); err != nil {
|
|
109
|
+
return err
|
|
110
|
+
}
|
|
111
|
+
if err := db.Callback().Delete().Before("gorm:delete").Register("otel:before_delete", before("delete")); err != nil {
|
|
112
|
+
return err
|
|
113
|
+
}
|
|
114
|
+
if err := db.Callback().Delete().After("gorm:delete").Register("otel:after_delete", after); err != nil {
|
|
115
|
+
return err
|
|
116
|
+
}
|
|
117
|
+
if err := db.Callback().Row().Before("gorm:row").Register("otel:before_row", before("row")); err != nil {
|
|
118
|
+
return err
|
|
119
|
+
}
|
|
120
|
+
if err := db.Callback().Row().After("gorm:row").Register("otel:after_row", after); err != nil {
|
|
121
|
+
return err
|
|
122
|
+
}
|
|
123
|
+
if err := db.Callback().Raw().Before("gorm:raw").Register("otel:before_raw", before("raw")); err != nil {
|
|
124
|
+
return err
|
|
125
|
+
}
|
|
126
|
+
if err := db.Callback().Raw().After("gorm:raw").Register("otel:after_raw", after); err != nil {
|
|
127
|
+
return err
|
|
128
|
+
}
|
|
129
|
+
return nil
|
|
130
|
+
}
|
|
@@ -4,6 +4,9 @@ import (
|
|
|
4
4
|
"net/http"
|
|
5
5
|
|
|
6
6
|
"{{goModule}}/internal/shared/httpx"
|
|
7
|
+
{{#if auth}}
|
|
8
|
+
"{{goModule}}/internal/shared/middleware"
|
|
9
|
+
{{/if}}
|
|
7
10
|
"{{goModule}}/internal/shared/pagination"
|
|
8
11
|
|
|
9
12
|
"github.com/gin-gonic/gin"
|
|
@@ -12,15 +15,29 @@ import (
|
|
|
12
15
|
// Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
|
|
13
16
|
type Handler struct {
|
|
14
17
|
svc *Service
|
|
18
|
+
{{#if auth}}
|
|
19
|
+
jwtSecret string
|
|
20
|
+
{{/if}}
|
|
21
|
+
{{#if permission}}
|
|
22
|
+
authz *middleware.Authz
|
|
23
|
+
{{/if}}
|
|
15
24
|
}
|
|
16
25
|
|
|
17
|
-
func NewHandler(svc *Service) *Handler {
|
|
18
|
-
return &Handler{
|
|
26
|
+
func NewHandler(svc *Service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
|
|
27
|
+
return &Handler{
|
|
28
|
+
svc: svc,
|
|
29
|
+
{{#if auth}}
|
|
30
|
+
jwtSecret: jwtSecret,
|
|
31
|
+
{{/if}}
|
|
32
|
+
{{#if permission}}
|
|
33
|
+
authz: authz,
|
|
34
|
+
{{/if}}
|
|
35
|
+
}
|
|
19
36
|
}
|
|
20
37
|
|
|
21
38
|
// Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
|
|
22
39
|
func (h *Handler) Register(rg gin.IRouter) {
|
|
23
|
-
g := rg.Group("/{{plural}}")
|
|
40
|
+
g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
|
|
24
41
|
g.POST("", h.create)
|
|
25
42
|
g.GET("", h.list)
|
|
26
43
|
g.GET("/:id", h.get)
|