@nakedev/go-scaffold 0.1.4 → 0.3.0
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/LICENSE +21 -0
- package/README.md +133 -44
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +21 -11
- package/dist/commands/method.js +32 -3
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +366 -63
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +17 -2
- package/dist/utils/module-location.js +37 -1
- package/dist/utils/naming.js +50 -2
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/package.json +3 -5
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +8 -4
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +43 -13
- package/templates/create/base/README.md.hbs +45 -8
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +0 -4
- package/templates/create/features/docs/patterns.md.hbs +82 -8
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler_test.go.hbs +8 -1
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -1
- package/templates/generate/module/model/model.go.hbs +10 -1
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +30 -0
- package/templates/generate/module/service.go.hbs +10 -1
- package/templates/generate/module/service_test.go.hbs +45 -0
- package/dist/commands/remove.js +0 -88
- package/scripts/smoke-test.mjs +0 -2058
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
- package/tests/integration/default-module.test.mjs +0 -46
- package/tests/integration/generator-naming.test.mjs +0 -81
- package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
- package/tests/integration/legacy-method-compat.test.mjs +0 -222
- package/tests/integration/remove-module.test.mjs +0 -58
- package/tests/unit/naming.test.mjs +0 -94
- package/tests/unit/smoke-isolation.test.mjs +0 -35
|
@@ -5,12 +5,39 @@ version: "2"
|
|
|
5
5
|
|
|
6
6
|
linters:
|
|
7
7
|
enable:
|
|
8
|
+
- depguard
|
|
8
9
|
- errcheck
|
|
9
10
|
- govet
|
|
10
11
|
- ineffassign
|
|
11
12
|
- staticcheck
|
|
12
13
|
- unused
|
|
13
14
|
settings:
|
|
15
|
+
depguard:
|
|
16
|
+
# What keeps this a modular monolith rather than a pile of folders: a
|
|
17
|
+
# domain may not reach into another domain's package. It declares a
|
|
18
|
+
# narrow interface for what it needs and cmd/api/wiring.go supplies the
|
|
19
|
+
# concrete service — see docs/architect/patterns.md "Calling Another
|
|
20
|
+
# Domain's Logic". Without this, the first shortcut someone takes stays
|
|
21
|
+
# invisible until the coupling is everywhere.
|
|
22
|
+
#
|
|
23
|
+
# One rule per domain, written by `go-scaffold generate module`: depguard
|
|
24
|
+
# matches import paths as static prefixes and has no notion of "the
|
|
25
|
+
# domain this file belongs to", so denying internal/app wholesale would
|
|
26
|
+
# also reject a domain importing its own model/ subpackage. Each rule
|
|
27
|
+
# scopes itself with `files:` and allows exactly its own path.
|
|
28
|
+
#
|
|
29
|
+
# This only sees Go imports. A raw SQL JOIN into another domain's table
|
|
30
|
+
# couples just as hard and no linter can see it — that boundary comes
|
|
31
|
+
# from giving each domain its own schema.
|
|
32
|
+
rules:
|
|
33
|
+
wiring-flows-inward:
|
|
34
|
+
list-mode: lax
|
|
35
|
+
files:
|
|
36
|
+
- "**/internal/app/**"
|
|
37
|
+
deny:
|
|
38
|
+
- pkg: "{{goModule}}/cmd"
|
|
39
|
+
desc: a domain must not import cmd/ — wiring flows the other way
|
|
40
|
+
# go-scaffold:depguard-rules
|
|
14
41
|
errcheck:
|
|
15
42
|
exclude-functions:
|
|
16
43
|
# gin.Context.Error()'s return value is intentionally never checked —
|
|
@@ -46,7 +46,7 @@ hand-rolling anything that looks like scaffolding.
|
|
|
46
46
|
## Command quick reference
|
|
47
47
|
|
|
48
48
|
- `go-scaffold generate module <name>` — safe minimal model + errors +
|
|
49
|
-
repository + service/handler plumbing, wired into `cmd/api/
|
|
49
|
+
repository + service/handler plumbing, wired into `cmd/api/wiring.go` and
|
|
50
50
|
appended to `migrations/`. Add endpoints one at a time with `generate
|
|
51
51
|
method`, or pass `--full` to opt into a CRUD skeleton with TODO DTO fields
|
|
52
52
|
- `go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]` —
|
|
@@ -55,9 +55,13 @@ hand-rolling anything that looks like scaffolding.
|
|
|
55
55
|
with the same name — pick a different one if it collides. With OpenAPI
|
|
56
56
|
enabled it also writes a valid TODO path document and wires the index;
|
|
57
57
|
replace placeholder schemas while implementing the method
|
|
58
|
-
- `go-scaffold
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
- `go-scaffold undo module <name>` — takes back a `generate module` that
|
|
59
|
+
shouldn't have happened (typo'd name, domain decided against): deletes the
|
|
60
|
+
package, un-wires main.go/OpenAPI, and deletes the module's migration files.
|
|
61
|
+
It refuses if those migrations are committed to git or already applied to
|
|
62
|
+
your database, since then they may exist somewhere this can't reach. Not for
|
|
63
|
+
retiring a domain that has shipped — that's `generate migration drop_<table>`
|
|
64
|
+
plus a reviewed data removal; the table is never dropped either way
|
|
61
65
|
- Every route in this project is grouped under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
|
|
62
66
|
(set once at `create` time via `--api-prefix`) — there is no per-domain
|
|
63
67
|
versioning; a breaking API change gets a new domain package or a new DTO
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Build both binaries in one image, ship each from a separate stage — cmd/api
|
|
2
|
+
# and cmd/worker are deployed as two processes, and neither needs the other's.
|
|
3
|
+
#
|
|
4
|
+
# docker build --target api -t {{projectName}}-api .
|
|
5
|
+
# docker build --target worker -t {{projectName}}-worker .
|
|
6
|
+
#
|
|
7
|
+
# The worker target only builds once `go-scaffold add worker` has created
|
|
8
|
+
# cmd/worker; until then `--target api` is the only one that resolves.
|
|
9
|
+
FROM golang:1.25-alpine AS build
|
|
10
|
+
WORKDIR /src
|
|
11
|
+
|
|
12
|
+
# Dependencies first: this layer is cached until go.mod/go.sum change, so an
|
|
13
|
+
# ordinary code edit doesn't re-download the module graph.
|
|
14
|
+
COPY go.mod go.sum ./
|
|
15
|
+
RUN go mod download
|
|
16
|
+
|
|
17
|
+
COPY . .
|
|
18
|
+
# CGO off so the result runs on a distroless/scratch base with no libc.
|
|
19
|
+
# -trimpath keeps build-machine paths out of panics; -s -w drops the symbol
|
|
20
|
+
# table and DWARF, which is most of the binary size and nothing you can use in
|
|
21
|
+
# production anyway.
|
|
22
|
+
ENV CGO_ENABLED=0
|
|
23
|
+
RUN go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
|
|
24
|
+
RUN if [ -d ./cmd/worker ]; then go build -trimpath -ldflags="-s -w" -o /out/worker ./cmd/worker; fi
|
|
25
|
+
|
|
26
|
+
# nonroot, and no shell: there is nothing in this image to exec into if
|
|
27
|
+
# something gets in. Debug with `docker run --entrypoint` against the build
|
|
28
|
+
# stage instead.
|
|
29
|
+
FROM gcr.io/distroless/static-debian12:nonroot AS api
|
|
30
|
+
WORKDIR /app
|
|
31
|
+
COPY --from=build /out/api /app/api
|
|
32
|
+
# migrations/ is embedded in the binary (migrations/embed.go), so nothing to
|
|
33
|
+
# copy for CheckMigrationVersion. docs/ is only served outside production.
|
|
34
|
+
USER nonroot:nonroot
|
|
35
|
+
EXPOSE 8080
|
|
36
|
+
ENTRYPOINT ["/app/api"]
|
|
37
|
+
|
|
38
|
+
FROM gcr.io/distroless/static-debian12:nonroot AS worker
|
|
39
|
+
WORKDIR /app
|
|
40
|
+
COPY --from=build /out/worker /app/worker
|
|
41
|
+
USER nonroot:nonroot
|
|
42
|
+
ENTRYPOINT ["/app/worker"]
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
# Pinned dev-tool versions — one place, used by `make tools` and by CI, so a
|
|
2
|
+
# lint rule or migration runner never behaves differently on someone's laptop
|
|
3
|
+
# than it does on a pull request.
|
|
4
|
+
#
|
|
5
|
+
# ponytail: installed with `go install`, not tracked as a go.mod `tool`
|
|
6
|
+
# directive — golang-migrate's CLI pulls a driver for every database it
|
|
7
|
+
# supports and golangci-lint pulls its whole linter tree, which between them
|
|
8
|
+
# turned this project's go.sum from 117 lines into 2,700. Tool pinning is not
|
|
9
|
+
# worth that in a scaffold whose point is a clean starting project.
|
|
10
|
+
MIGRATE_VERSION ?= v4.19.1
|
|
11
|
+
GOLANGCI_LINT_VERSION ?= v2.12.2
|
|
12
|
+
|
|
1
13
|
DB_HOST ?= localhost
|
|
2
14
|
DB_PORT ?= 5432
|
|
3
15
|
DB_USER ?= postgres
|
|
@@ -9,19 +21,26 @@ POSTGRES_CONTAINER ?=
|
|
|
9
21
|
# override to run against another file, e.g. copy .env.example to .env.production,
|
|
10
22
|
# fill it in, then `make run ENV_FILE=.env.production`.
|
|
11
23
|
#
|
|
12
|
-
# Every target that needs config loads it the same way:
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
24
|
+
# Every target that needs config loads it the same way: `set -a` marks whatever
|
|
25
|
+
# follows for export, the file is sourced, `set +a` stops.
|
|
26
|
+
#
|
|
27
|
+
# Sourcing rather than piping through xargs, which is what this used to do. The
|
|
28
|
+
# shell already understands the format: `PORT=8080 # prod: 80` keeps 8080,
|
|
29
|
+
# blank lines are nothing, and — the reason it changed — a quoted value with a
|
|
30
|
+
# space in it survives. xargs word-split `SMTP_PASSWORD="two words"` into two
|
|
31
|
+
# arguments and exported the wrong thing, silently.
|
|
32
|
+
#
|
|
33
|
+
# It does mean .env is executed, so `$(...)` in a value runs. That file is your
|
|
34
|
+
# own gitignored config, and the alternative could not read half of it.
|
|
35
|
+
#
|
|
36
|
+
# (Can't factor this into a make variable: a `#` in a variable value starts a
|
|
37
|
+
# make comment; in a recipe line it is passed to the shell untouched.)
|
|
19
38
|
ENV_FILE ?= .env
|
|
20
39
|
|
|
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}}
|
|
40
|
+
.PHONY: tools run build test fmt vet lint tidy db-create db-drop migrate-up migrate-up-test migrate-down migrate-verify{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
|
|
22
41
|
|
|
23
42
|
run:
|
|
24
|
-
@[ -f $(ENV_FILE) ] &&
|
|
43
|
+
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; go run ./cmd/api
|
|
25
44
|
|
|
26
45
|
build:
|
|
27
46
|
go build -o bin/api ./cmd/api
|
|
@@ -30,7 +49,7 @@ build:
|
|
|
30
49
|
# integration tests. A bare `go test ./...` still works, but falls back to
|
|
31
50
|
# whatever defaults the test files carry.
|
|
32
51
|
test:
|
|
33
|
-
@[ -f $(ENV_FILE) ] &&
|
|
52
|
+
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; go test ./...
|
|
34
53
|
|
|
35
54
|
fmt:
|
|
36
55
|
gofmt -w .
|
|
@@ -41,6 +60,11 @@ vet:
|
|
|
41
60
|
lint:
|
|
42
61
|
golangci-lint run
|
|
43
62
|
|
|
63
|
+
# install the pinned dev tools into $(go env GOPATH)/bin
|
|
64
|
+
tools:
|
|
65
|
+
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@$(MIGRATE_VERSION)
|
|
66
|
+
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
|
|
67
|
+
|
|
44
68
|
tidy:
|
|
45
69
|
go mod tidy
|
|
46
70
|
|
|
@@ -91,16 +115,22 @@ db-drop:
|
|
|
91
115
|
fi
|
|
92
116
|
|
|
93
117
|
migrate-up:
|
|
94
|
-
@[ -f $(ENV_FILE) ] &&
|
|
118
|
+
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$DB_DSN" up
|
|
119
|
+
|
|
120
|
+
# same migration files, applied to TEST_DB_DSN instead of DB_DSN — run this
|
|
121
|
+
# once after `make db-create DB_NAME=..._test` so repository_test.go files
|
|
122
|
+
# see real tables instead of skipping (or failing, if REQUIRE_TEST_DB=true).
|
|
123
|
+
migrate-up-test:
|
|
124
|
+
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$TEST_DB_DSN" up
|
|
95
125
|
|
|
96
126
|
migrate-down:
|
|
97
|
-
@[ -f $(ENV_FILE) ] &&
|
|
127
|
+
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$DB_DSN" down 1
|
|
98
128
|
|
|
99
129
|
# runs up -> down-to-zero -> up against $DB_DSN to catch a bit-rotted down.sql
|
|
100
130
|
# (one that no longer reverses cleanly) before you actually need a rollback.
|
|
101
131
|
# point DB_DSN at a throwaway/test database first — this drops every table.
|
|
102
132
|
migrate-verify:
|
|
103
|
-
@[ -f $(ENV_FILE) ] &&
|
|
133
|
+
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; \
|
|
104
134
|
migrate -path migrations -database "$$DB_DSN" up && \
|
|
105
135
|
migrate -path migrations -database "$$DB_DSN" down -all && \
|
|
106
136
|
migrate -path migrations -database "$$DB_DSN" up
|
|
@@ -27,8 +27,10 @@ internal/
|
|
|
27
27
|
│ ├── apperror/ # central error type (status + payload)
|
|
28
28
|
│ ├── dberr/ # maps DB errors to constraint kind (IsDuplicate, IsForeignKey) — shared by every domain
|
|
29
29
|
│ ├── httpx/ # HTTP helpers shared by every domain (ParseID, BindErr)
|
|
30
|
-
│ ├──
|
|
31
|
-
│
|
|
30
|
+
│ ├── id/ # UUID v7 generation (id.New) — app-side, not a DB default
|
|
31
|
+
│ ├── middleware/ # RequestID, Logger (slog), Error, CORS
|
|
32
|
+
│ ├── pagination/ # parses ?limit=&offset=, response envelope
|
|
33
|
+
│ └── tx/ # carries a transaction on the ctx (tx.Do / tx.From) so two repositories commit together
|
|
32
34
|
└── app/ # domain packages — empty until you `generate module`
|
|
33
35
|
```
|
|
34
36
|
|
|
@@ -53,13 +55,14 @@ safe; a `#` inside a value (password, DSN) is kept.
|
|
|
53
55
|
|
|
54
56
|
`make db-create` connects to Postgres at `DB_HOST`/`DB_PORT`/`DB_USER` (default:
|
|
55
57
|
`localhost`/`5432`/`postgres`, matching `.env.example`) using the `psql`
|
|
56
|
-
client — works the same whether Postgres came from
|
|
57
|
-
existing instance you already have
|
|
58
|
+
client — works the same whether Postgres came from
|
|
59
|
+
{{#if docker}}`make docker-up`{{else}}a container{{/if}} or an existing instance you already have
|
|
60
|
+
running. Override any of
|
|
58
61
|
`DB_HOST`/`DB_PORT`/`DB_USER`/`DB_NAME`/`PGPASSWORD` to point at a different
|
|
59
62
|
server. If `psql` isn't installed locally and `DB_HOST` is `localhost`, it
|
|
60
63
|
falls back to `docker exec` into whichever container is publishing
|
|
61
|
-
`DB_PORT` — this project's own Postgres (`make docker-up` first) or any
|
|
62
|
-
other Postgres container you already have running.
|
|
64
|
+
`DB_PORT` — {{#if docker}}this project's own Postgres (`make docker-up` first) or any
|
|
65
|
+
other Postgres container{{else}}any Postgres container{{/if}} you already have running.
|
|
63
66
|
|
|
64
67
|
Server listens on `:8080` (override with `PORT`). Ctrl+C = graceful shutdown.
|
|
65
68
|
|
|
@@ -76,6 +79,7 @@ make tidy # go mod tidy
|
|
|
76
79
|
make db-create # create the database itself (safe to re-run)
|
|
77
80
|
make db-drop # drop the database
|
|
78
81
|
make migrate-up # apply migrations (reads DB_DSN from ENV_FILE)
|
|
82
|
+
make migrate-up-test # same migrations, applied to TEST_DB_DSN instead — see Tests
|
|
79
83
|
make migrate-down # roll back one migration
|
|
80
84
|
make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql (point DB_DSN at a throwaway DB)
|
|
81
85
|
{{#if docker}}make docker-up # docker compose up -d
|
|
@@ -147,7 +151,7 @@ becoming a false-green skip. Use a separate `{{dbName}}_test` database:
|
|
|
147
151
|
make docker-up # local postgres first
|
|
148
152
|
{{/if}}
|
|
149
153
|
make db-create DB_NAME={{dbName}}_test
|
|
150
|
-
|
|
154
|
+
make migrate-up-test
|
|
151
155
|
TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable REQUIRE_TEST_DB=true go test ./...
|
|
152
156
|
```
|
|
153
157
|
|
|
@@ -180,6 +184,39 @@ go-scaffold generate module orders
|
|
|
180
184
|
```
|
|
181
185
|
|
|
182
186
|
Scaffolds the safe minimal `internal/app/order/` module, wires its empty route
|
|
183
|
-
group/model into `cmd/api/
|
|
187
|
+
group/model into `cmd/api/wiring.go`, and appends a migration file. Add endpoints
|
|
184
188
|
with `generate method`; use `--full` only when a CRUD skeleton is intentional.
|
|
185
189
|
See `docs/architect/patterns.md` for the module shape and foreign-key rules.
|
|
190
|
+
|
|
191
|
+
## Deploying
|
|
192
|
+
|
|
193
|
+
Two processes, one image, one target each:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
docker build --target api -t {{projectName}}-api .
|
|
197
|
+
docker build --target worker -t {{projectName}}-worker . # once `add worker` exists
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Both stages are distroless and run as `nonroot`, so there is no shell in either
|
|
201
|
+
image. Debug against the `build` stage instead:
|
|
202
|
+
`docker run --rm -it --entrypoint sh $(docker build -q --target build .)`.
|
|
203
|
+
|
|
204
|
+
`cmd/api` serves HTTP. `cmd/worker` consumes the queue and is a separate
|
|
205
|
+
deployment with **no port and no health endpoint** — scale it independently,
|
|
206
|
+
and remember that not running it means queued mail is never sent, silently.
|
|
207
|
+
|
|
208
|
+
Before the first deploy of a release:
|
|
209
|
+
|
|
210
|
+
| | |
|
|
211
|
+
|---|---|
|
|
212
|
+
| `AUTO_MIGRATE` | leave unset — it defaults off when `APP_ENV=production`, and the app checks the applied migration version instead |
|
|
213
|
+
| migrations | `migrate -path migrations -database "$DB_DSN" up` as its own step, before the new binaries roll |
|
|
214
|
+
| `TRUSTED_PROXIES` | the CIDRs of your ingress, or the auth rate limiter keys on a header anyone can send |
|
|
215
|
+
| `COOKIE_SECURE` | `true` · `COOKIE_SAMESITE=none` as well if your frontend is on a different site |
|
|
216
|
+
| `/metrics` | reachable in-cluster for Prometheus, blocked at the ingress |
|
|
217
|
+
| `JWT_SECRET`, `SMTP_HOST` | the app refuses to start in production without real values |
|
|
218
|
+
|
|
219
|
+
The migration check is what makes the rollout order safe: a binary whose
|
|
220
|
+
embedded migrations are ahead of the database refuses to start, while one
|
|
221
|
+
that's behind logs a warning and serves — so migrating first, then rolling, is
|
|
222
|
+
the order that never leaves a pod serving against a schema it doesn't know.
|
|
@@ -1,141 +1,28 @@
|
|
|
1
1
|
package main
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
-
"context"
|
|
5
|
-
"errors"
|
|
6
4
|
"log/slog"
|
|
7
|
-
"net/http"
|
|
8
5
|
"os"
|
|
9
|
-
"os/signal"
|
|
10
|
-
"syscall"
|
|
11
|
-
"time"
|
|
12
|
-
|
|
13
|
-
"{{goModule}}/internal/platform/database"
|
|
14
|
-
{{#if observability}}
|
|
15
|
-
"{{goModule}}/internal/platform/telemetry"
|
|
16
|
-
{{/if}}
|
|
17
|
-
"{{goModule}}/internal/shared/config"
|
|
18
|
-
"{{goModule}}/internal/shared/middleware"
|
|
19
|
-
// go-scaffold:imports
|
|
20
|
-
|
|
21
|
-
"github.com/gin-gonic/gin"
|
|
22
|
-
{{#if observability}}
|
|
23
|
-
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
24
|
-
{{/if}}
|
|
25
6
|
)
|
|
26
7
|
|
|
8
|
+
// main decides the exit code and nothing else. Everything it used to do lives
|
|
9
|
+
// in run() — see wiring.go, which is this binary's composition root: the one
|
|
10
|
+
// place that sees every module and hands them what they need.
|
|
11
|
+
//
|
|
12
|
+
// Two things that split buys. Deferred cleanup actually runs, because os.Exit
|
|
13
|
+
// skips defers and this doesn't call it until run has returned — telemetry
|
|
14
|
+
// shutdown and the signal handler used to be silently abandoned on a startup
|
|
15
|
+
// error. And run() is an ordinary function returning an ordinary error, so a
|
|
16
|
+
// test can call it.
|
|
17
|
+
//
|
|
18
|
+
// wiring.go is also the file `go-scaffold` patches. Keeping it out of main.go
|
|
19
|
+
// means the first file you open to understand this binary is not the one that
|
|
20
|
+
// grows a line every time a module is added.
|
|
27
21
|
func main() {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// go-scaffold:config-checks
|
|
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}}
|
|
43
|
-
db, err := database.Open(cfg)
|
|
44
|
-
if err != nil {
|
|
45
|
-
logger.Error("open db", "error", err)
|
|
46
|
-
os.Exit(1)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
sqlDB, err := db.DB()
|
|
50
|
-
if err != nil {
|
|
51
|
-
logger.Error("db handle", "error", err)
|
|
52
|
-
os.Exit(1)
|
|
53
|
-
}
|
|
54
|
-
// go-scaffold:platform-init
|
|
55
|
-
|
|
56
|
-
if cfg.AutoMigrate {
|
|
57
|
-
// ponytail: AutoMigrate is for dev only (add-only, locks the table once data grows)
|
|
58
|
-
// prod: set AUTO_MIGRATE=false and run golang-migrate as versioned SQL instead
|
|
59
|
-
if err := db.AutoMigrate(
|
|
60
|
-
// go-scaffold:models
|
|
61
|
-
); err != nil {
|
|
62
|
-
logger.Error("migrate", "error", err)
|
|
63
|
-
os.Exit(1)
|
|
64
|
-
}
|
|
65
|
-
} else if err := database.CheckMigrationVersion(db); err != nil {
|
|
66
|
-
logger.Error("migration version check", "error", err)
|
|
22
|
+
if err := run(); err != nil {
|
|
23
|
+
// Correct before and after run() sets the JSON handler: slog's default
|
|
24
|
+
// writes to stderr, so a failure this early is still reported.
|
|
25
|
+
slog.Error("startup failed", "error", err)
|
|
67
26
|
os.Exit(1)
|
|
68
27
|
}
|
|
69
|
-
|
|
70
|
-
r := gin.New()
|
|
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}})
|
|
72
|
-
|
|
73
|
-
// liveness = is the process up / readiness = ready for traffic (can it reach the DB)
|
|
74
|
-
r.GET("/livez", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
|
|
75
|
-
r.GET("/readyz", func(c *gin.Context) {
|
|
76
|
-
if err := sqlDB.PingContext(c.Request.Context()); err != nil {
|
|
77
|
-
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
// go-scaffold:readyz-checks
|
|
81
|
-
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
82
|
-
})
|
|
83
|
-
{{#if observability}}
|
|
84
|
-
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
|
85
|
-
{{/if}}
|
|
86
|
-
{{#if openapiDocs}}
|
|
87
|
-
// hand-written spec at docs/openapi.yaml, split across sibling files (common/, health/,
|
|
88
|
-
// <domain>/) via relative $ref — serve the whole tree under one prefix so a client that
|
|
89
|
-
// resolves $ref over HTTP (Scalar, Swagger UI, Redoc, Hey API pointed at a URL) can reach
|
|
90
|
-
// them too; StaticFile on just the index file would 404 on every $ref it follows.
|
|
91
|
-
r.Static("/docs", "./docs")
|
|
92
|
-
{{/if}}
|
|
93
|
-
|
|
94
|
-
api := r.Group("/{{apiPrefix}}")
|
|
95
|
-
// go-scaffold:routes
|
|
96
|
-
_ = api // dropped once `generate module` registers the first route
|
|
97
|
-
|
|
98
|
-
srv := &http.Server{
|
|
99
|
-
Addr: ":" + cfg.Port,
|
|
100
|
-
Handler: r,
|
|
101
|
-
ReadTimeout: 10 * time.Second,
|
|
102
|
-
WriteTimeout: 10 * time.Second,
|
|
103
|
-
IdleTimeout: 60 * time.Second,
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// graceful shutdown: catch SIGINT/SIGTERM, let in-flight requests finish before closing
|
|
107
|
-
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
108
|
-
defer stop()
|
|
109
|
-
|
|
110
|
-
go func() {
|
|
111
|
-
logger.Info("listening", "addr", srv.Addr)
|
|
112
|
-
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
113
|
-
logger.Error("server", "error", err)
|
|
114
|
-
os.Exit(1)
|
|
115
|
-
}
|
|
116
|
-
}()
|
|
117
|
-
|
|
118
|
-
<-ctx.Done()
|
|
119
|
-
logger.Info("shutting down")
|
|
120
|
-
|
|
121
|
-
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
122
|
-
defer cancel()
|
|
123
|
-
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
124
|
-
logger.Error("shutdown", "error", err)
|
|
125
|
-
}
|
|
126
|
-
// go-scaffold:shutdown
|
|
127
|
-
logger.Info("stopped")
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
func parseLevel(s string) slog.Level {
|
|
131
|
-
switch s {
|
|
132
|
-
case "debug":
|
|
133
|
-
return slog.LevelDebug
|
|
134
|
-
case "warn":
|
|
135
|
-
return slog.LevelWarn
|
|
136
|
-
case "error":
|
|
137
|
-
return slog.LevelError
|
|
138
|
-
default:
|
|
139
|
-
return slog.LevelInfo
|
|
140
|
-
}
|
|
141
28
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"log/slog"
|
|
8
|
+
"net/http"
|
|
9
|
+
"os"
|
|
10
|
+
"os/signal"
|
|
11
|
+
"syscall"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"{{goModule}}/internal/platform/database"
|
|
15
|
+
"{{goModule}}/internal/shared/config"
|
|
16
|
+
"{{goModule}}/internal/shared/middleware"
|
|
17
|
+
// go-scaffold:imports
|
|
18
|
+
|
|
19
|
+
"github.com/gin-gonic/gin"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// run is this binary's composition root — the only function that sees every
|
|
23
|
+
// module and wires them to each other. Modules never import one another; a
|
|
24
|
+
// domain that needs another's behaviour declares a narrow interface and gets
|
|
25
|
+
// the concrete service from here (see docs/architect/patterns.md).
|
|
26
|
+
//
|
|
27
|
+
// It returns an error rather than calling os.Exit, so every defer below runs
|
|
28
|
+
// on the way out and main() holds the only exit in the binary.
|
|
29
|
+
//
|
|
30
|
+
// This is the file `go-scaffold generate module` and `go-scaffold add *` patch,
|
|
31
|
+
// at the // go-scaffold: markers. It grows with the system, which is what a
|
|
32
|
+
// composition root is for.
|
|
33
|
+
func run() error {
|
|
34
|
+
cfg := config.Load()
|
|
35
|
+
|
|
36
|
+
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
|
|
37
|
+
slog.SetDefault(logger)
|
|
38
|
+
// go-scaffold:config-checks
|
|
39
|
+
|
|
40
|
+
db, err := database.Open(cfg)
|
|
41
|
+
if err != nil {
|
|
42
|
+
return fmt.Errorf("open db: %w", err)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
sqlDB, err := db.DB()
|
|
46
|
+
if err != nil {
|
|
47
|
+
return fmt.Errorf("db handle: %w", err)
|
|
48
|
+
}
|
|
49
|
+
// go-scaffold:platform-init
|
|
50
|
+
|
|
51
|
+
// AutoMigrate creates tables but never the schema they live in — each
|
|
52
|
+
// domain gets its own (see model.go's TableName), so it has to exist
|
|
53
|
+
// before AutoMigrate runs. The versioned SQL migrations create the same
|
|
54
|
+
// schemas with CREATE SCHEMA IF NOT EXISTS, so this only matters for dev.
|
|
55
|
+
// go-scaffold:schemas
|
|
56
|
+
|
|
57
|
+
if cfg.AutoMigrate {
|
|
58
|
+
// ponytail: AutoMigrate is for dev only (add-only, locks the table once data grows)
|
|
59
|
+
// prod: set AUTO_MIGRATE=false and run golang-migrate as versioned SQL instead
|
|
60
|
+
if err := db.AutoMigrate(
|
|
61
|
+
// go-scaffold:models
|
|
62
|
+
); err != nil {
|
|
63
|
+
return fmt.Errorf("migrate: %w", err)
|
|
64
|
+
}
|
|
65
|
+
} else if err := database.CheckMigrationVersion(db); err != nil {
|
|
66
|
+
return fmt.Errorf("migration version check: %w", err)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
r := gin.New()
|
|
70
|
+
// gin trusts every proxy by default, which makes c.ClientIP() — and so
|
|
71
|
+
// anything keyed on it, like the auth rate limiter — whatever
|
|
72
|
+
// X-Forwarded-For the caller felt like sending. Empty TRUSTED_PROXIES
|
|
73
|
+
// means trust nobody and use the peer address; set it to your load
|
|
74
|
+
// balancer's CIDRs when you actually run behind one.
|
|
75
|
+
if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
|
|
76
|
+
return fmt.Errorf("trusted proxies: %w", err)
|
|
77
|
+
}
|
|
78
|
+
r.Use(gin.Recovery(), middleware.CORS(cfg.CORSAllowedOrigins), middleware.RequestID(), middleware.Logger(logger), middleware.Error(!cfg.IsProd()))
|
|
79
|
+
|
|
80
|
+
// liveness = is the process up / readiness = ready for traffic (can it reach the DB)
|
|
81
|
+
r.GET("/livez", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
|
|
82
|
+
r.GET("/readyz", func(c *gin.Context) {
|
|
83
|
+
if err := sqlDB.PingContext(c.Request.Context()); err != nil {
|
|
84
|
+
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
// go-scaffold:readyz-checks
|
|
88
|
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
89
|
+
})
|
|
90
|
+
// go-scaffold:extra-routes
|
|
91
|
+
{{#if openapiDocs}}
|
|
92
|
+
// hand-written spec at docs/openapi.yaml, split across sibling files (common/, health/,
|
|
93
|
+
// <domain>/) via relative $ref — serve the whole tree under one prefix so a client that
|
|
94
|
+
// resolves $ref over HTTP (Scalar, Swagger UI, Redoc, Hey API pointed at a URL) can reach
|
|
95
|
+
// them too; StaticFile on just the index file would 404 on every $ref it follows.
|
|
96
|
+
//
|
|
97
|
+
// Not in production: this serves your whole API surface — every path,
|
|
98
|
+
// parameter and schema — to anyone who asks. Publish the spec deliberately
|
|
99
|
+
// (`make openapi-bundle`) rather than by leaving this on.
|
|
100
|
+
if !cfg.IsProd() {
|
|
101
|
+
r.Static("/docs", "./docs")
|
|
102
|
+
}
|
|
103
|
+
{{/if}}
|
|
104
|
+
|
|
105
|
+
api := r.Group("/{{apiPrefix}}")
|
|
106
|
+
// go-scaffold:routes
|
|
107
|
+
_ = api // dropped once `generate module` registers the first route
|
|
108
|
+
|
|
109
|
+
srv := &http.Server{
|
|
110
|
+
Addr: ":" + cfg.Port,
|
|
111
|
+
Handler: r,
|
|
112
|
+
ReadTimeout: 10 * time.Second,
|
|
113
|
+
WriteTimeout: 10 * time.Second,
|
|
114
|
+
IdleTimeout: 60 * time.Second,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// graceful shutdown: catch SIGINT/SIGTERM, let in-flight requests finish before closing
|
|
118
|
+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
119
|
+
defer stop()
|
|
120
|
+
|
|
121
|
+
// Buffered: if ListenAndServe fails before anything reads this, the
|
|
122
|
+
// goroutine still exits instead of blocking forever on an unread channel.
|
|
123
|
+
serverErr := make(chan error, 1)
|
|
124
|
+
go func() {
|
|
125
|
+
logger.Info("listening", "addr", srv.Addr)
|
|
126
|
+
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
127
|
+
serverErr <- err
|
|
128
|
+
}
|
|
129
|
+
}()
|
|
130
|
+
|
|
131
|
+
// A port already in use used to kill the process from inside that
|
|
132
|
+
// goroutine, skipping every defer on the way out. Now it comes back here.
|
|
133
|
+
select {
|
|
134
|
+
case err := <-serverErr:
|
|
135
|
+
return fmt.Errorf("server: %w", err)
|
|
136
|
+
case <-ctx.Done():
|
|
137
|
+
}
|
|
138
|
+
logger.Info("shutting down")
|
|
139
|
+
|
|
140
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
141
|
+
defer cancel()
|
|
142
|
+
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
143
|
+
logger.Error("shutdown", "error", err)
|
|
144
|
+
}
|
|
145
|
+
// go-scaffold:shutdown
|
|
146
|
+
logger.Info("stopped")
|
|
147
|
+
return nil
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func parseLevel(s string) slog.Level {
|
|
151
|
+
switch s {
|
|
152
|
+
case "debug":
|
|
153
|
+
return slog.LevelDebug
|
|
154
|
+
case "warn":
|
|
155
|
+
return slog.LevelWarn
|
|
156
|
+
case "error":
|
|
157
|
+
return slog.LevelError
|
|
158
|
+
default:
|
|
159
|
+
return slog.LevelInfo
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -3,9 +3,9 @@ module {{goModule}}
|
|
|
3
3
|
go 1.25
|
|
4
4
|
|
|
5
5
|
require (
|
|
6
|
-
github.com/gin-gonic/gin v1.10.
|
|
7
|
-
github.com/go-playground/validator/v10 v10.
|
|
6
|
+
github.com/gin-gonic/gin v1.10.1
|
|
7
|
+
github.com/go-playground/validator/v10 v10.30.3
|
|
8
8
|
github.com/google/uuid v1.6.0
|
|
9
|
-
gorm.io/driver/postgres v1.
|
|
10
|
-
gorm.io/gorm v1.
|
|
9
|
+
gorm.io/driver/postgres v1.6.2
|
|
10
|
+
gorm.io/gorm v1.31.2
|
|
11
11
|
)
|