@nakedev/go-scaffold 0.1.3 → 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 +18 -14
- package/dist/commands/generate.js +2 -1
- package/dist/commands/method.js +63 -15
- package/dist/commands/remove.js +23 -24
- package/dist/index.js +5 -4
- package/dist/templates/module-manifest.js +2 -0
- package/dist/utils/method-patcher.js +80 -16
- package/dist/utils/module-location.js +22 -0
- package/dist/utils/naming.js +75 -12
- package/dist/utils/openapi-patcher.js +19 -1
- package/dist/utils/smoke-run.js +31 -0
- package/package.json +14 -5
- package/scripts/smoke-test.mjs +2058 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +18 -5
- package/templates/create/base/AGENTS.md.hbs +10 -12
- package/templates/create/base/Makefile.hbs +9 -3
- package/templates/create/base/README.md.hbs +18 -6
- package/templates/create/features/docs/openapi.yaml.hbs +4 -5
- package/templates/create/features/docs/patterns.md.hbs +9 -6
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/handler.go.hbs +18 -4
- package/templates/generate/module/handler_test.go.hbs +81 -62
- 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 +8 -2
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
- 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/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
|
@@ -14,8 +14,8 @@ jobs:
|
|
|
14
14
|
env:
|
|
15
15
|
POSTGRES_USER: postgres
|
|
16
16
|
POSTGRES_PASSWORD: postgres
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# repository integration tests run against this isolated database after
|
|
18
|
+
# applying the same versioned SQL migrations used in production
|
|
19
19
|
POSTGRES_DB: {{dbName}}_test
|
|
20
20
|
ports:
|
|
21
21
|
- 5432:5432
|
|
@@ -43,6 +43,19 @@ jobs:
|
|
|
43
43
|
with:
|
|
44
44
|
version: latest
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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,6 +3,7 @@ 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).
|
|
8
9
|
# override to run against another file, e.g. copy .env.example to .env.production,
|
|
@@ -47,14 +48,17 @@ tidy:
|
|
|
47
48
|
# connects to the always-present "postgres" maintenance DB to run CREATE DATABASE,
|
|
48
49
|
# since the target DB may not exist yet. Safe to re-run — skips if it already exists.
|
|
49
50
|
# \gexec only works read from stdin, not through -c, hence the pipe.
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
# 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.
|
|
53
53
|
# override DB_HOST/DB_PORT/DB_USER/DB_NAME/PGPASSWORD to point at a different server.
|
|
54
54
|
db-create:
|
|
55
55
|
@if command -v psql >/dev/null 2>&1; then \
|
|
56
56
|
echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
|
|
57
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"; \
|
|
58
62
|
elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
|
|
59
63
|
container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
|
|
60
64
|
if [ -z "$$container" ]; then \
|
|
@@ -72,6 +76,8 @@ db-create:
|
|
|
72
76
|
db-drop:
|
|
73
77
|
@if command -v psql >/dev/null 2>&1; then \
|
|
74
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"; \
|
|
75
81
|
elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
|
|
76
82
|
container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
|
|
77
83
|
if [ -z "$$container" ]; then \
|
|
@@ -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
|
|
@@ -129,17 +132,23 @@ make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql befo
|
|
|
129
132
|
|
|
130
133
|
## Tests
|
|
131
134
|
|
|
132
|
-
|
|
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.
|
|
133
138
|
|
|
134
|
-
|
|
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:
|
|
135
144
|
|
|
136
145
|
```bash
|
|
137
146
|
{{#if docker}}
|
|
138
147
|
make docker-up # local postgres first
|
|
139
148
|
{{/if}}
|
|
140
149
|
make db-create DB_NAME={{dbName}}_test
|
|
141
|
-
make
|
|
142
|
-
|
|
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 ./...
|
|
143
152
|
```
|
|
144
153
|
|
|
145
154
|
## Error payload
|
|
@@ -170,4 +179,7 @@ Import `docs/openapi.bundled.yaml` instead. Tools that resolve `$ref` over HTTP
|
|
|
170
179
|
go-scaffold generate module orders
|
|
171
180
|
```
|
|
172
181
|
|
|
173
|
-
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.
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# ponytail: hand-written, not generated from annotations — cheap while the
|
|
2
|
-
# endpoint count is low.
|
|
3
|
-
#
|
|
2
|
+
# endpoint count is low. Generated module/method stubs keep the index wired,
|
|
3
|
+
# but request/response schemas still need to be completed with the code.
|
|
4
4
|
# Switch to swaggo (comment-generated) if drift becomes a recurring problem.
|
|
5
5
|
# multi-file: split by domain module (health/<domain>) + common/ for shared
|
|
6
|
-
# pieces — this file is just the index. `generate module
|
|
7
|
-
#
|
|
8
|
-
# (endpoint-specific docs stay hand-written).
|
|
6
|
+
# pieces — this file is just the index. `generate module` and `generate method`
|
|
7
|
+
# add path entries automatically.
|
|
9
8
|
openapi: 3.0.3
|
|
10
9
|
info:
|
|
11
10
|
title: {{projectName}} API
|
|
@@ -17,8 +17,9 @@ internal/app/<domain>/
|
|
|
17
17
|
├── repository.go # the only place that touches the DB for this domain, every method takes ctx
|
|
18
18
|
├── service.go # business logic; declares the repository interface it needs (mockable in tests)
|
|
19
19
|
├── handler.go # HTTP: routing, bind, delegate, respond
|
|
20
|
-
├── service_test.go # unit test,
|
|
21
|
-
|
|
20
|
+
├── service_test.go # unit test, function-backed repository stub, no DB
|
|
21
|
+
├── handler_test.go # HTTP unit test, service stub, no DB
|
|
22
|
+
└── repository_test.go # Postgres integration test against migrated schema
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
### Model
|
|
@@ -137,10 +138,12 @@ placeholder fields.
|
|
|
137
138
|
- Unit and integration tests live in the same directory as the code under
|
|
138
139
|
test (Go convention) — never a separate `test/` folder. `test/` is only
|
|
139
140
|
for e2e black-box suites or fixtures.
|
|
140
|
-
- `service_test.go` uses a
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
- `service_test.go` uses a function-backed repository stub so each dependency
|
|
142
|
+
method has independent behavior and argument assertions.
|
|
143
|
+
- `handler_test.go` uses a service stub — HTTP tests never need Postgres.
|
|
144
|
+
- `repository_test.go` runs against a migrated Postgres database inside a
|
|
145
|
+
transaction that's rolled back after each test. Local runs may skip when
|
|
146
|
+
`TEST_DB_DSN` is unset; CI sets `REQUIRE_TEST_DB=true` so it must run.
|
|
144
147
|
|
|
145
148
|
## Docs Maintenance
|
|
146
149
|
|
|
@@ -29,9 +29,9 @@ put:
|
|
|
29
29
|
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
30
30
|
"404": { $ref: '../common/responses.yaml#/NotFoundError' }
|
|
31
31
|
delete:
|
|
32
|
-
summary: Delete {{
|
|
32
|
+
summary: Delete {{name}}
|
|
33
|
+
description: Idempotent — deleting an already-missing resource also returns 204.
|
|
33
34
|
operationId: delete{{pascalName}}
|
|
34
35
|
tags: [{{plural}}]
|
|
35
36
|
responses:
|
|
36
|
-
"204": { description: deleted }
|
|
37
|
-
"404": { $ref: '../common/responses.yaml#/NotFoundError' }
|
|
37
|
+
"204": { description: deleted or already absent }
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
package {{pkg}}
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"net/http"
|
|
5
6
|
|
|
7
|
+
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
6
8
|
"{{goModule}}/internal/shared/httpx"
|
|
7
9
|
{{#if auth}}
|
|
8
10
|
"{{goModule}}/internal/shared/middleware"
|
|
@@ -10,11 +12,23 @@ import (
|
|
|
10
12
|
"{{goModule}}/internal/shared/pagination"
|
|
11
13
|
|
|
12
14
|
"github.com/gin-gonic/gin"
|
|
15
|
+
"github.com/google/uuid"
|
|
13
16
|
)
|
|
14
17
|
|
|
18
|
+
// service is the narrow application API required by this HTTP adapter. Keeping
|
|
19
|
+
// the dependency as an interface makes handler tests fast and database-free.
|
|
20
|
+
type service interface {
|
|
21
|
+
Create(context.Context, createInput) (*model.{{pascalName}}, error)
|
|
22
|
+
List(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
23
|
+
Get(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
24
|
+
Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
|
|
25
|
+
Delete(context.Context, uuid.UUID) error
|
|
26
|
+
// go-scaffold:service-interface
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
// Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
|
|
16
30
|
type Handler struct {
|
|
17
|
-
svc
|
|
31
|
+
svc service
|
|
18
32
|
{{#if auth}}
|
|
19
33
|
jwtSecret string
|
|
20
34
|
{{/if}}
|
|
@@ -23,7 +37,7 @@ type Handler struct {
|
|
|
23
37
|
{{/if}}
|
|
24
38
|
}
|
|
25
39
|
|
|
26
|
-
func NewHandler(svc
|
|
40
|
+
func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
|
|
27
41
|
return &Handler{
|
|
28
42
|
svc: svc,
|
|
29
43
|
{{#if auth}}
|
|
@@ -52,7 +66,7 @@ func (h *Handler) create(c *gin.Context) {
|
|
|
52
66
|
c.Error(httpx.BindErr(err))
|
|
53
67
|
return
|
|
54
68
|
}
|
|
55
|
-
m, err := h.svc.Create(c.Request.Context())
|
|
69
|
+
m, err := h.svc.Create(c.Request.Context(), in)
|
|
56
70
|
if err != nil {
|
|
57
71
|
c.Error(err)
|
|
58
72
|
return
|
|
@@ -97,7 +111,7 @@ func (h *Handler) update(c *gin.Context) {
|
|
|
97
111
|
c.Error(httpx.BindErr(err))
|
|
98
112
|
return
|
|
99
113
|
}
|
|
100
|
-
m, err := h.svc.Update(c.Request.Context(), id)
|
|
114
|
+
m, err := h.svc.Update(c.Request.Context(), id, in)
|
|
101
115
|
if err != nil {
|
|
102
116
|
c.Error(err)
|
|
103
117
|
return
|
|
@@ -2,14 +2,9 @@ package {{pkg}}
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
|
-
{{#if permission}}
|
|
6
5
|
"context"
|
|
7
|
-
{{/if}}
|
|
8
|
-
"encoding/json"
|
|
9
6
|
"net/http"
|
|
10
7
|
"net/http/httptest"
|
|
11
|
-
"os"
|
|
12
|
-
"sync"
|
|
13
8
|
"testing"
|
|
14
9
|
{{#if auth}}
|
|
15
10
|
"time"
|
|
@@ -23,67 +18,77 @@ import (
|
|
|
23
18
|
"github.com/golang-jwt/jwt/v5"
|
|
24
19
|
{{/if}}
|
|
25
20
|
"github.com/google/uuid"
|
|
26
|
-
"gorm.io/driver/postgres"
|
|
27
|
-
"gorm.io/gorm"
|
|
28
21
|
)
|
|
29
22
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
)
|
|
23
|
+
// serviceStub keeps handler tests at the HTTP boundary. It exercises binding,
|
|
24
|
+
// routing, middleware, status codes, and serialization without a database.
|
|
25
|
+
type serviceStub struct {
|
|
26
|
+
// Embedding keeps this stub source-compatible when `generate method` adds a
|
|
27
|
+
// new operation to the handler's service interface. Base CRUD methods below
|
|
28
|
+
// still override the promoted concrete methods for focused unit tests.
|
|
29
|
+
*Service
|
|
30
|
+
createFn func(context.Context, createInput) (*model.{{pascalName}}, error)
|
|
31
|
+
listFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
32
|
+
getFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
33
|
+
updateFn func(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
|
|
34
|
+
deleteFn func(context.Context, uuid.UUID) error
|
|
35
|
+
}
|
|
41
36
|
|
|
42
|
-
func
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
testDBErr = testDB.AutoMigrate(&model.{{pascalName}}{})
|
|
53
|
-
}
|
|
54
|
-
})
|
|
55
|
-
if testDBErr != nil {
|
|
56
|
-
t.Skipf("postgres not ready (make db-create DB_NAME={{dbName}}_test, or set TEST_DB_DSN): %v", testDBErr)
|
|
37
|
+
func (s *serviceStub) Create(ctx context.Context, in createInput) (*model.{{pascalName}}, error) {
|
|
38
|
+
if s.createFn == nil {
|
|
39
|
+
panic("unexpected service.Create call")
|
|
40
|
+
}
|
|
41
|
+
return s.createFn(ctx, in)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (s *serviceStub) List(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
45
|
+
if s.listFn == nil {
|
|
46
|
+
panic("unexpected service.List call")
|
|
57
47
|
}
|
|
58
|
-
return
|
|
48
|
+
return s.listFn(ctx, limit, offset)
|
|
59
49
|
}
|
|
60
50
|
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
func (s *serviceStub) Get(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
52
|
+
if s.getFn == nil {
|
|
53
|
+
panic("unexpected service.Get call")
|
|
54
|
+
}
|
|
55
|
+
return s.getFn(ctx, id)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func (s *serviceStub) Update(ctx context.Context, id uuid.UUID, in updateInput) (*model.{{pascalName}}, error) {
|
|
59
|
+
if s.updateFn == nil {
|
|
60
|
+
panic("unexpected service.Update call")
|
|
61
|
+
}
|
|
62
|
+
return s.updateFn(ctx, id, in)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func (s *serviceStub) Delete(ctx context.Context, id uuid.UUID) error {
|
|
66
|
+
if s.deleteFn == nil {
|
|
67
|
+
panic("unexpected service.Delete call")
|
|
68
|
+
}
|
|
69
|
+
return s.deleteFn(ctx, id)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// go-scaffold:service-stub-methods
|
|
73
|
+
|
|
74
|
+
func setupHandlerTest(t *testing.T, svc service) *gin.Engine {
|
|
63
75
|
t.Helper()
|
|
64
76
|
gin.SetMode(gin.TestMode)
|
|
65
|
-
tx := dbForTest(t).Begin()
|
|
66
|
-
t.Cleanup(func() { tx.Rollback() })
|
|
67
77
|
r := gin.New()
|
|
68
78
|
r.Use(middleware.RequestID(), middleware.Error(true))
|
|
69
79
|
{{#if permission}}
|
|
70
|
-
//
|
|
71
|
-
// CRUD handler, not re-proving RBAC (that's covered by the role
|
|
72
|
-
// package's own tests), so the resolver doesn't need a real DB lookup.
|
|
80
|
+
// The handler unit test verifies route composition, not the role repository.
|
|
73
81
|
authz := middleware.NewAuthz(func(_ context.Context, _ string) (map[string]struct{}, error) {
|
|
74
82
|
return map[string]struct{}{"{{permission}}": {}}, nil
|
|
75
83
|
}, time.Minute)
|
|
76
84
|
{{/if}}
|
|
77
|
-
NewHandler(
|
|
85
|
+
NewHandler(svc{{#if auth}}, testJWTSecret{{/if}}{{#if permission}}, authz{{/if}}).Register(r)
|
|
78
86
|
return r
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
{{#if auth}}
|
|
82
90
|
const testJWTSecret = "test-secret"
|
|
83
91
|
|
|
84
|
-
// authHeader mints a valid access token signed with testJWTSecret — the
|
|
85
|
-
// route's own RequireAuth(testJWTSecret) (wired in via NewHandler above)
|
|
86
|
-
// verifies against the same secret, so this round-trips for real.
|
|
87
92
|
func authHeader() string {
|
|
88
93
|
claims := jwt.MapClaims{
|
|
89
94
|
"typ": "access",
|
|
@@ -97,8 +102,8 @@ func authHeader() string {
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
{{/if}}
|
|
100
|
-
func
|
|
101
|
-
req := httptest.NewRequest(method,
|
|
105
|
+
func doHandlerRequest(r *gin.Engine, method, requestPath, body string) *httptest.ResponseRecorder {
|
|
106
|
+
req := httptest.NewRequest(method, requestPath, bytes.NewBufferString(body))
|
|
102
107
|
req.Header.Set("Content-Type", "application/json")
|
|
103
108
|
{{#if auth}}
|
|
104
109
|
req.Header.Set("Authorization", authHeader())
|
|
@@ -109,39 +114,53 @@ func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
func TestHandler_Create_OK(t *testing.T) {
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
svc := &serviceStub{
|
|
118
|
+
createFn: func(context.Context, createInput) (*model.{{pascalName}}, error) {
|
|
119
|
+
return &model.{{pascalName}}{ID: uuid.New()}, nil
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
r := setupHandlerTest(t, svc)
|
|
123
|
+
|
|
124
|
+
w := doHandlerRequest(r, http.MethodPost, "/{{plural}}", `{}`)
|
|
125
|
+
|
|
114
126
|
if w.Code != http.StatusCreated {
|
|
115
127
|
t.Fatalf("want 201, got %d body=%s", w.Code, w.Body)
|
|
116
128
|
}
|
|
117
129
|
}
|
|
118
130
|
|
|
119
131
|
func TestHandler_Get_NotFound(t *testing.T) {
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
svc := &serviceStub{
|
|
133
|
+
getFn: func(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
|
|
134
|
+
return nil, errNotFound()
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
r := setupHandlerTest(t, svc)
|
|
138
|
+
|
|
139
|
+
w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/"+uuid.NewString(), "")
|
|
140
|
+
|
|
122
141
|
if w.Code != http.StatusNotFound {
|
|
123
142
|
t.Fatalf("want 404, got %d body=%s", w.Code, w.Body)
|
|
124
143
|
}
|
|
125
144
|
}
|
|
126
145
|
|
|
127
|
-
func
|
|
128
|
-
r :=
|
|
129
|
-
|
|
146
|
+
func TestHandler_Get_InvalidID_DoesNotCallService(t *testing.T) {
|
|
147
|
+
r := setupHandlerTest(t, &serviceStub{})
|
|
148
|
+
|
|
149
|
+
w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/not-a-uuid", "")
|
|
150
|
+
|
|
130
151
|
if w.Code != http.StatusBadRequest {
|
|
131
152
|
t.Fatalf("want 400, got %d body=%s", w.Code, w.Body)
|
|
132
153
|
}
|
|
133
154
|
}
|
|
134
155
|
|
|
135
156
|
func TestHandler_Delete_OK(t *testing.T) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
var body struct {
|
|
139
|
-
ID string `json:"id"`
|
|
157
|
+
svc := &serviceStub{
|
|
158
|
+
deleteFn: func(context.Context, uuid.UUID) error { return nil },
|
|
140
159
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
160
|
+
r := setupHandlerTest(t, svc)
|
|
161
|
+
|
|
162
|
+
w := doHandlerRequest(r, http.MethodDelete, "/{{plural}}/"+uuid.NewString(), "")
|
|
163
|
+
|
|
145
164
|
if w.Code != http.StatusNoContent {
|
|
146
165
|
t.Fatalf("want 204, got %d body=%s", w.Code, w.Body)
|
|
147
166
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
DROP TABLE IF EXISTS {{
|
|
1
|
+
DROP TABLE IF EXISTS {{tableName}};
|
|
@@ -8,9 +8,15 @@ import (
|
|
|
8
8
|
"github.com/gin-gonic/gin"
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
+
// service is intentionally empty until `generate method` adds the exact
|
|
12
|
+
// application operations required by this HTTP adapter.
|
|
13
|
+
type service interface {
|
|
14
|
+
// go-scaffold:service-interface
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
// Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
|
|
12
18
|
type Handler struct {
|
|
13
|
-
svc
|
|
19
|
+
svc service
|
|
14
20
|
{{#if auth}}
|
|
15
21
|
jwtSecret string
|
|
16
22
|
{{/if}}
|
|
@@ -19,7 +25,7 @@ type Handler struct {
|
|
|
19
25
|
{{/if}}
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
func NewHandler(svc
|
|
28
|
+
func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
|
|
23
29
|
return &Handler{
|
|
24
30
|
svc: svc,
|
|
25
31
|
{{#if auth}}
|