@nakedev/go-scaffold 0.1.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 (73) hide show
  1. package/README.md +223 -0
  2. package/bin/go-scaffold.js +2 -0
  3. package/dist/commands/create.js +57 -0
  4. package/dist/commands/generate.js +97 -0
  5. package/dist/commands/method.js +70 -0
  6. package/dist/commands/remove.js +72 -0
  7. package/dist/index.js +138 -0
  8. package/dist/prompts/create-wizard.js +43 -0
  9. package/dist/prompts/generate-wizard.js +68 -0
  10. package/dist/templates/create-manifest.js +110 -0
  11. package/dist/templates/module-manifest.js +28 -0
  12. package/dist/types.js +2 -0
  13. package/dist/utils/config.js +53 -0
  14. package/dist/utils/main-patcher.js +59 -0
  15. package/dist/utils/marker-patch.js +63 -0
  16. package/dist/utils/method-patcher.js +271 -0
  17. package/dist/utils/migrations.js +17 -0
  18. package/dist/utils/module-paths.js +33 -0
  19. package/dist/utils/naming.js +160 -0
  20. package/dist/utils/openapi-patcher.js +47 -0
  21. package/dist/utils/template-renderer.js +51 -0
  22. package/package.json +49 -0
  23. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +83 -0
  24. package/templates/create/base/.env.example.hbs +7 -0
  25. package/templates/create/base/.github/workflows/ci.yml.hbs +46 -0
  26. package/templates/create/base/.gitignore.hbs +5 -0
  27. package/templates/create/base/.golangci.yml.hbs +32 -0
  28. package/templates/create/base/.vscode/settings.json.hbs +11 -0
  29. package/templates/create/base/AGENTS.md.hbs +68 -0
  30. package/templates/create/base/CLAUDE.md.hbs +1 -0
  31. package/templates/create/base/Makefile.hbs +93 -0
  32. package/templates/create/base/README.md.hbs +143 -0
  33. package/templates/create/base/cmd/api/main.go.hbs +116 -0
  34. package/templates/create/base/go.mod.hbs +11 -0
  35. package/templates/create/base/internal/platform/database/database.go.hbs +28 -0
  36. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +36 -0
  37. package/templates/create/base/internal/shared/config/config.go.hbs +46 -0
  38. package/templates/create/base/internal/shared/dberr/dberr.go.hbs +28 -0
  39. package/templates/create/base/internal/shared/httpx/httpx.go.hbs +37 -0
  40. package/templates/create/base/internal/shared/id/id.go.hbs +16 -0
  41. package/templates/create/base/internal/shared/middleware/error.go.hbs +33 -0
  42. package/templates/create/base/internal/shared/middleware/logger.go.hbs +23 -0
  43. package/templates/create/base/internal/shared/middleware/requestid.go.hbs +36 -0
  44. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +39 -0
  45. package/templates/create/base/migrations/.gitkeep.hbs +0 -0
  46. package/templates/create/features/docker-compose.yml.hbs +14 -0
  47. package/templates/create/features/docs/architecture.md.hbs +99 -0
  48. package/templates/create/features/docs/common/parameters.yaml.hbs +13 -0
  49. package/templates/create/features/docs/common/responses.yaml.hbs +20 -0
  50. package/templates/create/features/docs/common/schemas.yaml.hbs +23 -0
  51. package/templates/create/features/docs/health/health-livez.yaml.hbs +13 -0
  52. package/templates/create/features/docs/health/health-readyz.yaml.hbs +21 -0
  53. package/templates/create/features/docs/openapi.yaml.hbs +33 -0
  54. package/templates/create/features/docs/patterns.md.hbs +119 -0
  55. package/templates/create/features/docs/techstack.md.hbs +38 -0
  56. package/templates/generate/module/docs/collection.yaml.hbs +36 -0
  57. package/templates/generate/module/docs/item.yaml.hbs +37 -0
  58. package/templates/generate/module/docs/schemas.yaml.hbs +13 -0
  59. package/templates/generate/module/dto.go.hbs +29 -0
  60. package/templates/generate/module/errors.go.hbs +28 -0
  61. package/templates/generate/module/handler.go.hbs +103 -0
  62. package/templates/generate/module/handler_test.go.hbs +105 -0
  63. package/templates/generate/module/migration.down.sql.hbs +1 -0
  64. package/templates/generate/module/migration.up.sql.hbs +5 -0
  65. package/templates/generate/module/minimal/dto.go.hbs +26 -0
  66. package/templates/generate/module/minimal/handler.go.hbs +24 -0
  67. package/templates/generate/module/minimal/handler_test.go.hbs +70 -0
  68. package/templates/generate/module/minimal/service.go.hbs +45 -0
  69. package/templates/generate/module/minimal/service_test.go.hbs +54 -0
  70. package/templates/generate/module/model/model.go.hbs +20 -0
  71. package/templates/generate/module/repository.go.hbs +49 -0
  72. package/templates/generate/module/service.go.hbs +97 -0
  73. package/templates/generate/module/service_test.go.hbs +65 -0
@@ -0,0 +1,38 @@
1
+ # Tech Stack: {{projectName}}
2
+
3
+ > Status: generated by `@nakedev/go-scaffold`
4
+
5
+ ## Stack Overview
6
+
7
+ | Layer | Technology |
8
+ |-------|-----------|
9
+ | Language | Go 1.25 |
10
+ | HTTP framework | Gin (`github.com/gin-gonic/gin` v1.10.0) |
11
+ | Database | PostgreSQL |
12
+ | ORM | GORM (`gorm.io/gorm` v1.25.12 + `gorm.io/driver/postgres` v1.5.9) |
13
+ | Validation | `go-playground/validator/v10` v10.20.0 |
14
+ | IDs | UUID v7 (`google/uuid` v1.6.0) |
15
+ | Logging | `log/slog`, JSON handler |
16
+ | Migrations | [golang-migrate](https://github.com/golang-migrate/migrate) |
17
+ | Testing | stdlib `testing`, real Postgres for integration tests |
18
+
19
+ ## Scaffolded Capabilities
20
+
21
+ - Response envelope (`apperror`) and pagination helpers: always enabled
22
+ - PostgreSQL + GORM baseline: always enabled
23
+ - Graceful shutdown, structured logging, `/livez` + `/readyz`: always enabled
24
+ - Docker Compose (local Postgres): `{{#if docker}}enabled{{else}}disabled{{/if}}`
25
+ - OpenAPI docs (`docs/openapi.yaml`, whole `docs/` tree served at `/docs`): `{{#if openapiDocs}}enabled{{else}}disabled{{/if}}`
26
+ - API route prefix: `{{#if apiPrefix}}/{{apiPrefix}}{{else}}(none){{/if}}`
27
+ - CI (`.github/workflows/ci.yml` — build, vet, gofmt check, golangci-lint, `go test` with a real Postgres service): always enabled
28
+
29
+ ## Operational Notes
30
+
31
+ {{#if docker}}- Local Postgres comes from `docker-compose.yml`
32
+ {{else}}- No Docker scaffolding included — bring your own Postgres and point `DB_DSN` at it
33
+ {{/if}}- `AUTO_MIGRATE=true` (dev default) runs GORM AutoMigrate on boot; set it to
34
+ `false` in prod and run `migrate up` as a deploy step instead
35
+ {{#if openapiDocs}}- `docs/openapi.yaml` is hand-written, not generated — update it whenever an
36
+ endpoint or DTO changes
37
+ {{/if}}- Versions above are what `go-scaffold create` pinned in `go.mod` — bump them
38
+ by hand (`go get -u` + `go mod tidy`) as the stack evolves
@@ -0,0 +1,36 @@
1
+ post:
2
+ summary: Create {{pkg}}
3
+ operationId: create{{pascalName}}
4
+ tags: [{{plural}}]
5
+ requestBody:
6
+ required: true
7
+ content:
8
+ application/json:
9
+ schema: { $ref: './schemas.yaml#/{{pascalName}}CreateInput' }
10
+ responses:
11
+ "201":
12
+ description: created
13
+ content:
14
+ application/json:
15
+ schema: { $ref: './schemas.yaml#/{{pascalName}}Response' }
16
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
17
+ get:
18
+ summary: List {{plural}}
19
+ operationId: list{{pascalName}}
20
+ tags: [{{plural}}]
21
+ parameters:
22
+ - $ref: '../common/parameters.yaml#/Limit'
23
+ - $ref: '../common/parameters.yaml#/Offset'
24
+ responses:
25
+ "200":
26
+ description: paginated list
27
+ content:
28
+ application/json:
29
+ schema:
30
+ allOf:
31
+ - $ref: '../common/schemas.yaml#/PageEnvelope'
32
+ - type: object
33
+ properties:
34
+ data:
35
+ type: array
36
+ items: { $ref: './schemas.yaml#/{{pascalName}}Response' }
@@ -0,0 +1,37 @@
1
+ parameters:
2
+ - $ref: '../common/parameters.yaml#/IdParam'
3
+ get:
4
+ summary: Get {{pkg}}
5
+ operationId: get{{pascalName}}
6
+ tags: [{{plural}}]
7
+ responses:
8
+ "200":
9
+ description: ok
10
+ content:
11
+ application/json:
12
+ schema: { $ref: './schemas.yaml#/{{pascalName}}Response' }
13
+ "404": { $ref: '../common/responses.yaml#/NotFoundError' }
14
+ put:
15
+ summary: Update {{pkg}}
16
+ operationId: update{{pascalName}}
17
+ tags: [{{plural}}]
18
+ requestBody:
19
+ required: true
20
+ content:
21
+ application/json:
22
+ schema: { $ref: './schemas.yaml#/{{pascalName}}UpdateInput' }
23
+ responses:
24
+ "200":
25
+ description: updated
26
+ content:
27
+ application/json:
28
+ schema: { $ref: './schemas.yaml#/{{pascalName}}Response' }
29
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
30
+ "404": { $ref: '../common/responses.yaml#/NotFoundError' }
31
+ delete:
32
+ summary: Delete {{pkg}}
33
+ operationId: delete{{pascalName}}
34
+ tags: [{{plural}}]
35
+ responses:
36
+ "204": { description: deleted }
37
+ "404": { $ref: '../common/responses.yaml#/NotFoundError' }
@@ -0,0 +1,13 @@
1
+ {{pascalName}}CreateInput:
2
+ type: object
3
+ description: "TODO: add real request fields — matches the generated createInput stub, currently empty"
4
+ properties: {}
5
+ {{pascalName}}UpdateInput:
6
+ type: object
7
+ description: "TODO: add real request fields — matches the generated updateInput stub, currently empty"
8
+ properties: {}
9
+ {{pascalName}}Response:
10
+ type: object
11
+ properties:
12
+ id: { type: string, format: uuid }
13
+ created_at: { type: string, format: date-time }
@@ -0,0 +1,29 @@
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
+ type createInput struct {
13
+ }
14
+
15
+ // TODO: add request fields, e.g. Name string `json:"name" binding:"omitempty"`
16
+ type updateInput struct {
17
+ }
18
+
19
+ // response = the DTO sent out (kept separate from the model so a later DB column doesn't leak automatically)
20
+ type response struct {
21
+ ID uuid.UUID `json:"id"`
22
+ CreatedAt time.Time `json:"created_at"`
23
+ }
24
+
25
+ func toResponse(m *model.{{pascalName}}) response {
26
+ return response{ID: m.ID, CreatedAt: m.CreatedAt}
27
+ }
28
+
29
+ // go-scaffold:dto
@@ -0,0 +1,28 @@
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 errHasReferences() *apperror.AppError {
27
+ return apperror.New(http.StatusConflict, "{{errorPrefix}}_HAS_REFERENCES", "{{pkg}} still has related records")
28
+ }
@@ -0,0 +1,103 @@
1
+ package {{pkg}}
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/shared/httpx"
7
+ "{{goModule}}/internal/shared/pagination"
8
+
9
+ "github.com/gin-gonic/gin"
10
+ )
11
+
12
+ // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
13
+ type Handler struct {
14
+ svc *Service
15
+ }
16
+
17
+ func NewHandler(svc *Service) *Handler {
18
+ return &Handler{svc: svc}
19
+ }
20
+
21
+ // Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
22
+ func (h *Handler) Register(rg gin.IRouter) {
23
+ g := rg.Group("/{{plural}}")
24
+ g.POST("", h.create)
25
+ g.GET("", h.list)
26
+ g.GET("/:id", h.get)
27
+ g.PUT("/:id", h.update)
28
+ g.DELETE("/:id", h.delete)
29
+ // go-scaffold:handler-routes
30
+ }
31
+
32
+ func (h *Handler) create(c *gin.Context) {
33
+ var in createInput
34
+ if err := c.ShouldBindJSON(&in); err != nil {
35
+ c.Error(httpx.BindErr(err))
36
+ return
37
+ }
38
+ m, err := h.svc.Create(c.Request.Context())
39
+ if err != nil {
40
+ c.Error(err)
41
+ return
42
+ }
43
+ c.JSON(http.StatusCreated, toResponse(m))
44
+ }
45
+
46
+ func (h *Handler) list(c *gin.Context) {
47
+ p := pagination.Parse(c)
48
+ items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
49
+ if err != nil {
50
+ c.Error(err)
51
+ return
52
+ }
53
+ out := make([]response, len(items))
54
+ for i := range items {
55
+ out[i] = toResponse(&items[i])
56
+ }
57
+ c.JSON(http.StatusOK, p.Response(out))
58
+ }
59
+
60
+ func (h *Handler) get(c *gin.Context) {
61
+ id, ok := httpx.ParseID(c)
62
+ if !ok {
63
+ return
64
+ }
65
+ m, err := h.svc.Get(c.Request.Context(), id)
66
+ if err != nil {
67
+ c.Error(err)
68
+ return
69
+ }
70
+ c.JSON(http.StatusOK, toResponse(m))
71
+ }
72
+
73
+ func (h *Handler) update(c *gin.Context) {
74
+ id, ok := httpx.ParseID(c)
75
+ if !ok {
76
+ return
77
+ }
78
+ var in updateInput
79
+ if err := c.ShouldBindJSON(&in); err != nil {
80
+ c.Error(httpx.BindErr(err))
81
+ return
82
+ }
83
+ m, err := h.svc.Update(c.Request.Context(), id)
84
+ if err != nil {
85
+ c.Error(err)
86
+ return
87
+ }
88
+ c.JSON(http.StatusOK, toResponse(m))
89
+ }
90
+
91
+ func (h *Handler) delete(c *gin.Context) {
92
+ id, ok := httpx.ParseID(c)
93
+ if !ok {
94
+ return
95
+ }
96
+ if err := h.svc.Delete(c.Request.Context(), id); err != nil {
97
+ c.Error(err)
98
+ return
99
+ }
100
+ c.Status(http.StatusNoContent)
101
+ }
102
+
103
+ // go-scaffold:handler-funcs
@@ -0,0 +1,105 @@
1
+ package {{pkg}}
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "sync"
10
+ "testing"
11
+
12
+ "{{goModule}}/internal/app/{{modulePath}}/model"
13
+ "{{goModule}}/internal/shared/middleware"
14
+
15
+ "github.com/gin-gonic/gin"
16
+ "github.com/google/uuid"
17
+ "gorm.io/driver/postgres"
18
+ "gorm.io/gorm"
19
+ )
20
+
21
+ // integration test backed by real Postgres (same engine as prod, no sqlite) — skips if the DB isn't reachable
22
+ // start the DB: docker compose up -d (override with TEST_DB_DSN)
23
+ var (
24
+ testDBOnce sync.Once
25
+ testDB *gorm.DB
26
+ testDBErr error
27
+ )
28
+
29
+ func dbForTest(t *testing.T) *gorm.DB {
30
+ t.Helper()
31
+ testDBOnce.Do(func() {
32
+ dsn := os.Getenv("TEST_DB_DSN")
33
+ if dsn == "" {
34
+ dsn = "postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable"
35
+ }
36
+ if testDB, testDBErr = gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}); testDBErr == nil {
37
+ // drop first — AutoMigrate can't change an existing column's type, always start from a fresh schema
38
+ _ = testDB.Migrator().DropTable(&model.{{pascalName}}{})
39
+ testDBErr = testDB.AutoMigrate(&model.{{pascalName}}{})
40
+ }
41
+ })
42
+ if testDBErr != nil {
43
+ t.Skipf("postgres not ready (docker compose up -d, or set TEST_DB_DSN): %v", testDBErr)
44
+ }
45
+ return testDB
46
+ }
47
+
48
+ // setup builds the full stack on a transaction that's rolled back at the end → each test is isolated, no leftover rows
49
+ func setup(t *testing.T) *gin.Engine {
50
+ t.Helper()
51
+ gin.SetMode(gin.TestMode)
52
+ tx := dbForTest(t).Begin()
53
+ t.Cleanup(func() { tx.Rollback() })
54
+ r := gin.New()
55
+ r.Use(middleware.RequestID(), middleware.Error())
56
+ NewHandler(NewService(NewRepository(tx))).Register(r)
57
+ return r
58
+ }
59
+
60
+ func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
61
+ req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
62
+ req.Header.Set("Content-Type", "application/json")
63
+ w := httptest.NewRecorder()
64
+ r.ServeHTTP(w, req)
65
+ return w
66
+ }
67
+
68
+ func TestHandler_Create_OK(t *testing.T) {
69
+ r := setup(t)
70
+ w := do(r, http.MethodPost, "/{{plural}}", `{}`)
71
+ if w.Code != http.StatusCreated {
72
+ t.Fatalf("want 201, got %d body=%s", w.Code, w.Body)
73
+ }
74
+ }
75
+
76
+ func TestHandler_Get_NotFound(t *testing.T) {
77
+ r := setup(t)
78
+ w := do(r, http.MethodGet, "/{{plural}}/"+uuid.NewString(), "")
79
+ if w.Code != http.StatusNotFound {
80
+ t.Fatalf("want 404, got %d body=%s", w.Code, w.Body)
81
+ }
82
+ }
83
+
84
+ func TestHandler_Get_InvalidID(t *testing.T) {
85
+ r := setup(t)
86
+ w := do(r, http.MethodGet, "/{{plural}}/not-a-uuid", "")
87
+ if w.Code != http.StatusBadRequest {
88
+ t.Fatalf("want 400, got %d body=%s", w.Code, w.Body)
89
+ }
90
+ }
91
+
92
+ func TestHandler_Delete_OK(t *testing.T) {
93
+ r := setup(t)
94
+ created := do(r, http.MethodPost, "/{{plural}}", `{}`)
95
+ var body struct {
96
+ ID string `json:"id"`
97
+ }
98
+ if err := json.Unmarshal(created.Body.Bytes(), &body); err != nil {
99
+ t.Fatalf("decode create response: %v", err)
100
+ }
101
+ w := do(r, http.MethodDelete, "/{{plural}}/"+body.ID, "")
102
+ if w.Code != http.StatusNoContent {
103
+ t.Fatalf("want 204, got %d body=%s", w.Code, w.Body)
104
+ }
105
+ }
@@ -0,0 +1 @@
1
+ DROP TABLE IF EXISTS {{plural}};
@@ -0,0 +1,5 @@
1
+ CREATE TABLE {{plural}} (
2
+ id UUID PRIMARY KEY,
3
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
4
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
5
+ );
@@ -0,0 +1,26 @@
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
+ }
20
+
21
+ //nolint:unused
22
+ func toResponse(m *model.{{pascalName}}) response {
23
+ return response{ID: m.ID, CreatedAt: m.CreatedAt}
24
+ }
25
+
26
+ // go-scaffold:dto
@@ -0,0 +1,24 @@
1
+ package {{pkg}}
2
+
3
+ import (
4
+ "github.com/gin-gonic/gin"
5
+ )
6
+
7
+ // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
8
+ type Handler struct {
9
+ svc *Service
10
+ }
11
+
12
+ func NewHandler(svc *Service) *Handler {
13
+ return &Handler{svc: svc}
14
+ }
15
+
16
+ // Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
17
+ // minimal module: no routes yet — add them with `go-scaffold generate method {{pkg}} <name> --type ...`
18
+ func (h *Handler) Register(rg gin.IRouter) {
19
+ g := rg.Group("/{{plural}}")
20
+ _ = g
21
+ // go-scaffold:handler-routes
22
+ }
23
+
24
+ // go-scaffold:handler-funcs
@@ -0,0 +1,70 @@
1
+ package {{pkg}}
2
+
3
+ import (
4
+ "bytes"
5
+ "net/http/httptest"
6
+ "os"
7
+ "sync"
8
+ "testing"
9
+
10
+ "{{goModule}}/internal/app/{{modulePath}}/model"
11
+ "{{goModule}}/internal/shared/middleware"
12
+
13
+ "gorm.io/gorm"
14
+
15
+ "github.com/gin-gonic/gin"
16
+ "gorm.io/driver/postgres"
17
+ )
18
+
19
+ // integration test harness, backed by real Postgres (same engine as prod, no sqlite) — skips if
20
+ // the DB isn't reachable. minimal module: no routes yet, so no tests reference this yet — kept
21
+ // ready for once `generate method` adds endpoints (start the DB: docker compose up -d, override
22
+ // with TEST_DB_DSN)
23
+ //nolint:unused
24
+ var (
25
+ testDBOnce sync.Once
26
+ testDB *gorm.DB
27
+ testDBErr error
28
+ )
29
+
30
+ //nolint:unused
31
+ func dbForTest(t *testing.T) *gorm.DB {
32
+ t.Helper()
33
+ testDBOnce.Do(func() {
34
+ dsn := os.Getenv("TEST_DB_DSN")
35
+ if dsn == "" {
36
+ dsn = "postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable"
37
+ }
38
+ if testDB, testDBErr = gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}); testDBErr == nil {
39
+ _ = testDB.Migrator().DropTable(&model.{{pascalName}}{})
40
+ testDBErr = testDB.AutoMigrate(&model.{{pascalName}}{})
41
+ }
42
+ })
43
+ if testDBErr != nil {
44
+ t.Skipf("postgres not ready (docker compose up -d, or set TEST_DB_DSN): %v", testDBErr)
45
+ }
46
+ return testDB
47
+ }
48
+
49
+ // setup builds the full stack on a transaction that's rolled back at the end → each test is isolated, no leftover rows
50
+ //
51
+ //nolint:unused
52
+ func setup(t *testing.T) *gin.Engine {
53
+ t.Helper()
54
+ gin.SetMode(gin.TestMode)
55
+ tx := dbForTest(t).Begin()
56
+ t.Cleanup(func() { tx.Rollback() })
57
+ r := gin.New()
58
+ r.Use(middleware.RequestID(), middleware.Error())
59
+ NewHandler(NewService(NewRepository(tx))).Register(r)
60
+ return r
61
+ }
62
+
63
+ //nolint:unused
64
+ func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
65
+ req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
66
+ req.Header.Set("Content-Type", "application/json")
67
+ w := httptest.NewRecorder()
68
+ r.ServeHTTP(w, req)
69
+ return w
70
+ }
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,54 @@
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
+ // fakeRepo = mock of the repository interface, so the service can be tested without a DB
15
+ // minimal module: no service methods yet, so no tests reference this — kept ready for
16
+ // when `generate method` adds one (its patches append matching stubs here automatically
17
+ // for a `get --get-mode one --field` lookup)
18
+ //nolint:unused
19
+ type fakeRepo struct {
20
+ err error
21
+ m *model.{{pascalName}}
22
+ }
23
+
24
+ //nolint:unused
25
+ func (f *fakeRepo) Create(context.Context, *model.{{pascalName}}) error { return f.err }
26
+
27
+ //nolint:unused
28
+ func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.{{pascalName}}, error) { return nil, f.err }
29
+
30
+ //nolint:unused
31
+ func (f *fakeRepo) FindByID(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
32
+ if f.err != nil {
33
+ return nil, f.err
34
+ }
35
+ return f.m, nil
36
+ }
37
+
38
+ //nolint:unused
39
+ func (f *fakeRepo) Update(context.Context, *model.{{pascalName}}) error { return f.err }
40
+
41
+ //nolint:unused
42
+ func (f *fakeRepo) Delete(context.Context, uuid.UUID) error { return f.err }
43
+
44
+ // go-scaffold:fake-repo-methods
45
+
46
+ //nolint:unused
47
+ func status(t *testing.T, err error) int {
48
+ t.Helper()
49
+ var appErr *apperror.AppError
50
+ if !errors.As(err, &appErr) {
51
+ t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
52
+ }
53
+ return appErr.HTTPStatus
54
+ }
@@ -0,0 +1,20 @@
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
+ }