@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.
- package/README.md +93 -14
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +61 -2
- package/dist/commands/method.js +66 -15
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +36 -20
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +71 -7
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/module-manifest.js +2 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/method-patcher.js +80 -16
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/module-location.js +22 -0
- package/dist/utils/naming.js +75 -12
- package/dist/utils/openapi-patcher.js +35 -1
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/smoke-run.js +31 -0
- package/dist/utils/version.js +24 -0
- package/package.json +15 -6
- package/scripts/smoke-test.mjs +2058 -0
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/AGENTS.md.hbs +10 -12
- package/templates/create/base/Makefile.hbs +39 -8
- package/templates/create/base/README.md.hbs +52 -12
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +17 -5
- package/templates/create/features/docs/patterns.md.hbs +9 -6
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/handler.go.hbs +37 -6
- package/templates/generate/module/handler_test.go.hbs +113 -51
- package/templates/generate/module/migration.down.sql.hbs +1 -1
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/templates/generate/module/minimal/handler.go.hbs +28 -4
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +7 -0
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- package/templates/generate/module/repository_test.go.hbs +79 -0
- package/templates/generate/module/service.go.hbs +6 -4
- package/templates/generate/module/service_test.go.hbs +68 -17
- package/tests/integration/default-module.test.mjs +46 -0
- package/tests/integration/generator-naming.test.mjs +81 -0
- package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
- package/tests/integration/legacy-method-compat.test.mjs +222 -0
- package/tests/integration/remove-module.test.mjs +58 -0
- package/tests/unit/naming.test.mjs +94 -0
- package/tests/unit/smoke-isolation.test.mjs +35 -0
- 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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 ./...
|
|
@@ -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>` —
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
- `go-scaffold remove module <name>` — the
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
35
|
-
#
|
|
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
|
|
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
|
|
44
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
114
|
-
|
|
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/
|
|
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
|
-
|
|
35
|
+
cfg := Config{
|
|
36
|
+
AppEnv: env("APP_ENV", "development"),
|
|
22
37
|
Port: env("PORT", "8080"),
|
|
23
38
|
DBDSN: env("DB_DSN", "postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable"),
|
|
24
39
|
LogLevel: env("LOG_LEVEL", "info"),
|
|
@@ -26,7 +41,23 @@ func Load() Config {
|
|
|
26
41
|
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 10),
|
|
27
42
|
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 10),
|
|
28
43
|
DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MIN", 5)) * time.Minute,
|
|
44
|
+
|
|
45
|
+
CORSAllowedOrigins: envList("CORS_ALLOWED_ORIGINS", "http://localhost:3000"),
|
|
46
|
+
{{#if observability}}
|
|
47
|
+
OTELExporterEndpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
|
|
48
|
+
{{/if}}
|
|
49
|
+
// go-scaffold:config-load
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fail closed on an unknown APP_ENV: an unrecognized value (typo, stale var)
|
|
53
|
+
// would otherwise be treated as non-prod and silently disable the prod guard
|
|
54
|
+
// (leaking error details). Panics here — the one place the binary loads
|
|
55
|
+
// config — so a bad value is caught at boot, not discovered in prod later.
|
|
56
|
+
if cfg.AppEnv != "development" && cfg.AppEnv != "production" {
|
|
57
|
+
panic(fmt.Sprintf("invalid APP_ENV %q — must be development or production", cfg.AppEnv))
|
|
29
58
|
}
|
|
59
|
+
|
|
60
|
+
return cfg
|
|
30
61
|
}
|
|
31
62
|
|
|
32
63
|
func env(k, def string) string {
|
|
@@ -36,6 +67,17 @@ func env(k, def string) string {
|
|
|
36
67
|
return def
|
|
37
68
|
}
|
|
38
69
|
|
|
70
|
+
func envList(k, def string) []string {
|
|
71
|
+
raw := strings.Split(env(k, def), ",")
|
|
72
|
+
out := make([]string, 0, len(raw))
|
|
73
|
+
for _, v := range raw {
|
|
74
|
+
if v = strings.TrimSpace(v); v != "" {
|
|
75
|
+
out = append(out, v)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
39
81
|
func envInt(k string, def int) int {
|
|
40
82
|
if v := os.Getenv(k); v != "" {
|
|
41
83
|
if n, err := strconv.Atoi(v); err == nil {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"slices"
|
|
6
|
+
|
|
7
|
+
"github.com/gin-gonic/gin"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// CORS allows a fixed set of browser origins to call the API with
|
|
11
|
+
// credentials (cookies) — "*" can't be combined with Allow-Credentials per
|
|
12
|
+
// the fetch spec, so the origin is echoed back only when it's in
|
|
13
|
+
// allowedOrigins.
|
|
14
|
+
func CORS(allowedOrigins []string) gin.HandlerFunc {
|
|
15
|
+
return func(c *gin.Context) {
|
|
16
|
+
c.Header("Vary", "Origin")
|
|
17
|
+
if origin := c.GetHeader("Origin"); slices.Contains(allowedOrigins, origin) {
|
|
18
|
+
c.Header("Access-Control-Allow-Origin", origin)
|
|
19
|
+
c.Header("Access-Control-Allow-Credentials", "true")
|
|
20
|
+
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
21
|
+
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
22
|
+
}
|
|
23
|
+
if c.Request.Method == http.MethodOptions {
|
|
24
|
+
c.AbortWithStatus(http.StatusNoContent)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
c.Next()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -11,7 +11,12 @@ import (
|
|
|
11
11
|
|
|
12
12
|
// Error reads the last error attached via c.Error() and renders it once, so
|
|
13
13
|
// handlers only ever do c.Error(err); return — no c.JSON per call site.
|
|
14
|
-
|
|
14
|
+
//
|
|
15
|
+
// exposeDetail controls whether an error's Details (a validation field map,
|
|
16
|
+
// or an unexpected error's real message) is echoed back in the response body
|
|
17
|
+
// — wire this to !cfg.IsProd() so a caller hitting the API directly never
|
|
18
|
+
// learns field names/shape from prod responses; devs still get it locally.
|
|
19
|
+
func Error(exposeDetail bool) gin.HandlerFunc {
|
|
15
20
|
return func(c *gin.Context) {
|
|
16
21
|
c.Next()
|
|
17
22
|
|
|
@@ -25,6 +30,13 @@ func Error() gin.HandlerFunc {
|
|
|
25
30
|
// Unexpected error: log the real thing, answer the client generically.
|
|
26
31
|
slog.Error("unhandled error", "error", err.Err, "request_id", c.GetString(RequestIDKey))
|
|
27
32
|
appErr = apperror.NewInternal()
|
|
33
|
+
if exposeDetail {
|
|
34
|
+
appErr.Details = err.Err.Error()
|
|
35
|
+
}
|
|
36
|
+
} else if !exposeDetail {
|
|
37
|
+
// Known AppError (e.g. VALIDATION_ERROR): still strip Details in
|
|
38
|
+
// prod, so a direct API call doesn't get field-level hints.
|
|
39
|
+
appErr.Details = nil
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
appErr.RequestID = c.GetString(RequestIDKey)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Package migrations embeds the migration SQL files so the running binary
|
|
2
|
+
// can check its own schema version without shelling out to the migrate CLI.
|
|
3
|
+
package migrations
|
|
4
|
+
|
|
5
|
+
import "embed"
|
|
6
|
+
|
|
7
|
+
// ponytail: pattern is "*", not "*.sql" — a fresh project has zero .sql files
|
|
8
|
+
// until the first `generate module`, and "*.sql" fails to compile ("no
|
|
9
|
+
// matching files") until one exists. "*" always matches at least .gitkeep, so
|
|
10
|
+
// this builds from `create` onward; the harmless cost is embedding .gitkeep
|
|
11
|
+
// and this file itself alongside real migrations, which CheckMigrationVersion
|
|
12
|
+
// already skips over (regex requires a numeric prefix and .up.sql suffix).
|
|
13
|
+
//
|
|
14
|
+
//go:embed *
|
|
15
|
+
var FS embed.FS
|
|
@@ -91,6 +91,28 @@ Scalar/Swagger UI/Redoc to follow them).
|
|
|
91
91
|
comment-generated (swaggo) if hand-updates start drifting.
|
|
92
92
|
{{/if}}
|
|
93
93
|
|
|
94
|
+
{{#if observability}}
|
|
95
|
+
## 9. Observability
|
|
96
|
+
|
|
97
|
+
**Decision:** Prometheus metrics (`GET /metrics`, request count + latency
|
|
98
|
+
per route) always-on when this feature is enabled; OpenTelemetry tracing
|
|
99
|
+
(Gin + GORM) exports via OTLP/HTTP only when `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
100
|
+
is set — empty means no exporter is created and no network calls are made.
|
|
101
|
+
**Rationale:** Metrics have no external dependency to turn on (Prometheus
|
|
102
|
+
scrapes the app, the app never dials out) so there's no reason to gate them
|
|
103
|
+
further. Tracing needs a collector to be useful, so it stays off until one's
|
|
104
|
+
actually configured, instead of trying to dial a collector that isn't there.
|
|
105
|
+
This is a `create`-time choice (unlike `add auth`/`add rbac`), not something
|
|
106
|
+
layered on afterward — flip it by hand (`internal/shared/middleware/{metrics,tracing}.go`,
|
|
107
|
+
`internal/platform/telemetry/tracing.go`, wiring in `cmd/api/main.go` and
|
|
108
|
+
`internal/platform/database`) if the project needs it later. Both the Gin and
|
|
109
|
+
GORM tracing hooks are hand-rolled against the OTel SDK directly, not the
|
|
110
|
+
official `otelgin`/`gorm.io/plugin/opentelemetry` contrib packages — those
|
|
111
|
+
pull in a newer Gin (→ HTTP/3/quic-go) and every DB driver they support
|
|
112
|
+
tracing for (MySQL, ClickHouse, MongoDB), respectively, for a Postgres-only
|
|
113
|
+
project that only wants request/query spans.
|
|
114
|
+
|
|
115
|
+
{{/if}}
|
|
94
116
|
## Evolution Notes
|
|
95
117
|
|
|
96
118
|
- `go-scaffold generate module <name>` adds a new domain package and
|