@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.
- package/README.md +16 -2
- package/dist/commands/auth.js +6 -1
- package/dist/commands/check.js +5 -1
- package/dist/commands/method.js +27 -1
- package/dist/index.js +20 -6
- package/dist/prompts/auth-wizard.js +33 -1
- package/dist/templates/create-manifest.js +8 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/utils/hexagonal-method-patcher.js +79 -20
- package/package.json +1 -1
- package/templates/add/auth/docs/login.yaml.hbs +9 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +12 -2
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +97 -13
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +169 -5
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +44 -2
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +2 -1
- package/templates/add/auth/internal/app/user/application/service.go.hbs +1 -1
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +71 -12
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +5 -4
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +21 -3
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +2 -1
- package/templates/add/rbac/docs/users.yaml.hbs +2 -1
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs +52 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +18 -13
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +21 -4
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +19 -11
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +6 -6
- package/templates/add/rbac/internal/app/role/application/service_test.go.hbs +19 -1
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +13 -1
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +1 -1
- package/templates/create/base/AGENTS.md.hbs +9 -1
- package/templates/create/base/README.md.hbs +2 -1
- package/templates/create/base/internal/shared/dbq/dbq.go.hbs +47 -0
- package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +66 -0
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +19 -2
- package/templates/create/features/docs/architecture.md.hbs +9 -6
- package/templates/create/features/docs/common/parameters.yaml.hbs +5 -0
- package/templates/create/features/docs/common/schemas.yaml.hbs +1 -0
- package/templates/create/features/docs/patterns.md.hbs +60 -0
- package/templates/generate/module/docs/collection.yaml.hbs +2 -1
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +8 -3
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +27 -4
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +4 -3
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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([]
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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"), *
|
|
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([]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
Name string
|
|
12
|
+
Code string
|
|
13
|
+
Name string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
type SetPermissionsInput struct {
|
|
17
|
-
PermissionCodes *[]string
|
|
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
|
|
27
|
-
Name string
|
|
28
|
-
IsSystem bool
|
|
29
|
-
Permissions []string
|
|
30
|
-
CreatedAt time.Time
|
|
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
|
|
35
|
-
Description string
|
|
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:
|
|
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,
|
|
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,
|
|
54
|
-
roles, err := s.repo.FindAll(ctx,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|