@nakedev/go-scaffold 0.1.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/README.md +223 -0
- package/bin/go-scaffold.js +2 -0
- package/dist/commands/create.js +57 -0
- package/dist/commands/generate.js +97 -0
- package/dist/commands/method.js +70 -0
- package/dist/commands/remove.js +72 -0
- package/dist/index.js +138 -0
- package/dist/prompts/create-wizard.js +43 -0
- package/dist/prompts/generate-wizard.js +68 -0
- package/dist/templates/create-manifest.js +110 -0
- package/dist/templates/module-manifest.js +28 -0
- package/dist/types.js +2 -0
- package/dist/utils/config.js +53 -0
- package/dist/utils/main-patcher.js +59 -0
- package/dist/utils/marker-patch.js +63 -0
- package/dist/utils/method-patcher.js +271 -0
- package/dist/utils/migrations.js +17 -0
- package/dist/utils/module-paths.js +33 -0
- package/dist/utils/naming.js +160 -0
- package/dist/utils/openapi-patcher.js +47 -0
- package/dist/utils/template-renderer.js +51 -0
- package/package.json +49 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +83 -0
- package/templates/create/base/.env.example.hbs +7 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +46 -0
- package/templates/create/base/.gitignore.hbs +5 -0
- package/templates/create/base/.golangci.yml.hbs +32 -0
- package/templates/create/base/.vscode/settings.json.hbs +11 -0
- package/templates/create/base/AGENTS.md.hbs +68 -0
- package/templates/create/base/CLAUDE.md.hbs +1 -0
- package/templates/create/base/Makefile.hbs +93 -0
- package/templates/create/base/README.md.hbs +143 -0
- package/templates/create/base/cmd/api/main.go.hbs +116 -0
- package/templates/create/base/go.mod.hbs +11 -0
- package/templates/create/base/internal/platform/database/database.go.hbs +28 -0
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +36 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +46 -0
- package/templates/create/base/internal/shared/dberr/dberr.go.hbs +28 -0
- package/templates/create/base/internal/shared/httpx/httpx.go.hbs +37 -0
- package/templates/create/base/internal/shared/id/id.go.hbs +16 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +33 -0
- package/templates/create/base/internal/shared/middleware/logger.go.hbs +23 -0
- package/templates/create/base/internal/shared/middleware/requestid.go.hbs +36 -0
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +39 -0
- package/templates/create/base/migrations/.gitkeep.hbs +0 -0
- package/templates/create/features/docker-compose.yml.hbs +14 -0
- package/templates/create/features/docs/architecture.md.hbs +99 -0
- package/templates/create/features/docs/common/parameters.yaml.hbs +13 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +20 -0
- package/templates/create/features/docs/common/schemas.yaml.hbs +23 -0
- package/templates/create/features/docs/health/health-livez.yaml.hbs +13 -0
- package/templates/create/features/docs/health/health-readyz.yaml.hbs +21 -0
- package/templates/create/features/docs/openapi.yaml.hbs +33 -0
- package/templates/create/features/docs/patterns.md.hbs +119 -0
- package/templates/create/features/docs/techstack.md.hbs +38 -0
- package/templates/generate/module/docs/collection.yaml.hbs +36 -0
- package/templates/generate/module/docs/item.yaml.hbs +37 -0
- package/templates/generate/module/docs/schemas.yaml.hbs +13 -0
- package/templates/generate/module/dto.go.hbs +29 -0
- package/templates/generate/module/errors.go.hbs +28 -0
- package/templates/generate/module/handler.go.hbs +103 -0
- package/templates/generate/module/handler_test.go.hbs +105 -0
- package/templates/generate/module/migration.down.sql.hbs +1 -0
- package/templates/generate/module/migration.up.sql.hbs +5 -0
- package/templates/generate/module/minimal/dto.go.hbs +26 -0
- package/templates/generate/module/minimal/handler.go.hbs +24 -0
- package/templates/generate/module/minimal/handler_test.go.hbs +70 -0
- package/templates/generate/module/minimal/service.go.hbs +45 -0
- package/templates/generate/module/minimal/service_test.go.hbs +54 -0
- package/templates/generate/module/model/model.go.hbs +20 -0
- package/templates/generate/module/repository.go.hbs +49 -0
- package/templates/generate/module/service.go.hbs +97 -0
- package/templates/generate/module/service_test.go.hbs +65 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: go-scaffold
|
|
3
|
+
description: >
|
|
4
|
+
Use whenever the user asks to add, create, scaffold, wire up, or generate a
|
|
5
|
+
new domain/feature/resource OR a new endpoint/method in THIS project —
|
|
6
|
+
including indirect or paraphrased requests that imply new backend surface
|
|
7
|
+
area without saying "module"/"method" literally, e.g. "add a products
|
|
8
|
+
feature", "let admins approve orders", "we need an endpoint for X", "look
|
|
9
|
+
up a user by email". This project was scaffolded by go-scaffold and has a
|
|
10
|
+
go-scaffold.config.json at its root — check for that file before assuming
|
|
11
|
+
this applies. Do NOT use for: business logic inside an already-generated
|
|
12
|
+
method body, model/DTO field edits, foreign keys/relations between
|
|
13
|
+
domains, bug fixes, refactors of existing code, or any project that lacks
|
|
14
|
+
go-scaffold.config.json at its root.
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# go-scaffold
|
|
18
|
+
|
|
19
|
+
This project's domain modules (model, dto, errors, repository, service,
|
|
20
|
+
handler) and their endpoints are generated with the `go-scaffold` CLI — do
|
|
21
|
+
not hand-write a new one. Hand-writing a new `internal/app/<name>/` package,
|
|
22
|
+
or adding a method by editing the handler/service directly, produces a shape
|
|
23
|
+
that doesn't match the rest of the codebase (missing route registration,
|
|
24
|
+
missing AutoMigrate wiring, a `repository` interface out of sync with its
|
|
25
|
+
`fakeRepo` test mock, inconsistent error-catalog naming, etc.).
|
|
26
|
+
|
|
27
|
+
## When to use this skill
|
|
28
|
+
|
|
29
|
+
- Any request for a new feature/resource/domain, even named informally
|
|
30
|
+
("add invoices", "we need a way to track X")
|
|
31
|
+
- Any request for a new endpoint on an existing feature, even phrased as a
|
|
32
|
+
capability ("let users do X", "admins should be able to Y", "add a lookup
|
|
33
|
+
by Z")
|
|
34
|
+
- The user explicitly says "generate", "scaffold", or names the CLI
|
|
35
|
+
|
|
36
|
+
## When NOT to use this skill
|
|
37
|
+
|
|
38
|
+
- Filling in business logic inside a method the CLI already generated
|
|
39
|
+
- Editing model fields, DTO validation, or existing method bodies
|
|
40
|
+
- Wiring a foreign key / relation between two domains
|
|
41
|
+
- Bug fixes and refactors
|
|
42
|
+
- The project has no `go-scaffold.config.json` at its root
|
|
43
|
+
|
|
44
|
+
## How to use it
|
|
45
|
+
|
|
46
|
+
Run from the project root:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
go-scaffold generate module <name>
|
|
50
|
+
go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`<name>` for a module should be a singular, lowercase, one-word domain noun
|
|
54
|
+
(e.g. `order`, not `Orders` or `order-item`) — the CLI derives the Go
|
|
55
|
+
package name, the pluralized REST route (`/orders`), and the table name
|
|
56
|
+
from it.
|
|
57
|
+
|
|
58
|
+
### `generate module <name>`
|
|
59
|
+
|
|
60
|
+
What you get: `internal/app/<name>/{model/,dto,errors,repository,service,handler,service_test,handler_test}.go`,
|
|
61
|
+
a route registered under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
|
|
62
|
+
in `cmd/api/main.go`, the model added to the `AutoMigrate` call, and a new
|
|
63
|
+
file in `migrations/`. There's no per-domain versioning — the route prefix
|
|
64
|
+
is a single project-wide choice made at `create` time.
|
|
65
|
+
|
|
66
|
+
### `generate method <module> <name>`
|
|
67
|
+
|
|
68
|
+
Patches the existing module in place — never overwrites a method with the
|
|
69
|
+
same name; if the name collides it asks for a different one instead. Route
|
|
70
|
+
and shape depend on `--type`:
|
|
71
|
+
|
|
72
|
+
| `--type` | Route | Notes |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `get --get-mode all` | `GET /<plural>/<kebab-name>` | list-style, reuses `FindAll` — TODO to add real filtering |
|
|
75
|
+
| `get --get-mode one --field <f>` | `GET /<plural>/<f>/:<f>` | adds a real `FindBy<F>` query to the repository (and its `repository` interface + `fakeRepo` test stub); `--field` can't be `id` |
|
|
76
|
+
| `post` | `POST /<plural>/<kebab-name>` | adds a body DTO; service body is a TODO stub (`apperror.NewInternal()` until implemented) |
|
|
77
|
+
| `put` / `patch` | `<VERB> /<plural>/:id/<kebab-name>` | finds the record by id, TODO before saving (safe no-op until implemented) |
|
|
78
|
+
| `delete` | `DELETE /<plural>/:id/<kebab-name>` | TODO stub (`apperror.NewInternal()` until implemented) |
|
|
79
|
+
|
|
80
|
+
What you still do by hand: real field names on the model/DTOs (the generated
|
|
81
|
+
ones are placeholders), any foreign key to another domain (see
|
|
82
|
+
`docs/architect/patterns.md` for the 3 rules), and the actual business logic
|
|
83
|
+
behind every `TODO` the CLI leaves.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
services:
|
|
12
|
+
postgres:
|
|
13
|
+
image: postgres:16
|
|
14
|
+
env:
|
|
15
|
+
POSTGRES_USER: postgres
|
|
16
|
+
POSTGRES_PASSWORD: postgres
|
|
17
|
+
POSTGRES_DB: {{dbName}}
|
|
18
|
+
ports:
|
|
19
|
+
- 5432:5432
|
|
20
|
+
options: >-
|
|
21
|
+
--health-cmd pg_isready
|
|
22
|
+
--health-interval 10s
|
|
23
|
+
--health-timeout 5s
|
|
24
|
+
--health-retries 5
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
|
|
28
|
+
- uses: actions/setup-go@v5
|
|
29
|
+
with:
|
|
30
|
+
go-version-file: go.mod
|
|
31
|
+
|
|
32
|
+
- run: go build ./...
|
|
33
|
+
|
|
34
|
+
- run: go vet ./...
|
|
35
|
+
|
|
36
|
+
- name: gofmt check
|
|
37
|
+
run: |
|
|
38
|
+
diff <(gofmt -l .) <(printf '')
|
|
39
|
+
|
|
40
|
+
- uses: golangci/golangci-lint-action@v6
|
|
41
|
+
with:
|
|
42
|
+
version: latest
|
|
43
|
+
|
|
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 ./...
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# golangci-lint v2 config format — v1's `run:`/`linters: enable:` + `issues: exclude-dirs:`
|
|
2
|
+
# top-level keys were replaced by `version: "2"` + `linters:`/`formatters:` with their own
|
|
3
|
+
# `exclusions:` blocks (see https://golangci-lint.run/docs/product/migration-guide/).
|
|
4
|
+
version: "2"
|
|
5
|
+
|
|
6
|
+
linters:
|
|
7
|
+
enable:
|
|
8
|
+
- errcheck
|
|
9
|
+
- govet
|
|
10
|
+
- ineffassign
|
|
11
|
+
- staticcheck
|
|
12
|
+
- unused
|
|
13
|
+
settings:
|
|
14
|
+
errcheck:
|
|
15
|
+
exclude-functions:
|
|
16
|
+
# gin.Context.Error()'s return value is intentionally never checked —
|
|
17
|
+
# it just appends to Context.Errors for middleware.Error() to render,
|
|
18
|
+
# the "handling" of the error already happened via `return` right after.
|
|
19
|
+
- (*github.com/gin-gonic/gin.Context).Error
|
|
20
|
+
exclusions:
|
|
21
|
+
paths:
|
|
22
|
+
- migrations
|
|
23
|
+
|
|
24
|
+
formatters:
|
|
25
|
+
# gofmt only, not goimports: the CLI's own gofmtTree() only runs `gofmt -w .`
|
|
26
|
+
# (goimports needs a separate binary install), so keep the lint config matching
|
|
27
|
+
# what actually formats generated code, or every fresh project starts pre-failing.
|
|
28
|
+
enable:
|
|
29
|
+
- gofmt
|
|
30
|
+
exclusions:
|
|
31
|
+
paths:
|
|
32
|
+
- migrations
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Agent Guidance: {{projectName}}
|
|
2
|
+
|
|
3
|
+
This project was scaffolded by `@nakedev/go-scaffold` and is maintained using
|
|
4
|
+
its `generate` subcommands — new domain modules and new endpoints are not
|
|
5
|
+
hand-written.
|
|
6
|
+
|
|
7
|
+
## Hard rule: use the CLI to add a new domain module or a new endpoint
|
|
8
|
+
|
|
9
|
+
If you are about to create a **new** domain (a new `internal/app/<name>/`
|
|
10
|
+
package with its own model/handler/service/repository), or add a **new**
|
|
11
|
+
endpoint to an existing one — stop and run the CLI instead:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
go-scaffold generate module <name>
|
|
15
|
+
go-scaffold generate method <module> <name> --type <get|post|put|patch|delete>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This applies **even when the request doesn't say "module"/"method" or name
|
|
19
|
+
the CLI at all**. Recognize indirect asks as generation work, for example:
|
|
20
|
+
|
|
21
|
+
- "add a products feature" → a new domain (`generate module products`)
|
|
22
|
+
- "let admins approve orders" → a new method on an existing module
|
|
23
|
+
(`generate method orders approve --type patch`)
|
|
24
|
+
- "we need an endpoint that lists overdue invoices" → a new `get` method
|
|
25
|
+
(`generate method invoices findOverdue --type get --get-mode all`)
|
|
26
|
+
- "add a way to look up a user by email" → a new `get` lookup
|
|
27
|
+
(`generate method users findByEmail --type get --get-mode one --field email`)
|
|
28
|
+
|
|
29
|
+
Run `go-scaffold generate module --help` / `generate method --help` before
|
|
30
|
+
hand-rolling anything that looks like scaffolding.
|
|
31
|
+
|
|
32
|
+
## What the CLI does NOT do — these are still done by hand, as normal
|
|
33
|
+
|
|
34
|
+
- Business logic inside a generated method (the CLI leaves a TODO-marked
|
|
35
|
+
stub — filling in real behavior is your job)
|
|
36
|
+
- Adding fields to a generated model/DTO beyond `id`/`created_at`/`updated_at`
|
|
37
|
+
- Foreign keys / relations between domains — add the column, the migration
|
|
38
|
+
`REFERENCES ... ON DELETE ...` clause, and the `dberr.IsForeignKey` mapping
|
|
39
|
+
yourself; see `docs/architect/patterns.md` for the 3 rules domains with a
|
|
40
|
+
relation must follow
|
|
41
|
+
- Bug fixes, refactors, or edits to existing logic
|
|
42
|
+
- Anything in a project that does **not** have `go-scaffold.config.json` at
|
|
43
|
+
its root — that means the project wasn't scaffolded by this CLI, and this
|
|
44
|
+
rule does not apply
|
|
45
|
+
|
|
46
|
+
## Command quick reference
|
|
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`
|
|
53
|
+
- `go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]` —
|
|
54
|
+
patches an *existing* module's handler + service (and repository, for a
|
|
55
|
+
`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)
|
|
63
|
+
- Every route in this project is grouped under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
|
|
64
|
+
(set once at `create` time via `--api-prefix`) — there is no per-domain
|
|
65
|
+
versioning; a breaking API change gets a new domain package or a new DTO
|
|
66
|
+
field, not a duplicated model
|
|
67
|
+
- Full architecture rationale for this project's configuration: see
|
|
68
|
+
`docs/architect/`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
DB_HOST ?= localhost
|
|
2
|
+
DB_PORT ?= 5432
|
|
3
|
+
DB_USER ?= postgres
|
|
4
|
+
DB_NAME ?= {{dbName}}
|
|
5
|
+
PGPASSWORD ?= postgres
|
|
6
|
+
|
|
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
|
+
|
|
9
|
+
run:
|
|
10
|
+
go run ./cmd/api
|
|
11
|
+
|
|
12
|
+
build:
|
|
13
|
+
go build -o bin/api ./cmd/api
|
|
14
|
+
|
|
15
|
+
test:
|
|
16
|
+
go test ./...
|
|
17
|
+
|
|
18
|
+
fmt:
|
|
19
|
+
gofmt -w .
|
|
20
|
+
|
|
21
|
+
vet:
|
|
22
|
+
go vet ./...
|
|
23
|
+
|
|
24
|
+
lint:
|
|
25
|
+
golangci-lint run
|
|
26
|
+
|
|
27
|
+
tidy:
|
|
28
|
+
go mod tidy
|
|
29
|
+
|
|
30
|
+
# creates the database itself (not the schema — AUTO_MIGRATE or migrate-up does that).
|
|
31
|
+
# connects to the always-present "postgres" maintenance DB to run CREATE DATABASE,
|
|
32
|
+
# since the target DB may not exist yet. Safe to re-run — skips if it already exists.
|
|
33
|
+
# \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.
|
|
37
|
+
# override DB_HOST/DB_PORT/DB_USER/DB_NAME/PGPASSWORD to point at a different server.
|
|
38
|
+
db-create:
|
|
39
|
+
@if command -v psql >/dev/null 2>&1; then \
|
|
40
|
+
echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
|
|
41
|
+
PGPASSWORD=$(PGPASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d postgres && echo "SUCCESS! database $(DB_NAME) is ready"; \
|
|
42
|
+
elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
|
|
43
|
+
container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
|
|
44
|
+
if [ -z "$$container" ]; then \
|
|
45
|
+
echo "psql not found and no container is publishing port $(DB_PORT) — install the PostgreSQL client (brew install libpq && brew link --force libpq), or start Postgres any way you like and re-run" >&2; \
|
|
46
|
+
exit 1; \
|
|
47
|
+
fi; \
|
|
48
|
+
echo "no local psql — using docker exec into $$container"; \
|
|
49
|
+
echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
|
|
50
|
+
docker exec -i -e PGPASSWORD=$(PGPASSWORD) $$container psql -U $(DB_USER) -d postgres && echo "SUCCESS! database $(DB_NAME) is ready"; \
|
|
51
|
+
else \
|
|
52
|
+
echo "psql not found — install the PostgreSQL client (brew install libpq && brew link --force libpq) to connect to $(DB_HOST):$(DB_PORT)" >&2; \
|
|
53
|
+
exit 1; \
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
db-drop:
|
|
57
|
+
@if command -v psql >/dev/null 2>&1; then \
|
|
58
|
+
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"; \
|
|
59
|
+
elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
|
|
60
|
+
container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
|
|
61
|
+
if [ -z "$$container" ]; then \
|
|
62
|
+
echo "psql not found and no container is publishing port $(DB_PORT) — install the PostgreSQL client (brew install libpq && brew link --force libpq), or start Postgres any way you like and re-run" >&2; \
|
|
63
|
+
exit 1; \
|
|
64
|
+
fi; \
|
|
65
|
+
docker exec -i -e PGPASSWORD=$(PGPASSWORD) $$container psql -U $(DB_USER) -d postgres -c "DROP DATABASE IF EXISTS $(DB_NAME);" && echo "SUCCESS! database $(DB_NAME) dropped"; \
|
|
66
|
+
else \
|
|
67
|
+
echo "psql not found — install the PostgreSQL client (brew install libpq && brew link --force libpq) to connect to $(DB_HOST):$(DB_PORT)" >&2; \
|
|
68
|
+
exit 1; \
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
migrate-up:
|
|
72
|
+
migrate -path migrations -database "$$DB_DSN" up
|
|
73
|
+
|
|
74
|
+
migrate-down:
|
|
75
|
+
migrate -path migrations -database "$$DB_DSN" down 1
|
|
76
|
+
{{#if openapiDocs}}
|
|
77
|
+
|
|
78
|
+
# docs/openapi.yaml is hand-written and split across sibling files via relative
|
|
79
|
+
# $ref (common/, health/, per-domain folders) — fine for tools that resolve
|
|
80
|
+
# $ref over HTTP (Scalar, Swagger UI, Redoc), but importers that read a local
|
|
81
|
+
# file as-is (Bruno, some Postman flows) never follow $ref and see zero routes.
|
|
82
|
+
# This bundles everything into one fully-resolved file for those tools.
|
|
83
|
+
openapi-bundle:
|
|
84
|
+
npx --yes @redocly/cli bundle docs/openapi.yaml -o docs/openapi.bundled.yaml
|
|
85
|
+
{{/if}}
|
|
86
|
+
{{#if docker}}
|
|
87
|
+
|
|
88
|
+
docker-up:
|
|
89
|
+
docker compose up -d
|
|
90
|
+
|
|
91
|
+
docker-down:
|
|
92
|
+
docker compose down
|
|
93
|
+
{{/if}}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
This is a bare skeleton — no domain modules yet. Add one with:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
go-scaffold generate module orders
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
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
|
+
|
|
13
|
+
## Layout
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
cmd/
|
|
17
|
+
└── api/
|
|
18
|
+
└── main.go # config, slog, graceful shutdown, wires domains — the only entry point for now
|
|
19
|
+
internal/
|
|
20
|
+
├── platform/ # talks to real external systems (DB, later: cache, queue, mail, ...)
|
|
21
|
+
│ └── database/ # opens the connection + pool (GORM)
|
|
22
|
+
├── shared/ # pure logic/framework glue, no I/O
|
|
23
|
+
│ ├── config/ # loads config from env
|
|
24
|
+
│ ├── apperror/ # central error type (status + payload)
|
|
25
|
+
│ ├── dberr/ # maps DB errors to constraint kind (IsDuplicate, IsForeignKey) — shared by every domain
|
|
26
|
+
│ ├── httpx/ # HTTP helpers shared by every domain (ParseID, BindErr)
|
|
27
|
+
│ ├── middleware/ # RequestID, Logger (slog), Error
|
|
28
|
+
│ └── pagination/ # parses ?limit=&offset=, response envelope
|
|
29
|
+
└── app/ # domain packages — empty until you `generate module`
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Run
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
{{#if docker}}
|
|
36
|
+
make docker-up # local Postgres (postgres:5432)
|
|
37
|
+
{{/if}}
|
|
38
|
+
make db-create # create the {{dbName}} database itself (once — safe to re-run)
|
|
39
|
+
go mod tidy
|
|
40
|
+
make run # AUTO_MIGRATE=true creates the schema automatically in dev
|
|
41
|
+
# or override with env (the app reads os.Getenv directly, see .env.example):
|
|
42
|
+
export $(grep -v '^#' .env.example | xargs) && make run
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`make db-create` connects to Postgres at `DB_HOST`/`DB_PORT`/`DB_USER` (default:
|
|
46
|
+
`localhost`/`5432`/`postgres`, matching `.env.example`) using the `psql`
|
|
47
|
+
client — works the same whether Postgres came from `make docker-up` or an
|
|
48
|
+
existing instance you already have running. Override any of
|
|
49
|
+
`DB_HOST`/`DB_PORT`/`DB_USER`/`DB_NAME`/`PGPASSWORD` to point at a different
|
|
50
|
+
server. If `psql` isn't installed locally and `DB_HOST` is `localhost`, it
|
|
51
|
+
falls back to `docker exec` into whichever container is publishing
|
|
52
|
+
`DB_PORT` — this project's own Postgres (`make docker-up` first) or any
|
|
53
|
+
other Postgres container you already have running.
|
|
54
|
+
|
|
55
|
+
Server listens on `:8080` (override with `PORT`). Ctrl+C = graceful shutdown.
|
|
56
|
+
|
|
57
|
+
## Makefile
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
make run # go run ./cmd/api
|
|
61
|
+
make build # go build -o bin/api ./cmd/api
|
|
62
|
+
make test # go test ./...
|
|
63
|
+
make fmt # gofmt -w .
|
|
64
|
+
make vet # go vet ./...
|
|
65
|
+
make lint # golangci-lint run (see .golangci.yml)
|
|
66
|
+
make tidy # go mod tidy
|
|
67
|
+
make db-create # create the database itself (safe to re-run)
|
|
68
|
+
make db-drop # drop the database
|
|
69
|
+
make migrate-up # apply migrations (needs $DB_DSN)
|
|
70
|
+
make migrate-down # roll back one migration
|
|
71
|
+
{{#if docker}}make docker-up # docker compose up -d
|
|
72
|
+
make docker-down # docker compose down
|
|
73
|
+
{{/if}}```
|
|
74
|
+
|
|
75
|
+
## Migrations
|
|
76
|
+
|
|
77
|
+
Schema is managed with [golang-migrate](https://github.com/golang-migrate/migrate), files live in `migrations/`.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
brew install golang-migrate
|
|
81
|
+
# or: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
|
82
|
+
|
|
83
|
+
migrate -path migrations -database "$DB_DSN" up
|
|
84
|
+
migrate -path migrations -database "$DB_DSN" down 1
|
|
85
|
+
migrate create -ext sql -dir migrations -seq add_something
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**dev:** leave `AUTO_MIGRATE=true` (default) so GORM's AutoMigrate creates the schema quickly.
|
|
89
|
+
**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.
|
|
90
|
+
|
|
91
|
+
## Env vars
|
|
92
|
+
|
|
93
|
+
| var | default | notes |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `PORT` | `8080` | |
|
|
96
|
+
| `DB_DSN` | `postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable` | used by both GORM and the `migrate` CLI |
|
|
97
|
+
| `LOG_LEVEL` | `info` | debug/info/warn/error |
|
|
98
|
+
| `AUTO_MIGRATE` | `true` | disable in prod and use `migrate up` (see Migrations) |
|
|
99
|
+
| `DB_MAX_OPEN_CONNS` | `10` | |
|
|
100
|
+
| `DB_MAX_IDLE_CONNS` | `10` | |
|
|
101
|
+
| `DB_CONN_MAX_LIFETIME_MIN` | `5` | minutes |
|
|
102
|
+
|
|
103
|
+
## Tests
|
|
104
|
+
|
|
105
|
+
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).
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
{{#if docker}}
|
|
109
|
+
make docker-up # local postgres first
|
|
110
|
+
{{/if}}
|
|
111
|
+
make test
|
|
112
|
+
# point at a different test DB: TEST_DB_DSN=postgres://... go test ./...
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Error payload
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{"error":{"code":"VALIDATION_ERROR","message":"invalid input","details":{"email":"email"},"request_id":"a1b2..."}}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`code` is machine-readable, `details` names the field that failed, `request_id` correlates with server logs (header `X-Request-ID`).
|
|
122
|
+
{{#if openapiDocs}}
|
|
123
|
+
|
|
124
|
+
## API spec
|
|
125
|
+
|
|
126
|
+
`docs/openapi.yaml` is hand-written and served, along with every file it `$ref`s (`common/`, `health/`, per-domain folders), under `/docs` while the server runs — point a renderer (Scalar, Swagger UI, Redoc) or a client generator (Hey API) at `http://localhost:8080/docs/openapi.yaml`. Update the spec by hand whenever an endpoint/DTO changes.
|
|
127
|
+
|
|
128
|
+
Some tools don't resolve external `$ref`s when importing a local file — they read it as-is and see zero routes (Bruno's "Import Collection" does this; some Postman flows too). For those, bundle the spec into one fully-resolved file first:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
make openapi-bundle # writes docs/openapi.bundled.yaml (gitignored — regenerate anytime)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Import `docs/openapi.bundled.yaml` instead. Tools that resolve `$ref` over HTTP or when reading a URL (Scalar, Swagger UI, Redoc) don't need this — point them at the live `/docs/openapi.yaml` URL above.
|
|
135
|
+
{{/if}}
|
|
136
|
+
|
|
137
|
+
## Adding a domain
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
go-scaffold generate module orders
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
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.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"log/slog"
|
|
7
|
+
"net/http"
|
|
8
|
+
"os"
|
|
9
|
+
"os/signal"
|
|
10
|
+
"syscall"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"{{goModule}}/internal/platform/database"
|
|
14
|
+
"{{goModule}}/internal/shared/config"
|
|
15
|
+
"{{goModule}}/internal/shared/middleware"
|
|
16
|
+
// go-scaffold:imports
|
|
17
|
+
|
|
18
|
+
"github.com/gin-gonic/gin"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
func main() {
|
|
22
|
+
cfg := config.Load()
|
|
23
|
+
|
|
24
|
+
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
|
|
25
|
+
slog.SetDefault(logger)
|
|
26
|
+
|
|
27
|
+
db, err := database.Open(cfg)
|
|
28
|
+
if err != nil {
|
|
29
|
+
logger.Error("open db", "error", err)
|
|
30
|
+
os.Exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
sqlDB, err := db.DB()
|
|
34
|
+
if err != nil {
|
|
35
|
+
logger.Error("db handle", "error", err)
|
|
36
|
+
os.Exit(1)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if cfg.AutoMigrate {
|
|
40
|
+
// ponytail: AutoMigrate is for dev only (add-only, locks the table once data grows)
|
|
41
|
+
// prod: set AUTO_MIGRATE=false and run golang-migrate as versioned SQL instead
|
|
42
|
+
if err := db.AutoMigrate(
|
|
43
|
+
// go-scaffold:models
|
|
44
|
+
); err != nil {
|
|
45
|
+
logger.Error("migrate", "error", err)
|
|
46
|
+
os.Exit(1)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
r := gin.New()
|
|
51
|
+
r.Use(gin.Recovery(), middleware.RequestID(), middleware.Logger(logger), middleware.Error())
|
|
52
|
+
|
|
53
|
+
// liveness = is the process up / readiness = ready for traffic (can it reach the DB)
|
|
54
|
+
r.GET("/livez", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
|
|
55
|
+
r.GET("/readyz", func(c *gin.Context) {
|
|
56
|
+
if err := sqlDB.PingContext(c.Request.Context()); err != nil {
|
|
57
|
+
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
61
|
+
})
|
|
62
|
+
{{#if openapiDocs}}
|
|
63
|
+
// hand-written spec at docs/openapi.yaml, split across sibling files (common/, health/,
|
|
64
|
+
// <domain>/) via relative $ref — serve the whole tree under one prefix so a client that
|
|
65
|
+
// resolves $ref over HTTP (Scalar, Swagger UI, Redoc, Hey API pointed at a URL) can reach
|
|
66
|
+
// them too; StaticFile on just the index file would 404 on every $ref it follows.
|
|
67
|
+
r.Static("/docs", "./docs")
|
|
68
|
+
{{/if}}
|
|
69
|
+
|
|
70
|
+
api := r.Group("/{{apiPrefix}}")
|
|
71
|
+
// go-scaffold:routes
|
|
72
|
+
_ = api // dropped once `generate module` registers the first route
|
|
73
|
+
|
|
74
|
+
srv := &http.Server{
|
|
75
|
+
Addr: ":" + cfg.Port,
|
|
76
|
+
Handler: r,
|
|
77
|
+
ReadTimeout: 10 * time.Second,
|
|
78
|
+
WriteTimeout: 10 * time.Second,
|
|
79
|
+
IdleTimeout: 60 * time.Second,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// graceful shutdown: catch SIGINT/SIGTERM, let in-flight requests finish before closing
|
|
83
|
+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
84
|
+
defer stop()
|
|
85
|
+
|
|
86
|
+
go func() {
|
|
87
|
+
logger.Info("listening", "addr", srv.Addr)
|
|
88
|
+
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
89
|
+
logger.Error("server", "error", err)
|
|
90
|
+
os.Exit(1)
|
|
91
|
+
}
|
|
92
|
+
}()
|
|
93
|
+
|
|
94
|
+
<-ctx.Done()
|
|
95
|
+
logger.Info("shutting down")
|
|
96
|
+
|
|
97
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
98
|
+
defer cancel()
|
|
99
|
+
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
100
|
+
logger.Error("shutdown", "error", err)
|
|
101
|
+
}
|
|
102
|
+
logger.Info("stopped")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func parseLevel(s string) slog.Level {
|
|
106
|
+
switch s {
|
|
107
|
+
case "debug":
|
|
108
|
+
return slog.LevelDebug
|
|
109
|
+
case "warn":
|
|
110
|
+
return slog.LevelWarn
|
|
111
|
+
case "error":
|
|
112
|
+
return slog.LevelError
|
|
113
|
+
default:
|
|
114
|
+
return slog.LevelInfo
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package database
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"{{goModule}}/internal/shared/config"
|
|
5
|
+
|
|
6
|
+
"gorm.io/driver/postgres"
|
|
7
|
+
"gorm.io/gorm"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Open connects to Postgres and sets the connection pool — it talks to a
|
|
11
|
+
// real external system, so it lives in platform/, not shared/.
|
|
12
|
+
// Schema is managed via golang-migrate (migrations/) in prod, not AutoMigrate — see README.
|
|
13
|
+
func Open(cfg config.Config) (*gorm.DB, error) {
|
|
14
|
+
db, err := gorm.Open(postgres.Open(cfg.DBDSN), &gorm.Config{TranslateError: true})
|
|
15
|
+
if err != nil {
|
|
16
|
+
return nil, err
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
sqlDB, err := db.DB()
|
|
20
|
+
if err != nil {
|
|
21
|
+
return nil, err
|
|
22
|
+
}
|
|
23
|
+
sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns)
|
|
24
|
+
sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns)
|
|
25
|
+
sqlDB.SetConnMaxLifetime(cfg.DBConnMaxLifetime)
|
|
26
|
+
|
|
27
|
+
return db, nil
|
|
28
|
+
}
|