@nakedev/go-scaffold 0.4.0 → 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 (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -1,134 +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
- // service is the narrow application API required by this HTTP adapter. Keeping
19
- // the dependency as an interface makes handler tests fast and database-free.
20
- type service interface {
21
- Create(context.Context, createInput) (*model.{{pascalName}}, error)
22
- List(context.Context, int, int) ([]model.{{pascalName}}, error)
23
- Get(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
24
- Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
25
- Delete(context.Context, uuid.UUID) error
26
- // go-scaffold:service-interface
27
- }
28
-
29
- // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
30
- type Handler struct {
31
- svc service
32
- {{#if auth}}
33
- jwtSecret string
34
- {{/if}}
35
- {{#if permission}}
36
- authz *middleware.Authz
37
- {{/if}}
38
- }
39
-
40
- func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
41
- return &Handler{
42
- svc: svc,
43
- {{#if auth}}
44
- jwtSecret: jwtSecret,
45
- {{/if}}
46
- {{#if permission}}
47
- authz: authz,
48
- {{/if}}
49
- }
50
- }
51
-
52
- // Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
53
- func (h *Handler) Register(rg gin.IRouter) {
54
- g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
55
- g.POST("", h.create)
56
- g.GET("", h.list)
57
- g.GET("/:id", h.get)
58
- g.PUT("/:id", h.update)
59
- g.DELETE("/:id", h.delete)
60
- // go-scaffold:handler-routes
61
- }
62
-
63
- func (h *Handler) create(c *gin.Context) {
64
- var in createInput
65
- if err := c.ShouldBindJSON(&in); err != nil {
66
- c.Error(httpx.BindErr(err))
67
- return
68
- }
69
- m, err := h.svc.Create(c.Request.Context(), in)
70
- if err != nil {
71
- c.Error(err)
72
- return
73
- }
74
- c.JSON(http.StatusCreated, toResponse(m))
75
- }
76
-
77
- func (h *Handler) list(c *gin.Context) {
78
- p := pagination.Parse(c)
79
- items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
80
- if err != nil {
81
- c.Error(err)
82
- return
83
- }
84
- out := make([]response, len(items))
85
- for i := range items {
86
- out[i] = toResponse(&items[i])
87
- }
88
- c.JSON(http.StatusOK, p.Response(out))
89
- }
90
-
91
- func (h *Handler) get(c *gin.Context) {
92
- id, ok := httpx.ParseID(c)
93
- if !ok {
94
- return
95
- }
96
- m, err := h.svc.Get(c.Request.Context(), id)
97
- if err != nil {
98
- c.Error(err)
99
- return
100
- }
101
- c.JSON(http.StatusOK, toResponse(m))
102
- }
103
-
104
- func (h *Handler) update(c *gin.Context) {
105
- id, ok := httpx.ParseID(c)
106
- if !ok {
107
- return
108
- }
109
- var in updateInput
110
- if err := c.ShouldBindJSON(&in); err != nil {
111
- c.Error(httpx.BindErr(err))
112
- return
113
- }
114
- m, err := h.svc.Update(c.Request.Context(), id, in)
115
- if err != nil {
116
- c.Error(err)
117
- return
118
- }
119
- c.JSON(http.StatusOK, toResponse(m))
120
- }
121
-
122
- func (h *Handler) delete(c *gin.Context) {
123
- id, ok := httpx.ParseID(c)
124
- if !ok {
125
- return
126
- }
127
- if err := h.svc.Delete(c.Request.Context(), id); err != nil {
128
- c.Error(err)
129
- return
130
- }
131
- c.Status(http.StatusNoContent)
132
- }
133
-
134
- // 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,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,48 +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
- // service is intentionally empty until `generate method` adds the exact
12
- // application operations required by this HTTP adapter.
13
- type service interface {
14
- // go-scaffold:service-interface
15
- }
16
-
17
- // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
18
- type Handler struct {
19
- svc service
20
- {{#if auth}}
21
- jwtSecret string
22
- {{/if}}
23
- {{#if permission}}
24
- authz *middleware.Authz
25
- {{/if}}
26
- }
27
-
28
- func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
29
- return &Handler{
30
- svc: svc,
31
- {{#if auth}}
32
- jwtSecret: jwtSecret,
33
- {{/if}}
34
- {{#if permission}}
35
- authz: authz,
36
- {{/if}}
37
- }
38
- }
39
-
40
- // Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
41
- // minimal module: no routes yet — add them with `go-scaffold generate method {{pkg}} <name> --type ...`
42
- func (h *Handler) Register(rg gin.IRouter) {
43
- g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
44
- _ = g
45
- // go-scaffold:handler-routes
46
- }
47
-
48
- // go-scaffold:handler-funcs
@@ -1,10 +0,0 @@
1
- package {{pkg}}
2
-
3
- // serviceStub embeds the concrete service so a generated method automatically
4
- // satisfies the handler's narrow service interface. Add explicit function-backed
5
- // overrides here when writing focused handler tests for that method.
6
- //
7
- //nolint:unused
8
- type serviceStub struct {
9
- *Service
10
- }
@@ -1,45 +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
-
10
- "github.com/google/uuid"
11
- "gorm.io/gorm"
12
- )
13
-
14
- // repository = what the service needs from the data layer (declared on the consumer side, so it can be mocked in tests)
15
- // minimal module: no CRUD wired yet, but the full data-access surface is here so `generate method` has something to call
16
- type repository interface {
17
- Create(ctx context.Context, m *model.{{pascalName}}) error
18
- FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error)
19
- FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error)
20
- Update(ctx context.Context, m *model.{{pascalName}}) error
21
- Delete(ctx context.Context, id uuid.UUID) error
22
- // go-scaffold:repository-interface
23
- }
24
-
25
- // Service = business logic for {{pkg}} (rules live here, it knows nothing about HTTP)
26
- // minimal module: add methods with `go-scaffold generate method {{pkg}} <name> --type ...`
27
- type Service struct {
28
- repo repository
29
- }
30
-
31
- func NewService(repo repository) *Service {
32
- return &Service{repo: repo}
33
- }
34
-
35
- // unused until `generate method` adds a get-one/patch/put/delete method that calls it
36
- //
37
- //nolint:unused
38
- func wrapFindErr(err error) error {
39
- if errors.Is(err, gorm.ErrRecordNotFound) {
40
- return errNotFound()
41
- }
42
- return apperror.NewInternal()
43
- }
44
-
45
- // go-scaffold:service-methods
@@ -1,77 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "context"
5
- "errors"
6
- "testing"
7
-
8
- "{{goModule}}/internal/app/{{modulePath}}/model"
9
- "{{goModule}}/internal/shared/apperror"
10
-
11
- "github.com/google/uuid"
12
- )
13
-
14
- // repositoryStub is ready for focused use-case tests added alongside generated
15
- // methods. Every unset dependency panics so unexpected orchestration is visible.
16
- //
17
- //nolint:unused
18
- type repositoryStub struct {
19
- createFn func(context.Context, *model.{{pascalName}}) error
20
- findAllFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
21
- findByIDFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
22
- updateFn func(context.Context, *model.{{pascalName}}) error
23
- deleteFn func(context.Context, uuid.UUID) error
24
- // go-scaffold:repository-stub-fields
25
- }
26
-
27
- //nolint:unused
28
- func (s *repositoryStub) Create(ctx context.Context, m *model.{{pascalName}}) error {
29
- if s.createFn == nil {
30
- panic("unexpected repository.Create call")
31
- }
32
- return s.createFn(ctx, m)
33
- }
34
-
35
- //nolint:unused
36
- func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
37
- if s.findAllFn == nil {
38
- panic("unexpected repository.FindAll call")
39
- }
40
- return s.findAllFn(ctx, limit, offset)
41
- }
42
-
43
- //nolint:unused
44
- func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
45
- if s.findByIDFn == nil {
46
- panic("unexpected repository.FindByID call")
47
- }
48
- return s.findByIDFn(ctx, id)
49
- }
50
-
51
- //nolint:unused
52
- func (s *repositoryStub) Update(ctx context.Context, m *model.{{pascalName}}) error {
53
- if s.updateFn == nil {
54
- panic("unexpected repository.Update call")
55
- }
56
- return s.updateFn(ctx, m)
57
- }
58
-
59
- //nolint:unused
60
- func (s *repositoryStub) Delete(ctx context.Context, id uuid.UUID) error {
61
- if s.deleteFn == nil {
62
- panic("unexpected repository.Delete call")
63
- }
64
- return s.deleteFn(ctx, id)
65
- }
66
-
67
- // go-scaffold:repository-stub-methods
68
-
69
- //nolint:unused
70
- func status(t *testing.T, err error) int {
71
- t.Helper()
72
- var appErr *apperror.AppError
73
- if !errors.As(err, &appErr) {
74
- t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
75
- }
76
- return appErr.HTTPStatus
77
- }
@@ -1,36 +0,0 @@
1
- // Package model holds this domain's GORM tables — a folder (not a single
2
- // file) so a domain with more than one table can add a file per table
3
- // without cluttering the domain package's top level.
4
- package model
5
-
6
- import (
7
- "time"
8
-
9
- "github.com/google/uuid"
10
- )
11
-
12
- // {{pascalName}} = domain model + GORM table
13
- // TODO: add your real fields here.
14
- // ID = UUID v7 generated app-side (service.Create via id.New) instead of a DB
15
- // default, so the app has the id before insert without needing gen_random_uuid.
16
- type {{pascalName}} struct {
17
- ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
18
- CreatedAt time.Time `json:"created_at"`
19
- UpdatedAt time.Time `json:"updated_at"`
20
- // Version guards against two clients that both loaded this row overwriting
21
- // each other: an update only applies if the version it carries still
22
- // matches the stored one. Bumped by Repository.Update, never by hand.
23
- Version int `json:"version" gorm:"not null;default:1"`
24
- }
25
-
26
- // TableName pins the persistence contract to the versioned SQL migrations.
27
- // Do not rely on GORM's English inflector: irregular/plural module names must
28
- // resolve to exactly the same table in development and production.
29
- //
30
- // Schema-qualified ("{{schemaName}}", not just "{{tableName}}") so this
31
- // domain's table can never collide with another domain's, and a raw SQL JOIN
32
- // reaching into it from outside this package fails loudly instead of quietly
33
- // coupling two domains together.
34
- func ({{pascalName}}) TableName() string {
35
- return "{{schemaName}}.{{tableName}}"
36
- }
@@ -1,103 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "context"
5
- "errors"
6
-
7
- "{{goModule}}/internal/app/{{modulePath}}/model"
8
- "{{goModule}}/internal/shared/tx"
9
-
10
- "github.com/google/uuid"
11
- "gorm.io/gorm"
12
- )
13
-
14
- // ErrStaleVersion means the row moved on since the caller read it — someone
15
- // else saved first. Returned instead of overwriting their work.
16
- var ErrStaleVersion = errors.New("stale version")
17
-
18
- // Repository = data access for {{pkg}} (the only place that touches the DB for this domain)
19
- // every method takes ctx, so a cancelled request cancels the query too — and
20
- // so tx.From can pick up a transaction opened by the caller, letting two
21
- // repositories commit together without changing any signature here.
22
- type Repository struct {
23
- db *gorm.DB
24
- }
25
-
26
- func NewRepository(db *gorm.DB) *Repository {
27
- return &Repository{db: db}
28
- }
29
-
30
- func (r *Repository) Create(ctx context.Context, m *model.{{pascalName}}) error {
31
- return tx.From(ctx, r.db).WithContext(ctx).Create(m).Error
32
- }
33
-
34
- func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
35
- var items []model.{{pascalName}}
36
- err := tx.From(ctx, r.db).WithContext(ctx).Order("id desc").Limit(limit).Offset(offset).Find(&items).Error
37
- return items, err
38
- }
39
-
40
- func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
41
- var m model.{{pascalName}}
42
- // "id = ?" explicit — PK is a UUID, not an int, this avoids GORM misinterpreting the arg
43
- if err := tx.From(ctx, r.db).WithContext(ctx).First(&m, "id = ?", id).Error; err != nil {
44
- return nil, err
45
- }
46
- return &m, nil
47
- }
48
-
49
- // Update applies m only if the version it carries still matches the stored
50
- // row, then bumps it. Two clients that both loaded version 3 can't silently
51
- // overwrite each other: the second one gets ErrStaleVersion instead of the
52
- // first one's changes disappearing with no error anywhere.
53
- func (r *Repository) Update(ctx context.Context, m *model.{{pascalName}}) error {
54
- expected := m.Version
55
- m.Version = expected + 1
56
-
57
- res := tx.From(ctx, r.db).WithContext(ctx).
58
- Model(&model.{{pascalName}}{}).
59
- Where("id = ? AND version = ?", m.ID, expected).
60
- // Select("*") because Updates on a struct skips zero values, which
61
- // would quietly ignore a field the caller meant to clear.
62
- Select("*").Omit("id", "created_at").
63
- Updates(m)
64
- if res.Error != nil {
65
- m.Version = expected
66
- return res.Error
67
- }
68
- if res.RowsAffected == 0 {
69
- m.Version = expected
70
- return ErrStaleVersion
71
- }
72
- return nil
73
- }
74
-
75
- // Delete reports gorm.ErrRecordNotFound when the row wasn't there, so a
76
- // DELETE on an id that never existed answers 404 like GET does — a plain
77
- // GORM delete affects zero rows and returns no error, which had the same
78
- // request answer 204 as a real deletion.
79
- func (r *Repository) Delete(ctx context.Context, id uuid.UUID) error {
80
- res := tx.From(ctx, r.db).WithContext(ctx).Delete(&model.{{pascalName}}{}, "id = ?", id)
81
- if res.Error != nil {
82
- return res.Error
83
- }
84
- if res.RowsAffected == 0 {
85
- return gorm.ErrRecordNotFound
86
- }
87
- return nil
88
- }
89
-
90
- // Count is the total the list endpoint needs to say "page 3 of 12" — see
91
- // pagination.ResponseWithTotal. Separate from FindAll because it costs a second
92
- // query: an endpoint that only ever scrolls shouldn't pay for a count nobody
93
- // reads.
94
- //
95
- // Apply the same WHERE clause here that FindAll uses once you add filtering,
96
- // or the total will describe a different set than the rows.
97
- func (r *Repository) Count(ctx context.Context) (int64, error) {
98
- var total int64
99
- err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.{{pascalName}}{}).Count(&total).Error
100
- return total, err
101
- }
102
-
103
- // go-scaffold:repository-methods