@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
@@ -14,7 +14,9 @@ jobs:
14
14
  env:
15
15
  POSTGRES_USER: postgres
16
16
  POSTGRES_PASSWORD: postgres
17
- POSTGRES_DB: {{dbName}}
17
+ # repository integration tests run against this isolated database after
18
+ # applying the same versioned SQL migrations used in production
19
+ POSTGRES_DB: {{dbName}}_test
18
20
  ports:
19
21
  - 5432:5432
20
22
  options: >-
@@ -41,6 +43,19 @@ jobs:
41
43
  with:
42
44
  version: latest
43
45
 
44
- # TEST_DB_DSN not set: the default in handler_test.go already points at
45
- # localhost:5432/{{dbName}} with postgres/postgres, matching the service above
46
- - run: go test ./...
46
+ - name: Install migration runner
47
+ run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.3
48
+
49
+ - name: Apply production migrations to the test database
50
+ env:
51
+ DB_DSN: postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
52
+ run: |
53
+ if compgen -G "migrations/*.up.sql" > /dev/null; then
54
+ migrate -path migrations -database "$DB_DSN" up
55
+ fi
56
+
57
+ - name: Test (required PostgreSQL integration tests cannot skip)
58
+ env:
59
+ TEST_DB_DSN: postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
60
+ REQUIRE_TEST_DB: "true"
61
+ run: go test ./...
@@ -1,5 +1,7 @@
1
1
  app.db
2
2
  *.db
3
3
  .env
4
+ .env.*
5
+ !.env.example
4
6
  bin/
5
7
  docs/openapi.bundled.yaml
@@ -45,21 +45,19 @@ hand-rolling anything that looks like scaffolding.
45
45
 
46
46
  ## Command quick reference
47
47
 
48
- - `go-scaffold generate module <name>` — model + dto + errors + repository +
49
- service + handler + tests, wired into `cmd/api/main.go` (AutoMigrate +
50
- route registration) and appended to `migrations/`. Use `--no-full` for a
51
- bare skeleton (no default CRUD/routes) when you'd rather add endpoints one
52
- at a time with `generate method`
48
+ - `go-scaffold generate module <name>` — safe minimal model + errors +
49
+ repository + service/handler plumbing, wired into `cmd/api/main.go` and
50
+ appended to `migrations/`. Add endpoints one at a time with `generate
51
+ method`, or pass `--full` to opt into a CRUD skeleton with TODO DTO fields
53
52
  - `go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]` —
54
53
  patches an *existing* module's handler + service (and repository, for a
55
54
  `get --get-mode one --field` lookup) in place; never overwrites a method
56
- with the same name — pick a different one if it collides. Endpoint docs in
57
- `docs/openapi.yaml` are **not** auto-updated for methods the command
58
- prints the route so you can add the spec entry by hand
59
- - `go-scaffold remove module <name>` — the inverse of `generate module`:
60
- deletes the package and un-wires main.go / openapi.yaml / migrations. Use
61
- this to drop a domain instead of hand-deleting the folder (a partial
62
- hand-delete leaves duplicate wiring behind)
55
+ with the same name — pick a different one if it collides. With OpenAPI
56
+ enabled it also writes a valid TODO path document and wires the index;
57
+ replace placeholder schemas while implementing the method
58
+ - `go-scaffold remove module <name>` — deletes the package and un-wires
59
+ main.go/OpenAPI while preserving immutable migrations and table data. Use
60
+ `generate migration drop_<table>` for an explicit reviewed data removal
63
61
  - Every route in this project is grouped under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
64
62
  (set once at `create` time via `--api-prefix`) — there is no per-domain
65
63
  versioning; a breaking API change gets a new domain package or a new DTO
@@ -3,17 +3,34 @@ DB_PORT ?= 5432
3
3
  DB_USER ?= postgres
4
4
  DB_NAME ?= {{dbName}}
5
5
  PGPASSWORD ?= postgres
6
+ POSTGRES_CONTAINER ?=
6
7
 
7
- .PHONY: run build test fmt vet lint tidy db-create db-drop migrate-up migrate-down{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
8
+ # which env file to load defaults to .env (your local override, gitignored).
9
+ # override to run against another file, e.g. copy .env.example to .env.production,
10
+ # fill it in, then `make run ENV_FILE=.env.production`.
11
+ #
12
+ # Every target that needs config loads it the same way: drop whole-line comments
13
+ # (^#) AND trailing ` # ...` comments — the sed only strips a `#` preceded by
14
+ # whitespace, so a `#` inside a value (password, DSN) is kept. Without the sed,
15
+ # `xargs` hands the comment's words to `export` too: `PORT=8080 # prod: PORT=80`
16
+ # would export PORT twice and the comment's value would win. (Can't factor this
17
+ # into a make variable: a `#` in a variable value starts a make comment; in a
18
+ # recipe line it's passed to the shell untouched.)
19
+ ENV_FILE ?= .env
20
+
21
+ .PHONY: run build test fmt vet lint tidy db-create db-drop migrate-up migrate-down migrate-verify{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
8
22
 
9
23
  run:
10
- @[ -f .env ] && export $$(grep -v '^#' .env | xargs); go run ./cmd/api
24
+ @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); go run ./cmd/api
11
25
 
12
26
  build:
13
27
  go build -o bin/api ./cmd/api
14
28
 
29
+ # loads ENV_FILE like every other target, so TEST_DB_DSN set there reaches the
30
+ # integration tests. A bare `go test ./...` still works, but falls back to
31
+ # whatever defaults the test files carry.
15
32
  test:
16
- go test ./...
33
+ @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); go test ./...
17
34
 
18
35
  fmt:
19
36
  gofmt -w .
@@ -31,14 +48,17 @@ tidy:
31
48
  # connects to the always-present "postgres" maintenance DB to run CREATE DATABASE,
32
49
  # since the target DB may not exist yet. Safe to re-run — skips if it already exists.
33
50
  # \gexec only works read from stdin, not through -c, hence the pipe.
34
- # no local psql? falls back to `docker exec` into whatever container is publishing
35
- # DB_PORT the project's own `docker compose up`'d postgres, or an unrelated
36
- # shared Postgres container you already have running, either works the same way.
51
+ # `POSTGRES_CONTAINER` selects an exact Docker container when no host psql is
52
+ # installed; otherwise the fallback discovers a container publishing DB_PORT.
37
53
  # override DB_HOST/DB_PORT/DB_USER/DB_NAME/PGPASSWORD to point at a different server.
38
54
  db-create:
39
55
  @if command -v psql >/dev/null 2>&1; then \
40
56
  echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
41
57
  PGPASSWORD=$(PGPASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d postgres && echo "SUCCESS! database $(DB_NAME) is ready"; \
58
+ elif [ -n "$(POSTGRES_CONTAINER)" ]; then \
59
+ echo "no local psql — using docker exec into $(POSTGRES_CONTAINER)"; \
60
+ echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
61
+ docker exec -i -e PGPASSWORD=$(PGPASSWORD) $(POSTGRES_CONTAINER) psql -U $(DB_USER) -d postgres && echo "SUCCESS! database $(DB_NAME) is ready"; \
42
62
  elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
43
63
  container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
44
64
  if [ -z "$$container" ]; then \
@@ -56,6 +76,8 @@ db-create:
56
76
  db-drop:
57
77
  @if command -v psql >/dev/null 2>&1; then \
58
78
  PGPASSWORD=$(PGPASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d postgres -c "DROP DATABASE IF EXISTS $(DB_NAME);" && echo "SUCCESS! database $(DB_NAME) dropped"; \
79
+ elif [ -n "$(POSTGRES_CONTAINER)" ]; then \
80
+ docker exec -i -e PGPASSWORD=$(PGPASSWORD) $(POSTGRES_CONTAINER) psql -U $(DB_USER) -d postgres -c "DROP DATABASE IF EXISTS $(DB_NAME);" && echo "SUCCESS! database $(DB_NAME) dropped"; \
59
81
  elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
60
82
  container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
61
83
  if [ -z "$$container" ]; then \
@@ -69,10 +91,19 @@ db-drop:
69
91
  fi
70
92
 
71
93
  migrate-up:
72
- migrate -path migrations -database "$$DB_DSN" up
94
+ @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); migrate -path migrations -database "$$DB_DSN" up
73
95
 
74
96
  migrate-down:
75
- migrate -path migrations -database "$$DB_DSN" down 1
97
+ @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); migrate -path migrations -database "$$DB_DSN" down 1
98
+
99
+ # runs up -> down-to-zero -> up against $DB_DSN to catch a bit-rotted down.sql
100
+ # (one that no longer reverses cleanly) before you actually need a rollback.
101
+ # point DB_DSN at a throwaway/test database first — this drops every table.
102
+ migrate-verify:
103
+ @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); \
104
+ migrate -path migrations -database "$$DB_DSN" up && \
105
+ migrate -path migrations -database "$$DB_DSN" down -all && \
106
+ migrate -path migrations -database "$$DB_DSN" up
76
107
  {{#if openapiDocs}}
77
108
 
78
109
  # docs/openapi.yaml is hand-written and split across sibling files via relative
@@ -2,12 +2,15 @@
2
2
 
3
3
  Gin + GORM backend, scaffolded by [go-scaffold](https://github.com/nakedev/go-scaffold). Organized by feature (domain), Postgres-backed, schema managed with [golang-migrate](https://github.com/golang-migrate/migrate).
4
4
 
5
- This is a bare skeleton no domain modules yet. Add one with:
5
+ This project starts as a bare skeleton. Add a safe minimal domain with:
6
6
 
7
7
  ```bash
8
8
  go-scaffold generate module orders
9
9
  ```
10
10
 
11
+ Add endpoints one at a time with `generate method`, or pass `--full` to opt
12
+ into a CRUD skeleton whose DTO fields and business rules remain explicit TODOs.
13
+
11
14
  See `docs/architect/` for the conventions every generated module follows, and `AGENTS.md`/`CLAUDE.md` if you're working with an AI coding agent in this repo.
12
15
 
13
16
  ## Layout
@@ -40,9 +43,13 @@ go mod tidy
40
43
  make run # AUTO_MIGRATE=true creates the schema automatically in dev
41
44
  ```
42
45
 
43
- `make run` loads `.env` if present (copy `.env.example` to `.env` to override
44
- defaults the app itself just reads `os.Getenv`, no `.env` parsing at
45
- runtime).
46
+ `make run`/`make test`/`make migrate-up`/`make migrate-down` load `.env` if
47
+ present (copy `.env.example` to `.env` to override defaults the app itself
48
+ just reads `os.Getenv`, no `.env` parsing at runtime). Point them at another
49
+ file with `ENV_FILE`: copy `.env.example` to `.env.production`, fill it in,
50
+ then `make run ENV_FILE=.env.production`. Trailing `# ...` comments in the env
51
+ file are stripped before loading, so a `# prod: ...` note next to a value is
52
+ safe; a `#` inside a value (password, DSN) is kept.
46
53
 
47
54
  `make db-create` connects to Postgres at `DB_HOST`/`DB_PORT`/`DB_USER` (default:
48
55
  `localhost`/`5432`/`postgres`, matching `.env.example`) using the `psql`
@@ -68,15 +75,20 @@ make lint # golangci-lint run (see .golangci.yml)
68
75
  make tidy # go mod tidy
69
76
  make db-create # create the database itself (safe to re-run)
70
77
  make db-drop # drop the database
71
- make migrate-up # apply migrations (needs $DB_DSN)
78
+ make migrate-up # apply migrations (reads DB_DSN from ENV_FILE)
72
79
  make migrate-down # roll back one migration
80
+ make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql (point DB_DSN at a throwaway DB)
73
81
  {{#if docker}}make docker-up # docker compose up -d
74
82
  make docker-down # docker compose down
75
83
  {{/if}}```
76
84
 
77
85
  ## Migrations
78
86
 
79
- Schema is managed with [golang-migrate](https://github.com/golang-migrate/migrate), files live in `migrations/`.
87
+ Schema is managed with [golang-migrate](https://github.com/golang-migrate/migrate), files live in `migrations/`, named `<version>_<name>.{up,down}.sql`. `version` is a 14-digit UTC timestamp, not an incrementing counter — two people adding a migration off the same base branch get different filenames instead of both claiming the same number and colliding on merge. (`generate module` names its own migration the same way; existing sequential `0000NN_*` files from before this convention sort fine alongside timestamped ones either way.)
88
+
89
+ ```bash
90
+ go-scaffold generate migration add_status_to_orders # reserves migrations/<version>_add_status_to_orders.{up,down}.sql, TODO-stubbed — you write the SQL
91
+ ```
80
92
 
81
93
  ```bash
82
94
  brew install golang-migrate
@@ -84,16 +96,31 @@ brew install golang-migrate
84
96
 
85
97
  migrate -path migrations -database "$DB_DSN" up
86
98
  migrate -path migrations -database "$DB_DSN" down 1
87
- migrate create -ext sql -dir migrations -seq add_something
99
+ migrate create -ext sql -dir migrations -seq=false add_something # same as `go-scaffold generate migration`, if you'd rather not use the CLI
88
100
  ```
89
101
 
90
102
  **dev:** leave `AUTO_MIGRATE=true` (default) so GORM's AutoMigrate creates the schema quickly.
91
103
  **prod:** set `AUTO_MIGRATE=false` and run `migrate up` as a separate deploy step — versioned, has rollback (`down`), doesn't lock the table the way AutoMigrate does once there's real data.
92
104
 
105
+ With `AUTO_MIGRATE=false`, the app checks the DB's applied migration version
106
+ against the migration files baked into the binary (embedded at build time)
107
+ before it starts serving traffic — a stale or half-applied schema fails fast
108
+ at boot with a clear message, instead of failing later on whatever query
109
+ happens to hit the missing column first:
110
+
111
+ ```text
112
+ DB schema is at migration 3, this binary expects 5 — run `make migrate-up`
113
+ ```
114
+
115
+ ```bash
116
+ make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql before you actually need a rollback (point DB_DSN at a throwaway DB first — this drops every table)
117
+ ```
118
+
93
119
  ## Env vars
94
120
 
95
121
  | var | default | notes |
96
122
  |---|---|---|
123
+ | `APP_ENV` | `development` | `development` or `production` — **the prod gate**: hides error `details` from responses. App refuses to boot on any other value |
97
124
  | `PORT` | `8080` | |
98
125
  | `DB_DSN` | `postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable` | used by both GORM and the `migrate` CLI |
99
126
  | `LOG_LEVEL` | `info` | debug/info/warn/error |
@@ -101,17 +128,27 @@ migrate create -ext sql -dir migrations -seq add_something
101
128
  | `DB_MAX_OPEN_CONNS` | `10` | |
102
129
  | `DB_MAX_IDLE_CONNS` | `10` | |
103
130
  | `DB_CONN_MAX_LIFETIME_MIN` | `5` | minutes |
131
+ | `CORS_ALLOWED_ORIGINS` | `http://localhost:3000` | comma-separated frontend origins allowed to call this API with credentials (cookies) |
104
132
 
105
133
  ## Tests
106
134
 
107
- Integration tests (handler-level) run against a **real Postgres** instance (same engine as prod), each test in a transaction that's rolled back — no leftover rows. If the DB isn't reachable those tests **skip** (unit tests using a fake repo always run).
135
+ Handler and service tests are fast unit tests: handlers depend on a narrow
136
+ service interface, and services use function-backed repository stubs. Neither
137
+ suite requires a database.
138
+
139
+ Repository integration tests run against a **real Postgres** instance using
140
+ the same versioned SQL migrations as production, each test in a transaction
141
+ that's rolled back. Locally they skip when `TEST_DB_DSN` is unset; CI sets
142
+ `REQUIRE_TEST_DB=true`, so a missing or unmigrated database fails instead of
143
+ becoming a false-green skip. Use a separate `{{dbName}}_test` database:
108
144
 
109
145
  ```bash
110
146
  {{#if docker}}
111
147
  make docker-up # local postgres first
112
148
  {{/if}}
113
- make test
114
- # point at a different test DB: TEST_DB_DSN=postgres://... go test ./...
149
+ make db-create DB_NAME={{dbName}}_test
150
+ DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable make migrate-up
151
+ TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable REQUIRE_TEST_DB=true go test ./...
115
152
  ```
116
153
 
117
154
  ## Error payload
@@ -120,7 +157,7 @@ make test
120
157
  {"error":{"code":"VALIDATION_ERROR","message":"invalid input","details":{"email":"email"},"request_id":"a1b2..."}}
121
158
  ```
122
159
 
123
- `code` is machine-readable, `details` names the field that failed, `request_id` correlates with server logs (header `X-Request-ID`).
160
+ `code` is machine-readable, `details` names the field that failed, `request_id` correlates with server logs (header `X-Request-ID`). `details` is **only returned outside production** (`APP_ENV` != `production`) — in prod it's stripped from every error so a direct caller can't learn the API's shape; the server log still has the full detail keyed by `request_id`.
124
161
  {{#if openapiDocs}}
125
162
 
126
163
  ## API spec
@@ -142,4 +179,7 @@ Import `docs/openapi.bundled.yaml` instead. Tools that resolve `$ref` over HTTP
142
179
  go-scaffold generate module orders
143
180
  ```
144
181
 
145
- Scaffolds `internal/app/orders/` (model/dto/errors/repository/service/handler + tests), wires it into `cmd/api/main.go` (AutoMigrate, route registration), and appends a migration file. See `docs/architect/patterns.md` for the module shape and the rules for domains with foreign keys.
182
+ Scaffolds the safe minimal `internal/app/order/` module, wires its empty route
183
+ group/model into `cmd/api/main.go`, and appends a migration file. Add endpoints
184
+ with `generate method`; use `--full` only when a CRUD skeleton is intentional.
185
+ See `docs/architect/patterns.md` for the module shape and foreign-key rules.
@@ -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