@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.
Files changed (108) hide show
  1. package/README.md +75 -0
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +59 -1
  5. package/dist/commands/method.js +3 -0
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +19 -2
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +66 -3
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/rbac-manifest.js +17 -0
  16. package/dist/templates/worker-manifest.js +12 -0
  17. package/dist/utils/auth-patcher.js +96 -0
  18. package/dist/utils/gocheck.js +65 -0
  19. package/dist/utils/main-patcher.js +8 -1
  20. package/dist/utils/migrations.js +30 -8
  21. package/dist/utils/openapi-patcher.js +16 -0
  22. package/dist/utils/platform-patcher.js +59 -0
  23. package/dist/utils/rbac-patcher.js +277 -0
  24. package/dist/utils/version.js +24 -0
  25. package/package.json +2 -2
  26. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  27. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  28. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  29. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  30. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  31. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  32. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  33. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  35. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  36. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  37. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  38. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  39. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  40. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  41. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  42. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  43. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  44. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  45. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  46. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  50. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  52. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  54. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  55. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  56. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  57. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  58. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  59. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  60. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  61. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  62. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  63. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  64. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  65. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  66. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  67. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  68. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  69. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  70. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  71. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  72. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  73. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  74. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  75. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  76. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  77. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  78. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  79. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  80. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  81. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  82. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  83. package/templates/create/base/.env.example.hbs +20 -0
  84. package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
  85. package/templates/create/base/.gitignore.hbs +2 -0
  86. package/templates/create/base/Makefile.hbs +30 -5
  87. package/templates/create/base/README.md.hbs +36 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  89. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  90. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  91. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  92. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  93. package/templates/create/base/migrations/embed.go.hbs +15 -0
  94. package/templates/create/features/docs/architecture.md.hbs +22 -0
  95. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  96. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  97. package/templates/create/features/docs/openapi.yaml.hbs +13 -0
  98. package/templates/create/features/docs/techstack.md.hbs +3 -0
  99. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  100. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  101. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  102. package/templates/generate/module/handler.go.hbs +20 -3
  103. package/templates/generate/module/handler_test.go.hbs +49 -6
  104. package/templates/generate/module/minimal/handler.go.hbs +21 -3
  105. package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
  106. package/templates/generate/module/permission.down.sql.hbs +5 -0
  107. package/templates/generate/module/permission.up.sql.hbs +4 -0
  108. 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
- return Config{
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
- func Error() gin.HandlerFunc {
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{svc: svc}
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)