@nakedev/go-scaffold 0.1.3 → 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 +143 -50
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +23 -12
- package/dist/commands/method.js +94 -17
- 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 +368 -64
- 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/module-manifest.js +2 -0
- 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 +97 -18
- package/dist/utils/module-location.js +58 -0
- package/dist/utils/naming.js +125 -14
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/openapi-patcher.js +19 -1
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/dist/utils/smoke-run.js +31 -0
- package/package.json +11 -4
- 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 +29 -6
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +14 -12
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +52 -16
- package/templates/create/base/README.md.hbs +61 -12
- 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 +4 -9
- package/templates/create/features/docs/patterns.md.hbs +91 -14
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/docs/item.yaml.hbs +3 -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.go.hbs +18 -4
- package/templates/generate/module/handler_test.go.hbs +88 -62
- 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/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 +16 -0
- 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 +109 -0
- package/templates/generate/module/service.go.hbs +15 -4
- package/templates/generate/module/service_test.go.hbs +113 -17
- package/dist/commands/remove.js +0 -89
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
|
@@ -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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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")
|
|
47
|
+
}
|
|
48
|
+
return s.listFn(ctx, limit, offset)
|
|
49
|
+
}
|
|
50
|
+
|
|
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")
|
|
57
68
|
}
|
|
58
|
-
return
|
|
69
|
+
return s.deleteFn(ctx, id)
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
//
|
|
62
|
-
|
|
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())
|
|
@@ -108,40 +113,61 @@ func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
|
|
|
108
113
|
return w
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
// createBody is the JSON these tests POST. It starts empty because createInput
|
|
117
|
+
// starts empty — add a value here for every field you add to createInput in
|
|
118
|
+
// dto.go. Miss that and this test fails with a 400 the moment one of those
|
|
119
|
+
// fields is `binding:"required"`: the handler is fine, the fixture just no
|
|
120
|
+
// longer satisfies it.
|
|
121
|
+
const createBody = `{}`
|
|
122
|
+
|
|
111
123
|
func TestHandler_Create_OK(t *testing.T) {
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
svc := &serviceStub{
|
|
125
|
+
createFn: func(context.Context, createInput) (*model.{{pascalName}}, error) {
|
|
126
|
+
return &model.{{pascalName}}{ID: uuid.New()}, nil
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
r := setupHandlerTest(t, svc)
|
|
130
|
+
|
|
131
|
+
w := doHandlerRequest(r, http.MethodPost, "/{{plural}}", createBody)
|
|
132
|
+
|
|
114
133
|
if w.Code != http.StatusCreated {
|
|
115
134
|
t.Fatalf("want 201, got %d body=%s", w.Code, w.Body)
|
|
116
135
|
}
|
|
117
136
|
}
|
|
118
137
|
|
|
119
138
|
func TestHandler_Get_NotFound(t *testing.T) {
|
|
120
|
-
|
|
121
|
-
|
|
139
|
+
svc := &serviceStub{
|
|
140
|
+
getFn: func(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
|
|
141
|
+
return nil, errNotFound()
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
r := setupHandlerTest(t, svc)
|
|
145
|
+
|
|
146
|
+
w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/"+uuid.NewString(), "")
|
|
147
|
+
|
|
122
148
|
if w.Code != http.StatusNotFound {
|
|
123
149
|
t.Fatalf("want 404, got %d body=%s", w.Code, w.Body)
|
|
124
150
|
}
|
|
125
151
|
}
|
|
126
152
|
|
|
127
|
-
func
|
|
128
|
-
r :=
|
|
129
|
-
|
|
153
|
+
func TestHandler_Get_InvalidID_DoesNotCallService(t *testing.T) {
|
|
154
|
+
r := setupHandlerTest(t, &serviceStub{})
|
|
155
|
+
|
|
156
|
+
w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/not-a-uuid", "")
|
|
157
|
+
|
|
130
158
|
if w.Code != http.StatusBadRequest {
|
|
131
159
|
t.Fatalf("want 400, got %d body=%s", w.Code, w.Body)
|
|
132
160
|
}
|
|
133
161
|
}
|
|
134
162
|
|
|
135
163
|
func TestHandler_Delete_OK(t *testing.T) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
var body struct {
|
|
139
|
-
ID string `json:"id"`
|
|
164
|
+
svc := &serviceStub{
|
|
165
|
+
deleteFn: func(context.Context, uuid.UUID) error { return nil },
|
|
140
166
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
167
|
+
r := setupHandlerTest(t, svc)
|
|
168
|
+
|
|
169
|
+
w := doHandlerRequest(r, http.MethodDelete, "/{{plural}}/"+uuid.NewString(), "")
|
|
170
|
+
|
|
145
171
|
if w.Code != http.StatusNoContent {
|
|
146
172
|
t.Fatalf("want 204, got %d body=%s", w.Code, w.Body)
|
|
147
173
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
-- one schema per domain — see model.go's TableName for why
|
|
2
|
+
CREATE SCHEMA IF NOT EXISTS {{schemaName}};
|
|
3
|
+
|
|
4
|
+
CREATE TABLE {{schemaName}}.{{tableName}} (
|
|
2
5
|
id UUID PRIMARY KEY,
|
|
3
6
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
4
|
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
7
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
8
|
+
-- bumped on every update; an update that carries an older value is refused
|
|
9
|
+
version INTEGER NOT NULL DEFAULT 1
|
|
5
10
|
);
|
|
@@ -16,11 +16,13 @@ import (
|
|
|
16
16
|
type response struct {
|
|
17
17
|
ID uuid.UUID `json:"id"`
|
|
18
18
|
CreatedAt time.Time `json:"created_at"`
|
|
19
|
+
// clients send this back on an update so a concurrent save can be detected
|
|
20
|
+
Version int `json:"version"`
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
//nolint:unused
|
|
22
24
|
func toResponse(m *model.{{pascalName}}) response {
|
|
23
|
-
return response{ID: m.ID, CreatedAt: m.CreatedAt}
|
|
25
|
+
return response{ID: m.ID, CreatedAt: m.CreatedAt, Version: m.Version}
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
// go-scaffold:dto
|
|
@@ -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}}
|
|
@@ -1,117 +1,10 @@
|
|
|
1
1
|
package {{pkg}}
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"context"
|
|
7
|
-
{{/if}}
|
|
8
|
-
"net/http/httptest"
|
|
9
|
-
"os"
|
|
10
|
-
"sync"
|
|
11
|
-
"testing"
|
|
12
|
-
{{#if auth}}
|
|
13
|
-
"time"
|
|
14
|
-
{{/if}}
|
|
15
|
-
|
|
16
|
-
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
17
|
-
"{{goModule}}/internal/shared/middleware"
|
|
18
|
-
|
|
19
|
-
"gorm.io/gorm"
|
|
20
|
-
|
|
21
|
-
"github.com/gin-gonic/gin"
|
|
22
|
-
{{#if auth}}
|
|
23
|
-
"github.com/golang-jwt/jwt/v5"
|
|
24
|
-
"github.com/google/uuid"
|
|
25
|
-
{{/if}}
|
|
26
|
-
"gorm.io/driver/postgres"
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
// integration test harness, backed by real Postgres (same engine as prod, no sqlite) — skips if
|
|
30
|
-
// the DB isn't reachable. minimal module: no routes yet, so no tests reference this yet — kept
|
|
31
|
-
// ready for once `generate method` adds endpoints.
|
|
32
|
-
// Runs against its own {{dbName}}_test database, never the one DB_DSN points at: the
|
|
33
|
-
// harness does DropTable+AutoMigrate on every run, which would otherwise wipe the
|
|
34
|
-
// schema `make migrate-up` built in your dev DB (FK constraints, seed data and all).
|
|
35
|
-
// Create it once with `make db-create DB_NAME={{dbName}}_test`, or point TEST_DB_DSN
|
|
36
|
-
// somewhere else.
|
|
37
|
-
//nolint:unused
|
|
38
|
-
var (
|
|
39
|
-
testDBOnce sync.Once
|
|
40
|
-
testDB *gorm.DB
|
|
41
|
-
testDBErr error
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
//nolint:unused
|
|
45
|
-
func dbForTest(t *testing.T) *gorm.DB {
|
|
46
|
-
t.Helper()
|
|
47
|
-
testDBOnce.Do(func() {
|
|
48
|
-
dsn := os.Getenv("TEST_DB_DSN")
|
|
49
|
-
if dsn == "" {
|
|
50
|
-
dsn = "postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable"
|
|
51
|
-
}
|
|
52
|
-
if testDB, testDBErr = gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}); testDBErr == nil {
|
|
53
|
-
_ = testDB.Migrator().DropTable(&model.{{pascalName}}{})
|
|
54
|
-
testDBErr = testDB.AutoMigrate(&model.{{pascalName}}{})
|
|
55
|
-
}
|
|
56
|
-
})
|
|
57
|
-
if testDBErr != nil {
|
|
58
|
-
t.Skipf("postgres not ready (make db-create DB_NAME={{dbName}}_test, or set TEST_DB_DSN): %v", testDBErr)
|
|
59
|
-
}
|
|
60
|
-
return testDB
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// setup builds the full stack on a transaction that's rolled back at the end → each test is isolated, no leftover rows
|
|
3
|
+
// serviceStub embeds the concrete service so a generated method automatically
|
|
4
|
+
// satisfies the handler's narrow service interface. Add explicit function-backed
|
|
5
|
+
// overrides here when writing focused handler tests for that method.
|
|
64
6
|
//
|
|
65
7
|
//nolint:unused
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
gin.SetMode(gin.TestMode)
|
|
69
|
-
tx := dbForTest(t).Begin()
|
|
70
|
-
t.Cleanup(func() { tx.Rollback() })
|
|
71
|
-
r := gin.New()
|
|
72
|
-
r.Use(middleware.RequestID(), middleware.Error(true))
|
|
73
|
-
{{#if permission}}
|
|
74
|
-
// stub authz: always grants "{{permission}}" — this test is about the
|
|
75
|
-
// CRUD handler, not re-proving RBAC (that's covered by the role
|
|
76
|
-
// package's own tests), so the resolver doesn't need a real DB lookup.
|
|
77
|
-
authz := middleware.NewAuthz(func(_ context.Context, _ string) (map[string]struct{}, error) {
|
|
78
|
-
return map[string]struct{}{"{{permission}}": {}}, nil
|
|
79
|
-
}, time.Minute)
|
|
80
|
-
{{/if}}
|
|
81
|
-
NewHandler(NewService(NewRepository(tx)){{#if auth}}, testJWTSecret{{/if}}{{#if permission}}, authz{{/if}}).Register(r)
|
|
82
|
-
return r
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
{{#if auth}}
|
|
86
|
-
//nolint:unused
|
|
87
|
-
const testJWTSecret = "test-secret"
|
|
88
|
-
|
|
89
|
-
// authHeader mints a valid access token signed with testJWTSecret — the
|
|
90
|
-
// route's own RequireAuth(testJWTSecret) (wired in via NewHandler above)
|
|
91
|
-
// verifies against the same secret, so this round-trips for real.
|
|
92
|
-
//
|
|
93
|
-
//nolint:unused
|
|
94
|
-
func authHeader() string {
|
|
95
|
-
claims := jwt.MapClaims{
|
|
96
|
-
"typ": "access",
|
|
97
|
-
"sub": uuid.NewString(),
|
|
98
|
-
"role": "staff",
|
|
99
|
-
"exp": time.Now().Add(time.Hour).Unix(),
|
|
100
|
-
"iat": time.Now().Unix(),
|
|
101
|
-
}
|
|
102
|
-
tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testJWTSecret))
|
|
103
|
-
return "Bearer " + tok
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
{{/if}}
|
|
107
|
-
//nolint:unused
|
|
108
|
-
func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
|
|
109
|
-
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
|
110
|
-
req.Header.Set("Content-Type", "application/json")
|
|
111
|
-
{{#if auth}}
|
|
112
|
-
req.Header.Set("Authorization", authHeader())
|
|
113
|
-
{{/if}}
|
|
114
|
-
w := httptest.NewRecorder()
|
|
115
|
-
r.ServeHTTP(w, req)
|
|
116
|
-
return w
|
|
8
|
+
type serviceStub struct {
|
|
9
|
+
*Service
|
|
117
10
|
}
|
|
@@ -11,37 +11,60 @@ import (
|
|
|
11
11
|
"github.com/google/uuid"
|
|
12
12
|
)
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// for a `get --get-mode one --field` lookup)
|
|
14
|
+
// repositoryStub is ready for focused use-case tests added alongside generated
|
|
15
|
+
// methods. Every unset dependency panics so unexpected orchestration is visible.
|
|
16
|
+
//
|
|
18
17
|
//nolint:unused
|
|
19
|
-
type
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
type repositoryStub struct {
|
|
19
|
+
createFn func(context.Context, *model.{{pascalName}}) error
|
|
20
|
+
findAllFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
21
|
+
findByIDFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
22
|
+
updateFn func(context.Context, *model.{{pascalName}}) error
|
|
23
|
+
deleteFn func(context.Context, uuid.UUID) error
|
|
24
|
+
// go-scaffold:repository-stub-fields
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
//nolint:unused
|
|
25
|
-
func (
|
|
28
|
+
func (s *repositoryStub) Create(ctx context.Context, m *model.{{pascalName}}) error {
|
|
29
|
+
if s.createFn == nil {
|
|
30
|
+
panic("unexpected repository.Create call")
|
|
31
|
+
}
|
|
32
|
+
return s.createFn(ctx, m)
|
|
33
|
+
}
|
|
26
34
|
|
|
27
35
|
//nolint:unused
|
|
28
|
-
func (
|
|
36
|
+
func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
37
|
+
if s.findAllFn == nil {
|
|
38
|
+
panic("unexpected repository.FindAll call")
|
|
39
|
+
}
|
|
40
|
+
return s.findAllFn(ctx, limit, offset)
|
|
41
|
+
}
|
|
29
42
|
|
|
30
43
|
//nolint:unused
|
|
31
|
-
func (
|
|
32
|
-
if
|
|
33
|
-
|
|
44
|
+
func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
45
|
+
if s.findByIDFn == nil {
|
|
46
|
+
panic("unexpected repository.FindByID call")
|
|
34
47
|
}
|
|
35
|
-
return
|
|
48
|
+
return s.findByIDFn(ctx, id)
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
//nolint:unused
|
|
39
|
-
func (
|
|
52
|
+
func (s *repositoryStub) Update(ctx context.Context, m *model.{{pascalName}}) error {
|
|
53
|
+
if s.updateFn == nil {
|
|
54
|
+
panic("unexpected repository.Update call")
|
|
55
|
+
}
|
|
56
|
+
return s.updateFn(ctx, m)
|
|
57
|
+
}
|
|
40
58
|
|
|
41
59
|
//nolint:unused
|
|
42
|
-
func (
|
|
60
|
+
func (s *repositoryStub) Delete(ctx context.Context, id uuid.UUID) error {
|
|
61
|
+
if s.deleteFn == nil {
|
|
62
|
+
panic("unexpected repository.Delete call")
|
|
63
|
+
}
|
|
64
|
+
return s.deleteFn(ctx, id)
|
|
65
|
+
}
|
|
43
66
|
|
|
44
|
-
// go-scaffold:
|
|
67
|
+
// go-scaffold:repository-stub-methods
|
|
45
68
|
|
|
46
69
|
//nolint:unused
|
|
47
70
|
func status(t *testing.T, err error) int {
|
|
@@ -17,4 +17,20 @@ type {{pascalName}} struct {
|
|
|
17
17
|
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
|
18
18
|
CreatedAt time.Time `json:"created_at"`
|
|
19
19
|
UpdatedAt time.Time `json:"updated_at"`
|
|
20
|
+
// Version guards against two clients that both loaded this row overwriting
|
|
21
|
+
// each other: an update only applies if the version it carries still
|
|
22
|
+
// matches the stored one. Bumped by Repository.Update, never by hand.
|
|
23
|
+
Version int `json:"version" gorm:"not null;default:1"`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// TableName pins the persistence contract to the versioned SQL migrations.
|
|
27
|
+
// Do not rely on GORM's English inflector: irregular/plural module names must
|
|
28
|
+
// resolve to exactly the same table in development and production.
|
|
29
|
+
//
|
|
30
|
+
// Schema-qualified ("{{schemaName}}", not just "{{tableName}}") so this
|
|
31
|
+
// domain's table can never collide with another domain's, and a raw SQL JOIN
|
|
32
|
+
// reaching into it from outside this package fails loudly instead of quietly
|
|
33
|
+
// coupling two domains together.
|
|
34
|
+
func ({{pascalName}}) TableName() string {
|
|
35
|
+
return "{{schemaName}}.{{tableName}}"
|
|
20
36
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
-- ON CONFLICT DO NOTHING: safe to re-generate a module reusing a permission
|
|
2
2
|
-- code another module already inserted (e.g. a shared "orders:manage").
|
|
3
|
-
|
|
3
|
+
-- role_svc, not this module's own schema: `add rbac` owns the permissions
|
|
4
|
+
-- table, wherever it ends up living — see `add rbac`'s migration.
|
|
5
|
+
INSERT INTO role_svc.permissions (code, description) VALUES ('{{permission}}', 'Manage {{plural}}')
|
|
4
6
|
ON CONFLICT (code) DO NOTHING;
|
|
@@ -2,15 +2,23 @@ package {{pkg}}
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"errors"
|
|
5
6
|
|
|
6
7
|
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
8
|
+
"{{goModule}}/internal/shared/tx"
|
|
7
9
|
|
|
8
10
|
"github.com/google/uuid"
|
|
9
11
|
"gorm.io/gorm"
|
|
10
12
|
)
|
|
11
13
|
|
|
14
|
+
// ErrStaleVersion means the row moved on since the caller read it — someone
|
|
15
|
+
// else saved first. Returned instead of overwriting their work.
|
|
16
|
+
var ErrStaleVersion = errors.New("stale version")
|
|
17
|
+
|
|
12
18
|
// Repository = data access for {{pkg}} (the only place that touches the DB for this domain)
|
|
13
|
-
// every method takes ctx, so a cancelled request cancels the query too
|
|
19
|
+
// every method takes ctx, so a cancelled request cancels the query too — and
|
|
20
|
+
// so tx.From can pick up a transaction opened by the caller, letting two
|
|
21
|
+
// repositories commit together without changing any signature here.
|
|
14
22
|
type Repository struct {
|
|
15
23
|
db *gorm.DB
|
|
16
24
|
}
|
|
@@ -20,30 +28,76 @@ func NewRepository(db *gorm.DB) *Repository {
|
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
func (r *Repository) Create(ctx context.Context, m *model.{{pascalName}}) error {
|
|
23
|
-
return r.db.WithContext(ctx).Create(m).Error
|
|
31
|
+
return tx.From(ctx, r.db).WithContext(ctx).Create(m).Error
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
27
35
|
var items []model.{{pascalName}}
|
|
28
|
-
err := r.db.WithContext(ctx).Order("id desc").Limit(limit).Offset(offset).Find(&items).Error
|
|
36
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Order("id desc").Limit(limit).Offset(offset).Find(&items).Error
|
|
29
37
|
return items, err
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
33
41
|
var m model.{{pascalName}}
|
|
34
42
|
// "id = ?" explicit — PK is a UUID, not an int, this avoids GORM misinterpreting the arg
|
|
35
|
-
if err := r.db.WithContext(ctx).First(&m, "id = ?", id).Error; err != nil {
|
|
43
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).First(&m, "id = ?", id).Error; err != nil {
|
|
36
44
|
return nil, err
|
|
37
45
|
}
|
|
38
46
|
return &m, nil
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
// Update applies m only if the version it carries still matches the stored
|
|
50
|
+
// row, then bumps it. Two clients that both loaded version 3 can't silently
|
|
51
|
+
// overwrite each other: the second one gets ErrStaleVersion instead of the
|
|
52
|
+
// first one's changes disappearing with no error anywhere.
|
|
41
53
|
func (r *Repository) Update(ctx context.Context, m *model.{{pascalName}}) error {
|
|
42
|
-
|
|
54
|
+
expected := m.Version
|
|
55
|
+
m.Version = expected + 1
|
|
56
|
+
|
|
57
|
+
res := tx.From(ctx, r.db).WithContext(ctx).
|
|
58
|
+
Model(&model.{{pascalName}}{}).
|
|
59
|
+
Where("id = ? AND version = ?", m.ID, expected).
|
|
60
|
+
// Select("*") because Updates on a struct skips zero values, which
|
|
61
|
+
// would quietly ignore a field the caller meant to clear.
|
|
62
|
+
Select("*").Omit("id", "created_at").
|
|
63
|
+
Updates(m)
|
|
64
|
+
if res.Error != nil {
|
|
65
|
+
m.Version = expected
|
|
66
|
+
return res.Error
|
|
67
|
+
}
|
|
68
|
+
if res.RowsAffected == 0 {
|
|
69
|
+
m.Version = expected
|
|
70
|
+
return ErrStaleVersion
|
|
71
|
+
}
|
|
72
|
+
return nil
|
|
43
73
|
}
|
|
44
74
|
|
|
75
|
+
// Delete reports gorm.ErrRecordNotFound when the row wasn't there, so a
|
|
76
|
+
// DELETE on an id that never existed answers 404 like GET does — a plain
|
|
77
|
+
// GORM delete affects zero rows and returns no error, which had the same
|
|
78
|
+
// request answer 204 as a real deletion.
|
|
45
79
|
func (r *Repository) Delete(ctx context.Context, id uuid.UUID) error {
|
|
46
|
-
|
|
80
|
+
res := tx.From(ctx, r.db).WithContext(ctx).Delete(&model.{{pascalName}}{}, "id = ?", id)
|
|
81
|
+
if res.Error != nil {
|
|
82
|
+
return res.Error
|
|
83
|
+
}
|
|
84
|
+
if res.RowsAffected == 0 {
|
|
85
|
+
return gorm.ErrRecordNotFound
|
|
86
|
+
}
|
|
87
|
+
return nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Count is the total the list endpoint needs to say "page 3 of 12" — see
|
|
91
|
+
// pagination.ResponseWithTotal. Separate from FindAll because it costs a second
|
|
92
|
+
// query: an endpoint that only ever scrolls shouldn't pay for a count nobody
|
|
93
|
+
// reads.
|
|
94
|
+
//
|
|
95
|
+
// Apply the same WHERE clause here that FindAll uses once you add filtering,
|
|
96
|
+
// or the total will describe a different set than the rows.
|
|
97
|
+
func (r *Repository) Count(ctx context.Context) (int64, error) {
|
|
98
|
+
var total int64
|
|
99
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.{{pascalName}}{}).Count(&total).Error
|
|
100
|
+
return total, err
|
|
47
101
|
}
|
|
48
102
|
|
|
49
103
|
// go-scaffold:repository-methods
|