@nakedev/go-scaffold 0.4.3 → 0.5.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 (164) hide show
  1. package/README.md +567 -513
  2. package/dist/commands/auth.js +12 -1
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/create.js +2 -1
  5. package/dist/commands/generate.js +5 -2
  6. package/dist/commands/method.js +66 -70
  7. package/dist/commands/observability.js +4 -53
  8. package/dist/commands/rbac.js +19 -8
  9. package/dist/commands/undo.js +6 -3
  10. package/dist/commands/worker.js +14 -4
  11. package/dist/index.js +13 -1
  12. package/dist/templates/auth-manifest.js +46 -45
  13. package/dist/templates/create-manifest.js +4 -0
  14. package/dist/templates/module-manifest.js +82 -60
  15. package/dist/templates/rbac-manifest.js +15 -11
  16. package/dist/templates/worker-manifest.js +4 -1
  17. package/dist/types.js +2 -0
  18. package/dist/utils/auth-patcher.js +22 -22
  19. package/dist/utils/config.js +24 -5
  20. package/dist/utils/docs-patcher.js +68 -0
  21. package/dist/utils/hexagonal-method-patcher.js +334 -0
  22. package/dist/utils/main-patcher.js +3 -3
  23. package/dist/utils/module-location.js +17 -11
  24. package/dist/utils/platform-patcher.js +27 -0
  25. package/dist/utils/rbac-patcher.js +73 -216
  26. package/package.json +1 -1
  27. package/templates/add/auth/cmd/seed/main.go.hbs +2 -0
  28. package/templates/add/auth/internal/app/user/{browser_policy.go.hbs → adapters/inbound/http/browser_policy.go.hbs} +3 -3
  29. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  30. package/templates/add/auth/internal/app/user/{handler.go.hbs → adapters/inbound/http/handler.go.hbs} +103 -8
  31. package/templates/add/auth/internal/app/user/{handler_local.go.hbs → adapters/inbound/http/handler_local.go.hbs} +8 -7
  32. package/templates/add/auth/internal/app/user/{handler_mfa.go.hbs → adapters/inbound/http/handler_mfa.go.hbs} +6 -6
  33. package/templates/add/auth/internal/app/user/{handler_oauth.go.hbs → adapters/inbound/http/handler_oauth.go.hbs} +17 -17
  34. package/templates/add/auth/internal/app/user/{handler_recovery.go.hbs → adapters/inbound/http/handler_recovery.go.hbs} +4 -4
  35. package/templates/add/auth/internal/app/user/{handler_test.go.hbs → adapters/inbound/http/handler_test.go.hbs} +52 -31
  36. package/templates/add/auth/internal/app/user/{handler_user.go.hbs → adapters/inbound/http/handler_user.go.hbs} +4 -4
  37. package/templates/add/auth/internal/app/user/{session_cookie.go.hbs → adapters/inbound/http/session_cookie.go.hbs} +5 -3
  38. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  39. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  40. package/templates/add/auth/internal/app/user/{mfa_store.go.hbs → adapters/outbound/postgres/mfa_store.go.hbs} +22 -20
  41. package/templates/add/auth/internal/app/user/{mfa_store_test.go.hbs → adapters/outbound/postgres/mfa_store_test.go.hbs} +7 -7
  42. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  43. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  44. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  45. package/templates/add/auth/internal/app/user/{tokenstore_pg.go.hbs → adapters/outbound/postgres/tokenstore_pg.go.hbs} +36 -32
  46. package/templates/add/auth/internal/app/user/{tokenstore_pg_test.go.hbs → adapters/outbound/postgres/tokenstore_pg_test.go.hbs} +10 -3
  47. package/templates/add/auth/internal/app/user/{tokenstore_recovery.go.hbs → adapters/outbound/postgres/tokenstore_recovery.go.hbs} +29 -3
  48. package/templates/add/auth/internal/app/user/{tokenstore_redis.go.hbs → adapters/outbound/redis/tokenstore.go.hbs} +43 -36
  49. package/templates/add/auth/internal/app/user/{tokenstore_redis_test.go.hbs → adapters/outbound/redis/tokenstore_test.go.hbs} +14 -3
  50. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  51. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  52. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  53. package/templates/add/auth/internal/app/user/{external_login.go.hbs → application/external_login.go.hbs} +30 -40
  54. package/templates/add/auth/internal/app/user/{jwt.go.hbs → application/jwt.go.hbs} +6 -3
  55. package/templates/add/auth/internal/app/user/{local_auth.go.hbs → application/local_auth.go.hbs} +17 -19
  56. package/templates/add/auth/internal/app/user/{mfa_service.go.hbs → application/mfa_service.go.hbs} +27 -28
  57. package/templates/add/auth/internal/app/user/{mfa_service_test.go.hbs → application/mfa_service_test.go.hbs} +12 -11
  58. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +3 -3
  59. package/templates/add/auth/internal/app/user/{provider_test.go.hbs → application/provider_test.go.hbs} +55 -56
  60. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +16 -47
  61. package/templates/add/auth/internal/app/user/{recovery_service.go.hbs → application/recovery_service.go.hbs} +13 -15
  62. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  63. package/templates/add/auth/internal/app/user/{service_test.go.hbs → application/service_test.go.hbs} +94 -75
  64. package/templates/add/auth/internal/app/user/{sessions.go.hbs → application/sessions.go.hbs} +14 -14
  65. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  66. package/templates/add/auth/internal/app/user/{user_query.go.hbs → application/user_query.go.hbs} +16 -16
  67. package/templates/add/auth/internal/app/user/composition.go.hbs +89 -86
  68. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  69. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  70. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  71. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  72. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +1 -1
  73. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  74. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -1
  75. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  76. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  77. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  78. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  79. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  80. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  81. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  82. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  83. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  84. package/templates/add/rbac/internal/app/role/composition.go.hbs +23 -10
  85. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  86. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  87. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  88. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  89. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  90. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  91. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +54 -36
  92. package/templates/create/base/.golangci.yml.hbs +2 -2
  93. package/templates/create/base/AGENTS.md.hbs +55 -31
  94. package/templates/create/base/README.md.hbs +77 -22
  95. package/templates/create/base/cmd/api/wiring.go.hbs +3 -2
  96. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  97. package/templates/create/features/docs/architecture.md.hbs +64 -26
  98. package/templates/create/features/docs/patterns.md.hbs +117 -90
  99. package/templates/create/features/docs/techstack.md.hbs +17 -2
  100. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  101. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  102. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  103. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  104. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  105. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  106. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  107. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  108. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  109. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  110. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  111. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  112. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  113. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  114. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  115. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  116. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  117. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  118. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  119. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  120. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  121. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  122. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  123. package/templates/generate/module/migration.up.sql.hbs +1 -1
  124. package/dist/utils/method-patcher.js +0 -537
  125. package/templates/add/auth/internal/app/user/contracts.go.hbs +0 -88
  126. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -134
  127. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -68
  128. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -45
  129. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -32
  130. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  131. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +0 -17
  132. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +0 -20
  133. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +0 -17
  134. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -31
  135. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -139
  136. package/templates/add/auth/internal/app/user/service.go.hbs +0 -135
  137. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -58
  138. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  139. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  140. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  141. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  142. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  143. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  144. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  145. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  146. package/templates/generate/module/commands.go.hbs +0 -95
  147. package/templates/generate/module/composition.go.hbs +0 -23
  148. package/templates/generate/module/cqrs_test.go.hbs +0 -7
  149. package/templates/generate/module/dto.go.hbs +0 -36
  150. package/templates/generate/module/errors.go.hbs +0 -33
  151. package/templates/generate/module/handler.go.hbs +0 -179
  152. package/templates/generate/module/handler_test.go.hbs +0 -174
  153. package/templates/generate/module/minimal/commands.go.hbs +0 -34
  154. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  155. package/templates/generate/module/minimal/handler.go.hbs +0 -82
  156. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  157. package/templates/generate/module/minimal/queries.go.hbs +0 -45
  158. package/templates/generate/module/minimal/service.go.hbs +0 -71
  159. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  160. package/templates/generate/module/model/model.go.hbs +0 -36
  161. package/templates/generate/module/queries.go.hbs +0 -62
  162. package/templates/generate/module/repository.go.hbs +0 -103
  163. package/templates/generate/module/service.go.hbs +0 -164
  164. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -1,95 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "context"
5
- "errors"
6
-
7
- "{{goModule}}/internal/app/{{modulePath}}/model"
8
- "{{goModule}}/internal/shared/apperror"
9
- "{{goModule}}/internal/shared/dberr"
10
- "{{goModule}}/internal/shared/id"
11
-
12
- "github.com/google/uuid"
13
- "gorm.io/gorm"
14
- )
15
-
16
- // commandRepository is the outbound port for state-changing use cases. It is
17
- // intentionally smaller than queryRepository so command handlers do not grow
18
- // a dependency on read-only operations by accident.
19
- type commandRepository interface {
20
- Create(ctx context.Context, m *model.{{pascalName}}) error
21
- FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error)
22
- Update(ctx context.Context, m *model.{{pascalName}}) error
23
- Delete(ctx context.Context, id uuid.UUID) error
24
- // go-scaffold:command-repository-interface
25
- }
26
-
27
- // commandService is the inbound application port consumed by the HTTP
28
- // adapter. Its implementation is free of Gin and is independently testable.
29
- type commandService interface {
30
- Create(context.Context, createInput) (*model.{{pascalName}}, error)
31
- Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
32
- Delete(context.Context, uuid.UUID) error
33
- // go-scaffold:command-interface
34
- }
35
-
36
- // CommandHandler owns state-changing use cases for {{pkg}}. It is deliberately
37
- // separate from QueryHandler even though both adapters may share one database.
38
- type CommandHandler struct {
39
- repo commandRepository
40
- }
41
-
42
- func NewCommandHandler(repo commandRepository) *CommandHandler {
43
- return &CommandHandler{repo: repo}
44
- }
45
-
46
- func (h *CommandHandler) Create(ctx context.Context, in createInput) (*model.{{pascalName}}, error) {
47
- // TODO: set real fields from in
48
- _ = in
49
- m := &model.{{pascalName}}{ID: id.New()}
50
- if err := h.repo.Create(ctx, m); err != nil {
51
- if dberr.IsDuplicate(err) {
52
- return nil, errConflict()
53
- }
54
- return nil, apperror.NewInternal(err)
55
- }
56
- return m, nil
57
- }
58
-
59
- func (h *CommandHandler) Update(ctx context.Context, id uuid.UUID, in updateInput) (*model.{{pascalName}}, error) {
60
- m, err := h.repo.FindByID(ctx, id)
61
- if err != nil {
62
- return nil, wrapFindErr(err)
63
- }
64
- // TODO: apply real fields from in before saving
65
- // The version the client read comes from the request, not from the row we
66
- // just loaded — comparing the row against itself would always succeed and
67
- // defeat the check.
68
- m.Version = in.Version
69
- if err := h.repo.Update(ctx, m); err != nil {
70
- if errors.Is(err, ErrStaleVersion) {
71
- return nil, errStale()
72
- }
73
- if dberr.IsDuplicate(err) {
74
- return nil, errConflict()
75
- }
76
- return nil, apperror.NewInternal(err)
77
- }
78
- return m, nil
79
- }
80
-
81
- func (h *CommandHandler) Delete(ctx context.Context, id uuid.UUID) error {
82
- if err := h.repo.Delete(ctx, id); err != nil {
83
- if errors.Is(err, gorm.ErrRecordNotFound) {
84
- return errNotFound()
85
- }
86
- // deleting a {{pkg}} that's still referenced elsewhere → FK RESTRICT trips = 409, not 500
87
- if dberr.IsForeignKey(err) {
88
- return errHasReferences()
89
- }
90
- return apperror.NewInternal(err)
91
- }
92
- return nil
93
- }
94
-
95
- // go-scaffold:command-methods
@@ -1,23 +0,0 @@
1
- package {{pkg}}
2
-
3
- import "gorm.io/gorm"
4
- {{#if permission}}
5
- import "{{goModule}}/internal/shared/middleware"
6
- {{/if}}
7
-
8
- // NewHandlerFromDB is this feature's local composition root. The API binary
9
- // only chooses infrastructure and registers the route; repository, application
10
- // handlers, and delivery construction stay next to the feature.
11
- func NewHandlerFromDB(db *gorm.DB{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
12
- {{#if cqrs}}
13
- repo := NewRepository(db)
14
- return NewHandlerFromCQRS(
15
- NewCommandHandler(repo),
16
- NewQueryHandler(repo){{#if auth}}, jwtSecret{{/if}}{{#if permission}}, authz{{/if}},
17
- )
18
- {{else}}
19
- return NewHandler(
20
- NewService(NewRepository(db)){{#if auth}}, jwtSecret{{/if}}{{#if permission}}, authz{{/if}},
21
- )
22
- {{/if}}
23
- }
@@ -1,7 +0,0 @@
1
- package {{pkg}}
2
-
3
- // These compile-time assertions make the generated boundary visible to both
4
- // users and tooling: commands and queries are separate application ports even
5
- // when they share one Postgres adapter in this modular monolith.
6
- var _ commandService = (*CommandHandler)(nil)
7
- var _ queryService = (*QueryHandler)(nil)
@@ -1,36 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "time"
5
-
6
- "{{goModule}}/internal/app/{{modulePath}}/model"
7
-
8
- "github.com/google/uuid"
9
- )
10
-
11
- // TODO: add request fields, e.g. Name string `json:"name" binding:"required"`
12
- // When you do: update createBody in handler_test.go to match, or the
13
- // generated create test starts failing on its own empty `{}` body.
14
- type createInput struct {
15
- }
16
-
17
- // TODO: add request fields, e.g. Name string `json:"name" binding:"omitempty"`
18
- type updateInput struct {
19
- // Version the client last read. Required: without it an update is a blind
20
- // overwrite of whatever anyone else has since saved.
21
- Version int `json:"version" binding:"required"`
22
- }
23
-
24
- // response = the DTO sent out (kept separate from the model so a later DB column doesn't leak automatically)
25
- type response struct {
26
- ID uuid.UUID `json:"id"`
27
- CreatedAt time.Time `json:"created_at"`
28
- // echoed back so the client can send it with its next update
29
- Version int `json:"version"`
30
- }
31
-
32
- func toResponse(m *model.{{pascalName}}) response {
33
- return response{ID: m.ID, CreatedAt: m.CreatedAt, Version: m.Version}
34
- }
35
-
36
- // go-scaffold:dto
@@ -1,33 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "net/http"
5
-
6
- "{{goModule}}/internal/shared/apperror"
7
- )
8
-
9
- // error catalog specific to {{pkg}} — codes carry more meaning than the generic apperror ones
10
- // functions, not vars: the error middleware writes RequestID onto the returned pointer directly
11
- // (a shared instance would race across concurrent requests). Not every entry is called by every
12
- // module (a minimal module calls none of these until `generate method` wires one in), hence nolint.
13
- //
14
- //nolint:unused
15
- func errNotFound() *apperror.AppError {
16
- return apperror.New(http.StatusNotFound, "{{errorPrefix}}_NOT_FOUND", "{{pkg}} not found")
17
- }
18
-
19
- //nolint:unused
20
- func errConflict() *apperror.AppError {
21
- // TODO: rename/reword once a real unique field exists (e.g. email already exists)
22
- return apperror.New(http.StatusConflict, "{{errorPrefix}}_CONFLICT", "{{pkg}} already exists")
23
- }
24
-
25
- //nolint:unused
26
- func errStale() *apperror.AppError {
27
- return apperror.New(http.StatusConflict, "{{errorPrefix}}_STALE", "{{pkg}} was modified by someone else — reload and try again")
28
- }
29
-
30
- //nolint:unused
31
- func errHasReferences() *apperror.AppError {
32
- return apperror.New(http.StatusConflict, "{{errorPrefix}}_HAS_REFERENCES", "{{pkg}} still has related records")
33
- }
@@ -1,179 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "context"
5
- "net/http"
6
-
7
- "{{goModule}}/internal/app/{{modulePath}}/model"
8
- "{{goModule}}/internal/shared/httpx"
9
- {{#if auth}}
10
- "{{goModule}}/internal/shared/middleware"
11
- {{/if}}
12
- "{{goModule}}/internal/shared/pagination"
13
-
14
- "github.com/gin-gonic/gin"
15
- "github.com/google/uuid"
16
- )
17
-
18
- {{#if cqrs}}
19
- // service is retained as a compatibility seam for handler tests and callers
20
- // that still want one feature facade. Production wiring uses the narrower
21
- // commandService/queryService ports through NewHandlerFromCQRS.
22
- type service interface {
23
- commandService
24
- queryService
25
- Create(context.Context, createInput) (*model.{{pascalName}}, error)
26
- List(context.Context, int, int) ([]model.{{pascalName}}, error)
27
- Get(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
28
- Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
29
- Delete(context.Context, uuid.UUID) error
30
- // go-scaffold:service-interface
31
- }
32
- {{else}}
33
- // service is the narrow application API required by this HTTP adapter. Keeping
34
- // the dependency as an interface makes handler tests fast and database-free.
35
- type service interface {
36
- Create(context.Context, createInput) (*model.{{pascalName}}, error)
37
- List(context.Context, int, int) ([]model.{{pascalName}}, error)
38
- Get(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
39
- Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
40
- Delete(context.Context, uuid.UUID) error
41
- // go-scaffold:service-interface
42
- }
43
- {{/if}}
44
-
45
- // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
46
- type Handler struct {
47
- {{#if cqrs}}
48
- commands commandService
49
- queries queryService
50
- {{else}}
51
- svc service
52
- {{/if}}
53
- {{#if auth}}
54
- jwtSecret string
55
- {{/if}}
56
- {{#if permission}}
57
- authz *middleware.Authz
58
- {{/if}}
59
- }
60
-
61
- {{#if cqrs}}
62
- // NewHandler keeps the pre-CQRS constructor usable for focused handler tests
63
- // and hand-written callers. New production composition should use the explicit
64
- // command/query constructor below.
65
- func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
66
- return NewHandlerFromCQRS(svc, svc{{#if auth}}, jwtSecret{{/if}}{{#if permission}}, authz{{/if}})
67
- }
68
-
69
- func NewHandlerFromCQRS(commands commandService, queries queryService{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
70
- return &Handler{
71
- commands: commands,
72
- queries: queries,
73
- {{#if auth}}
74
- jwtSecret: jwtSecret,
75
- {{/if}}
76
- {{#if permission}}
77
- authz: authz,
78
- {{/if}}
79
- }
80
- }
81
- {{else}}
82
- func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
83
- return &Handler{
84
- svc: svc,
85
- {{#if auth}}
86
- jwtSecret: jwtSecret,
87
- {{/if}}
88
- {{#if permission}}
89
- authz: authz,
90
- {{/if}}
91
- }
92
- }
93
- {{/if}}
94
-
95
- // Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
96
- func (h *Handler) Register(rg gin.IRouter) {
97
- g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
98
- g.POST("", h.create)
99
- g.GET("", h.list)
100
- g.GET("/:id", h.get)
101
- g.PUT("/:id", h.update)
102
- g.DELETE("/:id", h.delete)
103
- // go-scaffold:handler-routes
104
- }
105
-
106
- func (h *Handler) create(c *gin.Context) {
107
- var in createInput
108
- if err := c.ShouldBindJSON(&in); err != nil {
109
- c.Error(httpx.BindErr(err))
110
- return
111
- }
112
- {{#if cqrs}}m, err := h.commands.Create(c.Request.Context(), in){{else}}m, err := h.svc.Create(c.Request.Context(), in){{/if}}
113
- if err != nil {
114
- c.Error(err)
115
- return
116
- }
117
- c.JSON(http.StatusCreated, toResponse(m))
118
- }
119
-
120
- func (h *Handler) list(c *gin.Context) {
121
- p := pagination.Parse(c)
122
- {{#if cqrs}}items, err := h.queries.List(c.Request.Context(), p.Limit, p.Offset){{else}}items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset){{/if}}
123
- if err != nil {
124
- c.Error(err)
125
- return
126
- }
127
- out := make([]response, len(items))
128
- for i := range items {
129
- out[i] = toResponse(&items[i])
130
- }
131
- c.JSON(http.StatusOK, p.Response(out))
132
- }
133
-
134
- func (h *Handler) get(c *gin.Context) {
135
- id, ok := httpx.ParseID(c)
136
- if !ok {
137
- return
138
- }
139
- {{#if cqrs}}m, err := h.queries.Get(c.Request.Context(), id){{else}}m, err := h.svc.Get(c.Request.Context(), id){{/if}}
140
- if err != nil {
141
- c.Error(err)
142
- return
143
- }
144
- c.JSON(http.StatusOK, toResponse(m))
145
- }
146
-
147
- func (h *Handler) update(c *gin.Context) {
148
- id, ok := httpx.ParseID(c)
149
- if !ok {
150
- return
151
- }
152
- var in updateInput
153
- if err := c.ShouldBindJSON(&in); err != nil {
154
- c.Error(httpx.BindErr(err))
155
- return
156
- }
157
- {{#if cqrs}}m, err := h.commands.Update(c.Request.Context(), id, in){{else}}m, err := h.svc.Update(c.Request.Context(), id, in){{/if}}
158
- if err != nil {
159
- c.Error(err)
160
- return
161
- }
162
- c.JSON(http.StatusOK, toResponse(m))
163
- }
164
-
165
- func (h *Handler) delete(c *gin.Context) {
166
- id, ok := httpx.ParseID(c)
167
- if !ok {
168
- return
169
- }
170
- {{#if cqrs}} if err := h.commands.Delete(c.Request.Context(), id); err != nil {
171
- {{else}} if err := h.svc.Delete(c.Request.Context(), id); err != nil {
172
- {{/if}}
173
- c.Error(err)
174
- return
175
- }
176
- c.Status(http.StatusNoContent)
177
- }
178
-
179
- // go-scaffold:handler-funcs
@@ -1,174 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "bytes"
5
- "context"
6
- "net/http"
7
- "net/http/httptest"
8
- "testing"
9
- {{#if auth}}
10
- "time"
11
- {{/if}}
12
-
13
- "{{goModule}}/internal/app/{{modulePath}}/model"
14
- "{{goModule}}/internal/shared/middleware"
15
-
16
- "github.com/gin-gonic/gin"
17
- {{#if auth}}
18
- "github.com/golang-jwt/jwt/v5"
19
- {{/if}}
20
- "github.com/google/uuid"
21
- )
22
-
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
- }
36
-
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")
68
- }
69
- return s.deleteFn(ctx, id)
70
- }
71
-
72
- // go-scaffold:service-stub-methods
73
-
74
- func setupHandlerTest(t *testing.T, svc service) *gin.Engine {
75
- t.Helper()
76
- gin.SetMode(gin.TestMode)
77
- r := gin.New()
78
- r.Use(middleware.RequestID(), middleware.Error(true))
79
- {{#if permission}}
80
- // The handler unit test verifies route composition, not the role repository.
81
- authz := middleware.NewAuthz(func(_ context.Context, _ string) (map[string]struct{}, error) {
82
- return map[string]struct{}{"{{permission}}": {}}, nil
83
- }, time.Minute)
84
- {{/if}}
85
- NewHandler(svc{{#if auth}}, testJWTSecret{{/if}}{{#if permission}}, authz{{/if}}).Register(r)
86
- return r
87
- }
88
-
89
- {{#if auth}}
90
- const testJWTSecret = "test-secret"
91
-
92
- func authHeader() string {
93
- claims := jwt.MapClaims{
94
- "typ": "access",
95
- "sub": uuid.NewString(),
96
- "role": "staff",
97
- "exp": time.Now().Add(time.Hour).Unix(),
98
- "iat": time.Now().Unix(),
99
- }
100
- tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testJWTSecret))
101
- return "Bearer " + tok
102
- }
103
-
104
- {{/if}}
105
- func doHandlerRequest(r *gin.Engine, method, requestPath, body string) *httptest.ResponseRecorder {
106
- req := httptest.NewRequest(method, requestPath, bytes.NewBufferString(body))
107
- req.Header.Set("Content-Type", "application/json")
108
- {{#if auth}}
109
- req.Header.Set("Authorization", authHeader())
110
- {{/if}}
111
- w := httptest.NewRecorder()
112
- r.ServeHTTP(w, req)
113
- return w
114
- }
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
-
123
- func TestHandler_Create_OK(t *testing.T) {
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
-
133
- if w.Code != http.StatusCreated {
134
- t.Fatalf("want 201, got %d body=%s", w.Code, w.Body)
135
- }
136
- }
137
-
138
- func TestHandler_Get_NotFound(t *testing.T) {
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
-
148
- if w.Code != http.StatusNotFound {
149
- t.Fatalf("want 404, got %d body=%s", w.Code, w.Body)
150
- }
151
- }
152
-
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
-
158
- if w.Code != http.StatusBadRequest {
159
- t.Fatalf("want 400, got %d body=%s", w.Code, w.Body)
160
- }
161
- }
162
-
163
- func TestHandler_Delete_OK(t *testing.T) {
164
- svc := &serviceStub{
165
- deleteFn: func(context.Context, uuid.UUID) error { return nil },
166
- }
167
- r := setupHandlerTest(t, svc)
168
-
169
- w := doHandlerRequest(r, http.MethodDelete, "/{{plural}}/"+uuid.NewString(), "")
170
-
171
- if w.Code != http.StatusNoContent {
172
- t.Fatalf("want 204, got %d body=%s", w.Code, w.Body)
173
- }
174
- }
@@ -1,34 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "context"
5
-
6
- "{{goModule}}/internal/app/{{modulePath}}/model"
7
-
8
- "github.com/google/uuid"
9
- )
10
-
11
- // commandRepository is the outbound port for state-changing use cases.
12
- type commandRepository interface {
13
- Create(ctx context.Context, m *model.{{pascalName}}) error
14
- FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error)
15
- Update(ctx context.Context, m *model.{{pascalName}}) error
16
- Delete(ctx context.Context, id uuid.UUID) error
17
- // go-scaffold:command-repository-interface
18
- }
19
-
20
- // commandService is the inbound application port consumed by command routes.
21
- type commandService interface {
22
- // go-scaffold:command-interface
23
- }
24
-
25
- // CommandHandler owns state-changing use cases for {{pkg}}.
26
- type CommandHandler struct {
27
- repo commandRepository
28
- }
29
-
30
- func NewCommandHandler(repo commandRepository) *CommandHandler {
31
- return &CommandHandler{repo: repo}
32
- }
33
-
34
- // go-scaffold:command-methods
@@ -1,28 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "time"
5
-
6
- "{{goModule}}/internal/app/{{modulePath}}/model"
7
-
8
- "github.com/google/uuid"
9
- )
10
-
11
- // response = the DTO sent out (kept separate from the model so a later DB column doesn't leak automatically)
12
- // TODO: add fields as you generate methods that return more than id/created_at
13
- // unused until `generate method` adds a method that returns one
14
- //
15
- //nolint:unused
16
- type response struct {
17
- ID uuid.UUID `json:"id"`
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"`
21
- }
22
-
23
- //nolint:unused
24
- func toResponse(m *model.{{pascalName}}) response {
25
- return response{ID: m.ID, CreatedAt: m.CreatedAt, Version: m.Version}
26
- }
27
-
28
- // go-scaffold:dto
@@ -1,82 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- {{#if auth}}
5
- "{{goModule}}/internal/shared/middleware"
6
-
7
- {{/if}}
8
- "github.com/gin-gonic/gin"
9
- )
10
-
11
- {{#if cqrs}}
12
- // service is retained as a compatibility seam. Production composition uses
13
- // the separate commandService/queryService ports below.
14
- type service interface {
15
- commandService
16
- queryService
17
- // go-scaffold:service-interface
18
- }
19
- {{else}}
20
- // service is intentionally empty until `generate method` adds the exact
21
- // application operations required by this HTTP adapter.
22
- type service interface {
23
- // go-scaffold:service-interface
24
- }
25
- {{/if}}
26
-
27
- // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
28
- type Handler struct {
29
- {{#if cqrs}}
30
- commands commandService
31
- queries queryService
32
- {{else}}
33
- svc service
34
- {{/if}}
35
- {{#if auth}}
36
- jwtSecret string
37
- {{/if}}
38
- {{#if permission}}
39
- authz *middleware.Authz
40
- {{/if}}
41
- }
42
-
43
- {{#if cqrs}}
44
- func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
45
- return NewHandlerFromCQRS(svc, svc{{#if auth}}, jwtSecret{{/if}}{{#if permission}}, authz{{/if}})
46
- }
47
-
48
- func NewHandlerFromCQRS(commands commandService, queries queryService{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
49
- return &Handler{
50
- commands: commands,
51
- queries: queries,
52
- {{#if auth}}
53
- jwtSecret: jwtSecret,
54
- {{/if}}
55
- {{#if permission}}
56
- authz: authz,
57
- {{/if}}
58
- }
59
- }
60
- {{else}}
61
- func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
62
- return &Handler{
63
- svc: svc,
64
- {{#if auth}}
65
- jwtSecret: jwtSecret,
66
- {{/if}}
67
- {{#if permission}}
68
- authz: authz,
69
- {{/if}}
70
- }
71
- }
72
- {{/if}}
73
-
74
- // Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
75
- // minimal module: no routes yet — add them with `go-scaffold generate method {{pkg}} <name> --type ...`
76
- func (h *Handler) Register(rg gin.IRouter) {
77
- g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
78
- _ = g
79
- // go-scaffold:handler-routes
80
- }
81
-
82
- // go-scaffold:handler-funcs