@nakedev/go-scaffold 0.1.2 → 0.1.4

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 (131) hide show
  1. package/README.md +93 -14
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +61 -2
  5. package/dist/commands/method.js +66 -15
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +36 -20
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +71 -7
  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/module-manifest.js +2 -0
  16. package/dist/templates/rbac-manifest.js +17 -0
  17. package/dist/templates/worker-manifest.js +12 -0
  18. package/dist/utils/auth-patcher.js +96 -0
  19. package/dist/utils/gocheck.js +65 -0
  20. package/dist/utils/main-patcher.js +8 -1
  21. package/dist/utils/method-patcher.js +80 -16
  22. package/dist/utils/migrations.js +30 -8
  23. package/dist/utils/module-location.js +22 -0
  24. package/dist/utils/naming.js +75 -12
  25. package/dist/utils/openapi-patcher.js +35 -1
  26. package/dist/utils/platform-patcher.js +59 -0
  27. package/dist/utils/rbac-patcher.js +277 -0
  28. package/dist/utils/smoke-run.js +31 -0
  29. package/dist/utils/version.js +24 -0
  30. package/package.json +15 -6
  31. package/scripts/smoke-test.mjs +2058 -0
  32. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  33. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  35. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  36. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  38. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  39. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  40. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  41. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  42. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  43. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  44. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  45. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  46. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  47. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  48. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  49. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  50. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  51. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  52. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  53. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  54. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  55. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  56. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  57. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  58. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  59. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  60. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  61. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  62. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  63. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  64. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  65. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  66. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  67. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  68. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  69. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  70. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  71. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  72. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  73. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  74. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  75. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  76. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  77. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  78. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  79. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  80. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  81. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  82. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  83. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  84. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  85. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  86. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  87. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  88. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  89. package/templates/create/base/.env.example.hbs +20 -0
  90. package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
  91. package/templates/create/base/.gitignore.hbs +2 -0
  92. package/templates/create/base/AGENTS.md.hbs +10 -12
  93. package/templates/create/base/Makefile.hbs +39 -8
  94. package/templates/create/base/README.md.hbs +52 -12
  95. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  96. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  97. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  98. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  99. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  100. package/templates/create/base/migrations/embed.go.hbs +15 -0
  101. package/templates/create/features/docs/architecture.md.hbs +22 -0
  102. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  103. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  104. package/templates/create/features/docs/openapi.yaml.hbs +17 -5
  105. package/templates/create/features/docs/patterns.md.hbs +9 -6
  106. package/templates/create/features/docs/techstack.md.hbs +3 -0
  107. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  108. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  109. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  110. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  111. package/templates/generate/module/handler.go.hbs +37 -6
  112. package/templates/generate/module/handler_test.go.hbs +113 -51
  113. package/templates/generate/module/migration.down.sql.hbs +1 -1
  114. package/templates/generate/module/migration.up.sql.hbs +1 -1
  115. package/templates/generate/module/minimal/handler.go.hbs +28 -4
  116. package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
  117. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  118. package/templates/generate/module/model/model.go.hbs +7 -0
  119. package/templates/generate/module/permission.down.sql.hbs +5 -0
  120. package/templates/generate/module/permission.up.sql.hbs +4 -0
  121. package/templates/generate/module/repository_test.go.hbs +79 -0
  122. package/templates/generate/module/service.go.hbs +6 -4
  123. package/templates/generate/module/service_test.go.hbs +68 -17
  124. package/tests/integration/default-module.test.mjs +46 -0
  125. package/tests/integration/generator-naming.test.mjs +81 -0
  126. package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
  127. package/tests/integration/legacy-method-compat.test.mjs +222 -0
  128. package/tests/integration/remove-module.test.mjs +58 -0
  129. package/tests/unit/naming.test.mjs +94 -0
  130. package/tests/unit/smoke-isolation.test.mjs +35 -0
  131. package/dist/utils/module-paths.js +0 -33
@@ -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 }
@@ -1,11 +1,10 @@
1
1
  # ponytail: hand-written, not generated from annotations — cheap while the
2
- # endpoint count is low. Ceiling: must be updated by hand whenever an
3
- # endpoint/DTO changes beyond what `generate module` scaffolds.
2
+ # endpoint count is low. Generated module/method stubs keep the index wired,
3
+ # but request/response schemas still need to be completed with the code.
4
4
  # Switch to swaggo (comment-generated) if drift becomes a recurring problem.
5
5
  # multi-file: split by domain module (health/<domain>) + common/ for shared
6
- # pieces — this file is just the index. `generate module <name>`
7
- # adds its paths/schemas here automatically; `generate method` does not
8
- # (endpoint-specific docs stay hand-written).
6
+ # pieces — this file is just the index. `generate module` and `generate method`
7
+ # add path entries automatically.
9
8
  openapi: 3.0.3
10
9
  info:
11
10
  title: {{projectName}} API
@@ -19,6 +18,10 @@ paths:
19
18
  $ref: './health/health-livez.yaml'
20
19
  /readyz:
21
20
  $ref: './health/health-readyz.yaml'
21
+ {{#if observability}}
22
+ /metrics:
23
+ $ref: './observability/metrics.yaml'
24
+ {{/if}}
22
25
  # go-scaffold:paths
23
26
 
24
27
  components:
@@ -31,3 +34,12 @@ components:
31
34
  NotFoundError: { $ref: './common/responses.yaml#/NotFoundError' }
32
35
  ConflictError: { $ref: './common/responses.yaml#/ConflictError' }
33
36
  UnprocessableEntityError: { $ref: './common/responses.yaml#/UnprocessableEntityError' }
37
+ UnauthorizedError: { $ref: './common/responses.yaml#/UnauthorizedError' }
38
+ ForbiddenError: { $ref: './common/responses.yaml#/ForbiddenError' }
39
+ TooManyRequestsError: { $ref: './common/responses.yaml#/TooManyRequestsError' }
40
+ securitySchemes:
41
+ bearerAuth:
42
+ type: http
43
+ scheme: bearer
44
+ bearerFormat: JWT
45
+ description: "access token from POST /auth/login or /auth/register, sent as `Authorization: Bearer <token>`"
@@ -17,8 +17,9 @@ internal/app/<domain>/
17
17
  ├── repository.go # the only place that touches the DB for this domain, every method takes ctx
18
18
  ├── service.go # business logic; declares the repository interface it needs (mockable in tests)
19
19
  ├── handler.go # HTTP: routing, bind, delegate, respond
20
- ├── service_test.go # unit test, fake repo, no DB
21
- └── handler_test.go # integration test, real Postgres, transaction rolled back per test
20
+ ├── service_test.go # unit test, function-backed repository stub, no DB
21
+ ├── handler_test.go # HTTP unit test, service stub, no DB
22
+ └── repository_test.go # Postgres integration test against migrated schema
22
23
  ```
23
24
 
24
25
  ### Model
@@ -137,10 +138,12 @@ placeholder fields.
137
138
  - Unit and integration tests live in the same directory as the code under
138
139
  test (Go convention) — never a separate `test/` folder. `test/` is only
139
140
  for e2e black-box suites or fixtures.
140
- - `service_test.go` uses a fake repository no DB required, always runs.
141
- - `handler_test.go` runs against a real Postgres instance inside a
142
- transaction that's rolled back after each test skips automatically if
143
- the DB isn't reachable.
141
+ - `service_test.go` uses a function-backed repository stub so each dependency
142
+ method has independent behavior and argument assertions.
143
+ - `handler_test.go` uses a service stubHTTP tests never need Postgres.
144
+ - `repository_test.go` runs against a migrated Postgres database inside a
145
+ transaction that's rolled back after each test. Local runs may skip when
146
+ `TEST_DB_DSN` is unset; CI sets `REQUIRE_TEST_DB=true` so it must run.
144
147
 
145
148
  ## Docs Maintenance
146
149
 
@@ -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
+ }
@@ -29,9 +29,9 @@ put:
29
29
  "400": { $ref: '../common/responses.yaml#/ValidationError' }
30
30
  "404": { $ref: '../common/responses.yaml#/NotFoundError' }
31
31
  delete:
32
- summary: Delete {{pkg}}
32
+ summary: Delete {{name}}
33
+ description: Idempotent — deleting an already-missing resource also returns 204.
33
34
  operationId: delete{{pascalName}}
34
35
  tags: [{{plural}}]
35
36
  responses:
36
- "204": { description: deleted }
37
- "404": { $ref: '../common/responses.yaml#/NotFoundError' }
37
+ "204": { description: deleted or already absent }
@@ -1,26 +1,57 @@
1
1
  package {{pkg}}
2
2
 
3
3
  import (
4
+ "context"
4
5
  "net/http"
5
6
 
7
+ "{{goModule}}/internal/app/{{modulePath}}/model"
6
8
  "{{goModule}}/internal/shared/httpx"
9
+ {{#if auth}}
10
+ "{{goModule}}/internal/shared/middleware"
11
+ {{/if}}
7
12
  "{{goModule}}/internal/shared/pagination"
8
13
 
9
14
  "github.com/gin-gonic/gin"
15
+ "github.com/google/uuid"
10
16
  )
11
17
 
18
+ // service is the narrow application API required by this HTTP adapter. Keeping
19
+ // the dependency as an interface makes handler tests fast and database-free.
20
+ type service interface {
21
+ Create(context.Context, createInput) (*model.{{pascalName}}, error)
22
+ List(context.Context, int, int) ([]model.{{pascalName}}, error)
23
+ Get(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
24
+ Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
25
+ Delete(context.Context, uuid.UUID) error
26
+ // go-scaffold:service-interface
27
+ }
28
+
12
29
  // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
13
30
  type Handler struct {
14
- svc *Service
31
+ svc service
32
+ {{#if auth}}
33
+ jwtSecret string
34
+ {{/if}}
35
+ {{#if permission}}
36
+ authz *middleware.Authz
37
+ {{/if}}
15
38
  }
16
39
 
17
- func NewHandler(svc *Service) *Handler {
18
- return &Handler{svc: svc}
40
+ func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
41
+ return &Handler{
42
+ svc: svc,
43
+ {{#if auth}}
44
+ jwtSecret: jwtSecret,
45
+ {{/if}}
46
+ {{#if permission}}
47
+ authz: authz,
48
+ {{/if}}
49
+ }
19
50
  }
20
51
 
21
52
  // Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
22
53
  func (h *Handler) Register(rg gin.IRouter) {
23
- g := rg.Group("/{{plural}}")
54
+ g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
24
55
  g.POST("", h.create)
25
56
  g.GET("", h.list)
26
57
  g.GET("/:id", h.get)
@@ -35,7 +66,7 @@ func (h *Handler) create(c *gin.Context) {
35
66
  c.Error(httpx.BindErr(err))
36
67
  return
37
68
  }
38
- m, err := h.svc.Create(c.Request.Context())
69
+ m, err := h.svc.Create(c.Request.Context(), in)
39
70
  if err != nil {
40
71
  c.Error(err)
41
72
  return
@@ -80,7 +111,7 @@ func (h *Handler) update(c *gin.Context) {
80
111
  c.Error(httpx.BindErr(err))
81
112
  return
82
113
  }
83
- m, err := h.svc.Update(c.Request.Context(), id)
114
+ m, err := h.svc.Update(c.Request.Context(), id, in)
84
115
  if err != nil {
85
116
  c.Error(err)
86
117
  return
@@ -2,103 +2,165 @@ package {{pkg}}
2
2
 
3
3
  import (
4
4
  "bytes"
5
- "encoding/json"
5
+ "context"
6
6
  "net/http"
7
7
  "net/http/httptest"
8
- "os"
9
- "sync"
10
8
  "testing"
9
+ {{#if auth}}
10
+ "time"
11
+ {{/if}}
11
12
 
12
13
  "{{goModule}}/internal/app/{{modulePath}}/model"
13
14
  "{{goModule}}/internal/shared/middleware"
14
15
 
15
16
  "github.com/gin-gonic/gin"
17
+ {{#if auth}}
18
+ "github.com/golang-jwt/jwt/v5"
19
+ {{/if}}
16
20
  "github.com/google/uuid"
17
- "gorm.io/driver/postgres"
18
- "gorm.io/gorm"
19
21
  )
20
22
 
21
- // integration test backed by real Postgres (same engine as prod, no sqlite) — skips if the DB isn't reachable
22
- // start the DB: docker compose up -d (override with TEST_DB_DSN)
23
- var (
24
- testDBOnce sync.Once
25
- testDB *gorm.DB
26
- testDBErr error
27
- )
23
+ // serviceStub keeps handler tests at the HTTP boundary. It exercises binding,
24
+ // routing, middleware, status codes, and serialization without a database.
25
+ type serviceStub struct {
26
+ // Embedding keeps this stub source-compatible when `generate method` adds a
27
+ // new operation to the handler's service interface. Base CRUD methods below
28
+ // still override the promoted concrete methods for focused unit tests.
29
+ *Service
30
+ createFn func(context.Context, createInput) (*model.{{pascalName}}, error)
31
+ listFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
32
+ getFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
33
+ updateFn func(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
34
+ deleteFn func(context.Context, uuid.UUID) error
35
+ }
28
36
 
29
- func dbForTest(t *testing.T) *gorm.DB {
30
- t.Helper()
31
- testDBOnce.Do(func() {
32
- dsn := os.Getenv("TEST_DB_DSN")
33
- if dsn == "" {
34
- dsn = "postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable"
35
- }
36
- if testDB, testDBErr = gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}); testDBErr == nil {
37
- // drop first — AutoMigrate can't change an existing column's type, always start from a fresh schema
38
- _ = testDB.Migrator().DropTable(&model.{{pascalName}}{})
39
- testDBErr = testDB.AutoMigrate(&model.{{pascalName}}{})
40
- }
41
- })
42
- if testDBErr != nil {
43
- t.Skipf("postgres not ready (docker compose up -d, or set TEST_DB_DSN): %v", testDBErr)
37
+ func (s *serviceStub) Create(ctx context.Context, in createInput) (*model.{{pascalName}}, error) {
38
+ if s.createFn == nil {
39
+ panic("unexpected service.Create call")
40
+ }
41
+ return s.createFn(ctx, in)
42
+ }
43
+
44
+ func (s *serviceStub) List(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
45
+ if s.listFn == nil {
46
+ panic("unexpected service.List call")
47
+ }
48
+ return s.listFn(ctx, limit, offset)
49
+ }
50
+
51
+ func (s *serviceStub) Get(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
52
+ if s.getFn == nil {
53
+ panic("unexpected service.Get call")
54
+ }
55
+ return s.getFn(ctx, id)
56
+ }
57
+
58
+ func (s *serviceStub) Update(ctx context.Context, id uuid.UUID, in updateInput) (*model.{{pascalName}}, error) {
59
+ if s.updateFn == nil {
60
+ panic("unexpected service.Update call")
44
61
  }
45
- return testDB
62
+ return s.updateFn(ctx, id, in)
46
63
  }
47
64
 
48
- // setup builds the full stack on a transaction that's rolled back at the end each test is isolated, no leftover rows
49
- func setup(t *testing.T) *gin.Engine {
65
+ func (s *serviceStub) Delete(ctx context.Context, id uuid.UUID) error {
66
+ if s.deleteFn == nil {
67
+ panic("unexpected service.Delete call")
68
+ }
69
+ return s.deleteFn(ctx, id)
70
+ }
71
+
72
+ // go-scaffold:service-stub-methods
73
+
74
+ func setupHandlerTest(t *testing.T, svc service) *gin.Engine {
50
75
  t.Helper()
51
76
  gin.SetMode(gin.TestMode)
52
- tx := dbForTest(t).Begin()
53
- t.Cleanup(func() { tx.Rollback() })
54
77
  r := gin.New()
55
- r.Use(middleware.RequestID(), middleware.Error())
56
- NewHandler(NewService(NewRepository(tx))).Register(r)
78
+ r.Use(middleware.RequestID(), middleware.Error(true))
79
+ {{#if permission}}
80
+ // The handler unit test verifies route composition, not the role repository.
81
+ authz := middleware.NewAuthz(func(_ context.Context, _ string) (map[string]struct{}, error) {
82
+ return map[string]struct{}{"{{permission}}": {}}, nil
83
+ }, time.Minute)
84
+ {{/if}}
85
+ NewHandler(svc{{#if auth}}, testJWTSecret{{/if}}{{#if permission}}, authz{{/if}}).Register(r)
57
86
  return r
58
87
  }
59
88
 
60
- func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
61
- req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
89
+ {{#if auth}}
90
+ const testJWTSecret = "test-secret"
91
+
92
+ func authHeader() string {
93
+ claims := jwt.MapClaims{
94
+ "typ": "access",
95
+ "sub": uuid.NewString(),
96
+ "role": "staff",
97
+ "exp": time.Now().Add(time.Hour).Unix(),
98
+ "iat": time.Now().Unix(),
99
+ }
100
+ tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testJWTSecret))
101
+ return "Bearer " + tok
102
+ }
103
+
104
+ {{/if}}
105
+ func doHandlerRequest(r *gin.Engine, method, requestPath, body string) *httptest.ResponseRecorder {
106
+ req := httptest.NewRequest(method, requestPath, bytes.NewBufferString(body))
62
107
  req.Header.Set("Content-Type", "application/json")
108
+ {{#if auth}}
109
+ req.Header.Set("Authorization", authHeader())
110
+ {{/if}}
63
111
  w := httptest.NewRecorder()
64
112
  r.ServeHTTP(w, req)
65
113
  return w
66
114
  }
67
115
 
68
116
  func TestHandler_Create_OK(t *testing.T) {
69
- r := setup(t)
70
- w := do(r, http.MethodPost, "/{{plural}}", `{}`)
117
+ svc := &serviceStub{
118
+ createFn: func(context.Context, createInput) (*model.{{pascalName}}, error) {
119
+ return &model.{{pascalName}}{ID: uuid.New()}, nil
120
+ },
121
+ }
122
+ r := setupHandlerTest(t, svc)
123
+
124
+ w := doHandlerRequest(r, http.MethodPost, "/{{plural}}", `{}`)
125
+
71
126
  if w.Code != http.StatusCreated {
72
127
  t.Fatalf("want 201, got %d body=%s", w.Code, w.Body)
73
128
  }
74
129
  }
75
130
 
76
131
  func TestHandler_Get_NotFound(t *testing.T) {
77
- r := setup(t)
78
- w := do(r, http.MethodGet, "/{{plural}}/"+uuid.NewString(), "")
132
+ svc := &serviceStub{
133
+ getFn: func(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
134
+ return nil, errNotFound()
135
+ },
136
+ }
137
+ r := setupHandlerTest(t, svc)
138
+
139
+ w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/"+uuid.NewString(), "")
140
+
79
141
  if w.Code != http.StatusNotFound {
80
142
  t.Fatalf("want 404, got %d body=%s", w.Code, w.Body)
81
143
  }
82
144
  }
83
145
 
84
- func TestHandler_Get_InvalidID(t *testing.T) {
85
- r := setup(t)
86
- w := do(r, http.MethodGet, "/{{plural}}/not-a-uuid", "")
146
+ func TestHandler_Get_InvalidID_DoesNotCallService(t *testing.T) {
147
+ r := setupHandlerTest(t, &serviceStub{})
148
+
149
+ w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/not-a-uuid", "")
150
+
87
151
  if w.Code != http.StatusBadRequest {
88
152
  t.Fatalf("want 400, got %d body=%s", w.Code, w.Body)
89
153
  }
90
154
  }
91
155
 
92
156
  func TestHandler_Delete_OK(t *testing.T) {
93
- r := setup(t)
94
- created := do(r, http.MethodPost, "/{{plural}}", `{}`)
95
- var body struct {
96
- ID string `json:"id"`
157
+ svc := &serviceStub{
158
+ deleteFn: func(context.Context, uuid.UUID) error { return nil },
97
159
  }
98
- if err := json.Unmarshal(created.Body.Bytes(), &body); err != nil {
99
- t.Fatalf("decode create response: %v", err)
100
- }
101
- w := do(r, http.MethodDelete, "/{{plural}}/"+body.ID, "")
160
+ r := setupHandlerTest(t, svc)
161
+
162
+ w := doHandlerRequest(r, http.MethodDelete, "/{{plural}}/"+uuid.NewString(), "")
163
+
102
164
  if w.Code != http.StatusNoContent {
103
165
  t.Fatalf("want 204, got %d body=%s", w.Code, w.Body)
104
166
  }
@@ -1 +1 @@
1
- DROP TABLE IF EXISTS {{plural}};
1
+ DROP TABLE IF EXISTS {{tableName}};
@@ -1,4 +1,4 @@
1
- CREATE TABLE {{plural}} (
1
+ CREATE TABLE {{tableName}} (
2
2
  id UUID PRIMARY KEY,
3
3
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
4
4
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()