@nakedev/go-scaffold 0.5.4 → 0.8.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.
Files changed (50) hide show
  1. package/README.md +16 -2
  2. package/dist/commands/auth.js +6 -1
  3. package/dist/commands/check.js +5 -1
  4. package/dist/commands/method.js +27 -1
  5. package/dist/index.js +20 -6
  6. package/dist/prompts/auth-wizard.js +33 -1
  7. package/dist/templates/create-manifest.js +8 -0
  8. package/dist/templates/rbac-manifest.js +1 -0
  9. package/dist/utils/hexagonal-method-patcher.js +79 -20
  10. package/package.json +1 -1
  11. package/templates/add/auth/docs/login.yaml.hbs +9 -1
  12. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +12 -2
  13. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +1 -0
  14. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +97 -13
  15. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +169 -5
  16. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +44 -2
  17. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +2 -1
  18. package/templates/add/auth/internal/app/user/application/service.go.hbs +1 -1
  19. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +71 -12
  20. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +5 -4
  21. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -0
  22. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +21 -3
  23. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  24. package/templates/add/rbac/docs/roles.yaml.hbs +2 -1
  25. package/templates/add/rbac/docs/users.yaml.hbs +2 -1
  26. package/templates/add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs +52 -0
  27. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +18 -13
  28. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +21 -4
  29. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +19 -11
  30. package/templates/add/rbac/internal/app/role/application/service.go.hbs +6 -6
  31. package/templates/add/rbac/internal/app/role/application/service_test.go.hbs +19 -1
  32. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +13 -1
  33. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +1 -1
  34. package/templates/create/base/AGENTS.md.hbs +9 -1
  35. package/templates/create/base/README.md.hbs +2 -1
  36. package/templates/create/base/internal/shared/dbq/dbq.go.hbs +47 -0
  37. package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +66 -0
  38. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +19 -2
  39. package/templates/create/features/docs/architecture.md.hbs +9 -6
  40. package/templates/create/features/docs/common/parameters.yaml.hbs +5 -0
  41. package/templates/create/features/docs/common/schemas.yaml.hbs +1 -0
  42. package/templates/create/features/docs/patterns.md.hbs +60 -0
  43. package/templates/generate/module/docs/collection.yaml.hbs +2 -1
  44. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +8 -3
  45. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +27 -4
  46. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +3 -3
  47. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +3 -3
  48. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +3 -3
  49. package/templates/generate/module/hexagonal/application/service_test.go.hbs +4 -3
  50. package/templates/generate/module/hexagonal/ports/repository.go.hbs +18 -2
@@ -10,11 +10,28 @@ import (
10
10
  "github.com/google/uuid"
11
11
  )
12
12
 
13
+ // ListFilter is the whole question the admin user list asks. The zero value is
14
+ // "the first page of everything", so a caller only sets what it narrows by.
15
+ //
16
+ // Put new filters here as fields rather than as arguments: one struct travels
17
+ // handler -> application -> repository, so adding a filter later is a field
18
+ // and not a fourth parameter on three signatures. See
19
+ // docs/architect/patterns.md.
20
+ type ListFilter struct {
21
+ // Search is ?q= as shared/pagination parsed it, matched against the name
22
+ // and the primary email address.
23
+ Search string
24
+ Limit int
25
+ Offset int
26
+ }
27
+
13
28
  type UserRepository interface {
14
29
  FindByEmail(context.Context, string) (*domain.User, error)
15
30
  FindByID(context.Context, uuid.UUID) (*domain.User, error)
16
31
  UpdateUser(context.Context, *domain.User) error
17
- FindAll(context.Context, int, int) ([]domain.User, error)
32
+ // FindAll answers one page and the total the filter matched, which is what
33
+ // a client needs to draw "page 3 of 12".
34
+ FindAll(context.Context, ListFilter) ([]domain.User, int64, error)
18
35
  FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
19
36
  ListIdentities(context.Context, uuid.UUID) ([]domain.Identity, error)
20
37
  FindIdentityByProviderUID(context.Context, string, string) (*domain.Identity, error)
@@ -23,8 +40,9 @@ type UserRepository interface {
23
40
  UpdateIdentity(context.Context, *domain.Identity) error
24
41
  DeleteIdentity(context.Context, uuid.UUID, domain.Provider) error
25
42
  LoginLockedUntil(context.Context, string) (time.Time, error)
26
- RecordLoginFailure(context.Context, string, int, time.Duration) error
27
- ClearLoginFailures(context.Context, string) error
43
+ {{#if fixedLockout}} RecordLoginFailure(context.Context, string, string, int, time.Duration, time.Duration) error
44
+ {{else}} RecordLoginFailure(context.Context, string, string, int, time.Duration) error
45
+ {{/if}} ClearLoginFailures(context.Context, string) error
28
46
  // go-scaffold:repository-interface
29
47
  }
30
48
 
@@ -2,9 +2,19 @@
2
2
  -- so it works for addresses that have no account (otherwise throttling itself
3
3
  -- leaks which addresses are registered) and so this table is not a user list.
4
4
  -- See adapters/outbound/postgres/model.go for why this exists separately from the rate limiter.
5
+ -- last_attempt fingerprints the most recent wrong password so replaying the
6
+ -- same one does not count again (see RecordLoginFailure). It is an HMAC, not a
7
+ -- plain hash: a leaked table must not become a dictionary of near-miss
8
+ -- passwords for known accounts.
5
9
  CREATE TABLE user_svc.login_throttle (
6
10
  email_hash TEXT PRIMARY KEY,
7
11
  failures INTEGER NOT NULL DEFAULT 0,
12
+ last_attempt TEXT,
8
13
  locked_until TIMESTAMPTZ,
9
14
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
10
15
  );
16
+
17
+ -- The table sweeps itself: every failed login deletes the rows nothing has
18
+ -- touched for a day (see RecordLoginFailure). This index is what keeps that
19
+ -- delete from scanning the table each time.
20
+ CREATE INDEX login_throttle_updated_at_idx ON user_svc.login_throttle (updated_at);
@@ -6,9 +6,10 @@ get:
6
6
  parameters:
7
7
  - $ref: '../common/parameters.yaml#/Limit'
8
8
  - $ref: '../common/parameters.yaml#/Offset'
9
+ - $ref: '../common/parameters.yaml#/Search'
9
10
  responses:
10
11
  "200":
11
- description: paginated list
12
+ description: "paginated list; `q` matches code and display name"
12
13
  content:
13
14
  application/json:
14
15
  schema:
@@ -6,9 +6,10 @@ get:
6
6
  parameters:
7
7
  - $ref: '../common/parameters.yaml#/Limit'
8
8
  - $ref: '../common/parameters.yaml#/Offset'
9
+ - $ref: '../common/parameters.yaml#/Search'
9
10
  responses:
10
11
  "200":
11
- description: paginated list
12
+ description: "paginated list; `q` matches name and primary email"
12
13
  content:
13
14
  application/json:
14
15
  schema:
@@ -0,0 +1,52 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/role/application"
7
+ )
8
+
9
+ // Request DTOs belong to the inbound adapter. Keep JSON names and binding
10
+ // tags out of application inputs so role use cases remain transport-neutral.
11
+ type createInput struct {
12
+ Code string `json:"code"`
13
+ Name string `json:"name"`
14
+ }
15
+
16
+ func toCreateInput(in createInput) application.CreateInput {
17
+ return application.CreateInput{Code: in.Code, Name: in.Name}
18
+ }
19
+
20
+ type setPermissionsInput struct {
21
+ PermissionCodes *[]string `json:"permission_codes"`
22
+ }
23
+
24
+ func toSetPermissionsInput(in setPermissionsInput) application.SetPermissionsInput {
25
+ return application.SetPermissionsInput{PermissionCodes: in.PermissionCodes}
26
+ }
27
+
28
+ // Response DTOs belong to the inbound adapter too. Application responses are
29
+ // deliberately free of JSON tags and are mapped explicitly before encoding.
30
+ type roleResponse struct {
31
+ Code string `json:"code"`
32
+ Name string `json:"name"`
33
+ IsSystem bool `json:"is_system"`
34
+ Permissions []string `json:"permissions"`
35
+ CreatedAt time.Time `json:"created_at"`
36
+ }
37
+
38
+ func toRoleResponse(out application.RoleResponse) roleResponse {
39
+ return roleResponse{
40
+ Code: out.Code, Name: out.Name, IsSystem: out.IsSystem,
41
+ Permissions: out.Permissions, CreatedAt: out.CreatedAt,
42
+ }
43
+ }
44
+
45
+ type permissionResponse struct {
46
+ Code string `json:"code"`
47
+ Description string `json:"description"`
48
+ }
49
+
50
+ func toPermissionResponse(out application.PermissionResponse) permissionResponse {
51
+ return permissionResponse{Code: out.Code, Description: out.Description}
52
+ }
@@ -9,6 +9,7 @@ import (
9
9
 
10
10
  "{{goModule}}/internal/app/role/application"
11
11
  "{{goModule}}/internal/app/role/domain"
12
+ "{{goModule}}/internal/app/role/ports"
12
13
  "{{goModule}}/internal/shared/apperror"
13
14
  "{{goModule}}/internal/shared/httpx"
14
15
  "{{goModule}}/internal/shared/middleware"
@@ -40,49 +41,53 @@ func (h *Handler) Register(rg gin.IRouter) {
40
41
 
41
42
  func (h *Handler) list(c *gin.Context) {
42
43
  p := pagination.Parse(c)
43
- items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
44
+ // One struct all the way down, so a filter added later is a field on
45
+ // ports.ListFilter and not a new argument on the three signatures below.
46
+ filter := ports.ListFilter{Search: p.Search, Limit: p.Limit, Offset: p.Offset}
47
+ items, total, err := h.svc.List(c.Request.Context(), filter)
44
48
  if err != nil {
45
49
  c.Error(toHTTPError(err))
46
50
  return
47
51
  }
48
- out := make([]application.RoleResponse, len(items))
52
+ out := make([]roleResponse, len(items))
49
53
  for i := range items {
50
- out[i] = application.ToRoleResponse(items[i])
54
+ out[i] = toRoleResponse(application.ToRoleResponse(items[i]))
51
55
  }
52
- c.JSON(http.StatusOK, p.Response(out))
56
+ c.JSON(http.StatusOK, p.ResponseWithTotal(out, total))
53
57
  }
54
58
 
55
59
  func (h *Handler) create(c *gin.Context) {
56
- var in application.CreateInput
60
+ var in createInput
57
61
  if err := c.ShouldBindJSON(&in); err != nil {
58
62
  c.Error(httpx.BindErr(err))
59
63
  return
60
64
  }
61
- item, err := h.svc.Create(c.Request.Context(), in)
65
+ item, err := h.svc.Create(c.Request.Context(), toCreateInput(in))
62
66
  if err != nil {
63
67
  c.Error(toHTTPError(err))
64
68
  return
65
69
  }
66
- c.JSON(http.StatusCreated, application.ToRoleResponse(*item))
70
+ c.JSON(http.StatusCreated, toRoleResponse(application.ToRoleResponse(*item)))
67
71
  }
68
72
 
69
73
  func (h *Handler) setPermissions(c *gin.Context) {
70
- var in application.SetPermissionsInput
74
+ var in setPermissionsInput
71
75
  if err := c.ShouldBindJSON(&in); err != nil {
72
76
  c.Error(httpx.BindErr(err))
73
77
  return
74
78
  }
75
- if in.PermissionCodes == nil {
79
+ appInput := toSetPermissionsInput(in)
80
+ if appInput.PermissionCodes == nil {
76
81
  c.Error(apperror.NewValidation("invalid role input", map[string]string{"permission_codes": "is required"}))
77
82
  return
78
83
  }
79
- item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *in.PermissionCodes)
84
+ item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *appInput.PermissionCodes)
80
85
  if err != nil {
81
86
  c.Error(toHTTPError(err))
82
87
  return
83
88
  }
84
89
  h.authz.Invalidate(item.Role.Code)
85
- c.JSON(http.StatusOK, application.ToRoleResponse(*item))
90
+ c.JSON(http.StatusOK, toRoleResponse(application.ToRoleResponse(*item)))
86
91
  }
87
92
 
88
93
  func (h *Handler) delete(c *gin.Context) {
@@ -101,9 +106,9 @@ func (h *Handler) listPermissions(c *gin.Context) {
101
106
  c.Error(toHTTPError(err))
102
107
  return
103
108
  }
104
- out := make([]application.PermissionResponse, len(items))
109
+ out := make([]permissionResponse, len(items))
105
110
  for i := range items {
106
- out[i] = application.ToPermissionResponse(items[i])
111
+ out[i] = toPermissionResponse(application.ToPermissionResponse(items[i]))
107
112
  }
108
113
  c.JSON(http.StatusOK, p.Response(out))
109
114
  }
@@ -6,6 +6,7 @@ import (
6
6
 
7
7
  "{{goModule}}/internal/app/role/domain"
8
8
  "{{goModule}}/internal/app/role/ports"
9
+ "{{goModule}}/internal/shared/dbq"
9
10
  "{{goModule}}/internal/shared/dberr"
10
11
  "{{goModule}}/internal/shared/tx"
11
12
 
@@ -27,17 +28,33 @@ func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]st
27
28
  return codes, mapDatabaseError(err)
28
29
  }
29
30
 
30
- func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.Role, error) {
31
+ // FindAll answers one page of the filter and how many roles it matched
32
+ // altogether. Two queries, because a count over a LIMITed query would only
33
+ // ever count the page.
34
+ func (r *Repository) FindAll(ctx context.Context, filter ports.ListFilter) ([]domain.Role, int64, error) {
35
+ // A closure rather than one *gorm.DB reused twice: conditions accumulate
36
+ // on the value, so the count would silently inherit the page's LIMIT.
37
+ matching := func() *gorm.DB {
38
+ q := tx.From(ctx, r.db).WithContext(ctx).Model(&Role{})
39
+ return dbq.Search(q, filter.Search, "code", "name")
40
+ }
41
+
42
+ var total int64
43
+ if err := matching().Count(&total).Error; err != nil {
44
+ return nil, 0, mapDatabaseError(err)
45
+ }
46
+
31
47
  var rows []Role
32
- err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&rows).Error
48
+ // code is unique, so it needs no tiebreaker to page stably.
49
+ err := matching().Order("code").Limit(filter.Limit).Offset(filter.Offset).Find(&rows).Error
33
50
  if err != nil {
34
- return nil, mapDatabaseError(err)
51
+ return nil, 0, mapDatabaseError(err)
35
52
  }
36
53
  items := make([]domain.Role, len(rows))
37
54
  for i := range rows {
38
55
  items[i] = toDomainRole(&rows[i])
39
56
  }
40
- return items, nil
57
+ return items, total, nil
41
58
  }
42
59
 
43
60
  func (r *Repository) FindByCode(ctx context.Context, code string) (*domain.Role, error) {
@@ -9,12 +9,12 @@ import (
9
9
  const PermRoleManage = "role:manage"
10
10
 
11
11
  type CreateInput struct {
12
- Code string `json:"code"`
13
- Name string `json:"name"`
12
+ Code string
13
+ Name string
14
14
  }
15
15
 
16
16
  type SetPermissionsInput struct {
17
- PermissionCodes *[]string `json:"permission_codes"`
17
+ PermissionCodes *[]string
18
18
  }
19
19
 
20
20
  type RoleListItem struct {
@@ -23,22 +23,30 @@ type RoleListItem struct {
23
23
  }
24
24
 
25
25
  type RoleResponse struct {
26
- Code string `json:"code"`
27
- Name string `json:"name"`
28
- IsSystem bool `json:"is_system"`
29
- Permissions []string `json:"permissions"`
30
- CreatedAt time.Time `json:"created_at"`
26
+ Code string
27
+ Name string
28
+ IsSystem bool
29
+ Permissions []string
30
+ CreatedAt time.Time
31
31
  }
32
32
 
33
33
  type PermissionResponse struct {
34
- Code string `json:"code"`
35
- Description string `json:"description"`
34
+ Code string
35
+ Description string
36
36
  }
37
37
 
38
38
  func ToRoleResponse(item RoleListItem) RoleResponse {
39
+ // A role holding nothing must still answer `[]`, never `null`: Pluck
40
+ // leaves the slice nil, and the seeded `staff` role starts with no grants
41
+ // at all — so this is the common case, not an edge one. Every HTTP path
42
+ // (list, create, set-permissions) maps through here.
43
+ permissions := item.Permissions
44
+ if permissions == nil {
45
+ permissions = []string{}
46
+ }
39
47
  return RoleResponse{
40
48
  Code: item.Role.Code, Name: item.Role.Name, IsSystem: item.Role.IsSystem,
41
- Permissions: item.Permissions, CreatedAt: item.Role.CreatedAt,
49
+ Permissions: permissions, CreatedAt: item.Role.CreatedAt,
42
50
  }
43
51
  }
44
52
 
@@ -15,7 +15,7 @@ import (
15
15
  type ServicePort interface {
16
16
  PermissionsOf(context.Context, string) (map[string]struct{}, error)
17
17
  CodeExists(context.Context, string) (bool, error)
18
- List(context.Context, int, int) ([]RoleListItem, error)
18
+ List(context.Context, ports.ListFilter) ([]RoleListItem, int64, error)
19
19
  Create(context.Context, CreateInput) (*RoleListItem, error)
20
20
  SetPermissions(context.Context, string, []string) (*RoleListItem, error)
21
21
  Delete(context.Context, string) error
@@ -50,20 +50,20 @@ func (s *Service) CodeExists(ctx context.Context, code string) (bool, error) {
50
50
  return true, nil
51
51
  }
52
52
 
53
- func (s *Service) List(ctx context.Context, limit, offset int) ([]RoleListItem, error) {
54
- roles, err := s.repo.FindAll(ctx, limit, offset)
53
+ func (s *Service) List(ctx context.Context, filter ports.ListFilter) ([]RoleListItem, int64, error) {
54
+ roles, total, err := s.repo.FindAll(ctx, filter)
55
55
  if err != nil {
56
- return nil, err
56
+ return nil, 0, err
57
57
  }
58
58
  out := make([]RoleListItem, len(roles))
59
59
  for i, role := range roles {
60
60
  perms, err := s.repo.PermissionCodes(ctx, role.Code)
61
61
  if err != nil {
62
- return nil, err
62
+ return nil, 0, err
63
63
  }
64
64
  out[i] = RoleListItem{Role: role, Permissions: perms}
65
65
  }
66
- return out, nil
66
+ return out, total, nil
67
67
  }
68
68
 
69
69
  var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
@@ -2,11 +2,14 @@ package application
2
2
 
3
3
  import (
4
4
  "context"
5
+ "encoding/json"
5
6
  "errors"
6
7
  "net/http"
8
+ "strings"
7
9
  "testing"
8
10
 
9
11
  "{{goModule}}/internal/app/role/domain"
12
+ "{{goModule}}/internal/app/role/ports"
10
13
  )
11
14
 
12
15
  // fakeRepo = mock of the repository interface, so the service can be tested
@@ -23,7 +26,9 @@ type fakeRepo struct {
23
26
  }
24
27
 
25
28
  func (f *fakeRepo) PermissionCodes(context.Context, string) ([]string, error) { return nil, nil }
26
- func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.Role, error) { return nil, nil }
29
+ func (f *fakeRepo) FindAll(context.Context, ports.ListFilter) ([]domain.Role, int64, error) {
30
+ return nil, 0, nil
31
+ }
27
32
  func (f *fakeRepo) FindByCode(context.Context, string) (*domain.Role, error) {
28
33
  if f.findErr != nil {
29
34
  return nil, f.findErr
@@ -124,3 +129,16 @@ func TestService_Delete_SystemRoleRejected(t *testing.T) {
124
129
  t.Fatalf("want 409 for a system role, got %d", got)
125
130
  }
126
131
  }
132
+
133
+ func TestToRoleResponse_EmptyPermissionsMarshalAsArray(t *testing.T) {
134
+ // The repository's Pluck leaves the slice nil for a role with no grants,
135
+ // and `null` breaks any client that treats permissions as a list — which
136
+ // is every one of them, since the seeded `staff` role starts empty.
137
+ body, err := json.Marshal(ToRoleResponse(RoleListItem{Role: domain.Role{Code: "staff"}}))
138
+ if err != nil {
139
+ t.Fatalf("marshal: %v", err)
140
+ }
141
+ if !strings.Contains(string(body), `"Permissions":[]`) {
142
+ t.Fatalf("want an empty array for a role with no permissions, got %s", body)
143
+ }
144
+ }
@@ -7,9 +7,21 @@ import (
7
7
  "{{goModule}}/internal/app/role/domain"
8
8
  )
9
9
 
10
+ // ListFilter is the whole question the role list asks. The zero value is "the
11
+ // first page of everything", so a caller only sets what it narrows by. Put new
12
+ // filters here as fields rather than as arguments — see
13
+ // docs/architect/patterns.md.
14
+ type ListFilter struct {
15
+ // Search is ?q= as shared/pagination parsed it, matched against the code
16
+ // and the display name.
17
+ Search string
18
+ Limit int
19
+ Offset int
20
+ }
21
+
10
22
  type Repository interface {
11
23
  PermissionCodes(context.Context, string) ([]string, error)
12
- FindAll(context.Context, int, int) ([]domain.Role, error)
24
+ FindAll(context.Context, ListFilter) ([]domain.Role, int64, error)
13
25
  FindByCode(context.Context, string) (*domain.Role, error)
14
26
  Create(context.Context, *domain.Role) error
15
27
  SetPermissions(context.Context, string, []string) error
@@ -172,7 +172,7 @@ method:
172
172
 
173
173
  | Type | Shape | Generated behavior |
174
174
  |---|---|---|
175
- | get --get-mode all | GET /<plural>/<name> | Reuses FindAll; add real filtering |
175
+ | get --get-mode all | GET /<plural>/<name> | Reuses FindAll (page + total, `?q=` parsed); add fields to ports.ListFilter |
176
176
  | get --get-mode one --field <f> | GET /<plural>/<f>/:<f> | Adds a repository lookup and test seam |
177
177
  | post | POST /<plural>/<name> | Body DTO plus internal-error TODO |
178
178
  | put / patch | <VERB> /<plural>/:id/<name> | Safe 501 Not Implemented; no repository read/write |
@@ -102,6 +102,13 @@ go-scaffold generate module <name> --defaults
102
102
  go-scaffold generate method <module> <name> --type <get|post|put|patch|delete>
103
103
  ```
104
104
 
105
+ `generate method` extends the modules `generate module` created, and the one
106
+ `add auth` owns. It refuses `add rbac`'s: that module builds its response from
107
+ a role *together with its permissions* rather than from the entity, and the
108
+ generator has no shape to write against there. Add an endpoint to it by hand —
109
+ a route in `internal/app/role/adapters/inbound/http/handler.go`, a method on
110
+ the application service, and its entry in `docs/`.
111
+
105
112
  Pass every value as a flag in CI. In an interactive shell, omitted values use
106
113
  the project defaults as the initial wizard choices. In a non-interactive
107
114
  shell, omitted values prompt and the command exits without writing.
@@ -142,7 +149,8 @@ These remain deliberate engineering work:
142
149
  - any work in a repository that lacks `go-scaffold.config.json`.
143
150
 
144
151
  Generated methods are explicit stubs. `generate method` GET-all reuses
145
- `FindAll` until real filtering is added; POST and DELETE return an internal
152
+ `FindAll`, so it answers the same rows as the module's own list until
153
+ `ports.ListFilter` grows the fields that endpoint narrows by; POST and DELETE return an internal
146
154
  error until implemented; PUT/PATCH intentionally return `501 Not Implemented`
147
155
  and must not read or write a record before their behavior is designed. Treat
148
156
  the generated OpenAPI TODO contract as incomplete, not as a promise that the
@@ -50,10 +50,11 @@ internal/
50
50
  │ ├── config/ # loads config from env
51
51
  │ ├── apperror/ # central error type (status + payload)
52
52
  │ ├── dberr/ # maps DB errors to constraint kind — shared by every domain
53
+ │ ├── dbq/ # the SQL every list repeats: escaped contains-search
53
54
  │ ├── httpx/ # HTTP helpers shared by every domain
54
55
  │ ├── id/ # UUID v7 generation (id.New)
55
56
  │ ├── middleware/ # RequestID, Logger, Error, CORS{{#if auth}}, auth{{/if}}{{#if rbac}}, RBAC{{/if}}{{#if observability}}, metrics, tracing{{/if}}
56
- │ ├── pagination/ # parses ?limit=&offset=, response envelope
57
+ │ ├── pagination/ # parses ?limit=&offset=&q=, response envelope
57
58
  │ └── tx/ # carries a transaction on the context
58
59
  └── app/ # one package per domain, added with `generate module`
59
60
  ```
@@ -0,0 +1,47 @@
1
+ // Package dbq holds the SQL every list endpoint repeats, so a filter is not
2
+ // re-derived — and re-broken — once per module.
3
+ package dbq
4
+
5
+ import (
6
+ "strings"
7
+
8
+ "gorm.io/gorm"
9
+ )
10
+
11
+ // likeEscaper protects the pattern's own metacharacters. Backslash first, or
12
+ // it would escape the backslashes the other two just added.
13
+ var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
14
+
15
+ // LikePattern turns a search term into a `contains` pattern with the wildcards
16
+ // escaped, so searching for "50%" looks for the three characters "50%" rather
17
+ // than for anything starting with 50.
18
+ //
19
+ // Postgres already treats backslash as LIKE's escape character, so the pattern
20
+ // needs no trailing ESCAPE clause.
21
+ func LikePattern(term string) string {
22
+ return "%" + likeEscaper.Replace(term) + "%"
23
+ }
24
+
25
+ // Search narrows db to rows where any of cols contains term, ignoring case.
26
+ // An empty term returns db untouched, so a caller applies it unconditionally:
27
+ //
28
+ // q = dbq.Search(q, filter.Search, "name", "code")
29
+ //
30
+ // cols are SQL identifiers written by the caller. Never pass one that came off
31
+ // the request — they are interpolated; only the term is a bound parameter.
32
+ func Search(db *gorm.DB, term string, cols ...string) *gorm.DB {
33
+ if term == "" || len(cols) == 0 {
34
+ return db
35
+ }
36
+ pattern := LikePattern(term)
37
+ conds := make([]string, len(cols))
38
+ args := make([]any, len(cols))
39
+ for i, col := range cols {
40
+ conds[i] = col + " ILIKE ?"
41
+ args[i] = pattern
42
+ }
43
+ // Parenthesised here rather than trusting the driver to group it: ANDed
44
+ // with a second filter, an unwrapped OR would widen the result instead of
45
+ // narrowing it.
46
+ return db.Where("("+strings.Join(conds, " OR ")+")", args...)
47
+ }
@@ -0,0 +1,66 @@
1
+ package dbq
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "gorm.io/driver/postgres"
8
+ "gorm.io/gorm"
9
+ )
10
+
11
+ func TestLikePatternEscapesWildcards(t *testing.T) {
12
+ // A term the user typed is data, not pattern: each of these three would
13
+ // otherwise silently widen the search.
14
+ for term, want := range map[string]string{
15
+ "nake": "%nake%",
16
+ "50%": `%50\%%`,
17
+ "a_b": `%a\_b%`,
18
+ `c:\tmp`: `%c:\\tmp%`,
19
+ } {
20
+ if got := LikePattern(term); got != want {
21
+ t.Errorf("LikePattern(%q) = %q, want %q", term, got, want)
22
+ }
23
+ }
24
+ }
25
+
26
+ // dryRunDB builds SQL without a server, so this stays a unit test.
27
+ func dryRunDB(t *testing.T) *gorm.DB {
28
+ t.Helper()
29
+ db, err := gorm.Open(postgres.New(postgres.Config{DSN: "postgres://x/y"}), &gorm.Config{
30
+ DryRun: true,
31
+ DisableAutomaticPing: true,
32
+ })
33
+ if err != nil {
34
+ t.Fatalf("open dry-run database: %v", err)
35
+ }
36
+ return db
37
+ }
38
+
39
+ type row struct {
40
+ ID int
41
+ Name string
42
+ Code string
43
+ }
44
+
45
+ func TestSearchGroupsItsOrAndKeepsTheTermBound(t *testing.T) {
46
+ db := dryRunDB(t)
47
+ stmt := Search(db.Model(&row{}).Where("id = ?", 1), "ab", "name", "code").Find(&[]row{}).Statement
48
+ sql := stmt.SQL.String()
49
+
50
+ // Unparenthesised, `id = 1 AND name ILIKE ... OR code ILIKE ...` matches
51
+ // every row whose code hits — the filter beside it stops applying.
52
+ if !strings.Contains(sql, "(name ILIKE $2 OR code ILIKE $3)") {
53
+ t.Errorf("OR is not grouped: %s", sql)
54
+ }
55
+ if strings.Contains(sql, "ab") {
56
+ t.Errorf("search term was interpolated, not bound: %s", sql)
57
+ }
58
+ }
59
+
60
+ func TestSearchWithoutATermIsANoOp(t *testing.T) {
61
+ db := dryRunDB(t)
62
+ stmt := Search(db.Model(&row{}), "", "name").Find(&[]row{}).Statement
63
+ if strings.Contains(stmt.SQL.String(), "ILIKE") {
64
+ t.Errorf("empty term still filtered: %s", stmt.SQL.String())
65
+ }
66
+ }
@@ -2,6 +2,7 @@ package pagination
2
2
 
3
3
  import (
4
4
  "strconv"
5
+ "strings"
5
6
 
6
7
  "github.com/gin-gonic/gin"
7
8
  )
@@ -9,15 +10,23 @@ import (
9
10
  const (
10
11
  defaultLimit = 20
11
12
  maxLimit = 100
13
+ // Long enough for any name or address anyone types into a search box, and
14
+ // short enough that the LIKE it becomes cannot be used to make the
15
+ // database do real work.
16
+ maxSearchRunes = 100
12
17
  )
13
18
 
14
- // Params is a validated limit/offset pair (default 20, capped at 100).
19
+ // Params is a validated limit/offset pair (default 20, capped at 100) plus the
20
+ // free-text search the list was asked for.
15
21
  type Params struct {
16
22
  Limit int
17
23
  Offset int
24
+ // Search is ?q= trimmed, empty when the caller sent none — so a repository
25
+ // can pass it straight to dbq.Search, which no-ops on an empty term.
26
+ Search string
18
27
  }
19
28
 
20
- // Parse reads ?limit=&offset= off the request so every feature parses the
29
+ // Parse reads ?limit=&offset=&q= off the request so every feature parses the
21
30
  // same way.
22
31
  func Parse(c *gin.Context) Params {
23
32
  p := Params{Limit: defaultLimit}
@@ -30,6 +39,14 @@ func Parse(c *gin.Context) Params {
30
39
  if v, err := strconv.Atoi(c.Query("offset")); err == nil && v >= 0 {
31
40
  p.Offset = v
32
41
  }
42
+ // Runes, not bytes: one non-Latin character is often three bytes, and
43
+ // cutting one in half would send invalid UTF-8 into the query.
44
+ if q := strings.TrimSpace(c.Query("q")); q != "" {
45
+ if r := []rune(q); len(r) > maxSearchRunes {
46
+ q = string(r[:maxSearchRunes])
47
+ }
48
+ p.Search = q
49
+ }
33
50
  return p
34
51
  }
35
52