@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.
- package/README.md +223 -0
- package/bin/go-scaffold.js +2 -0
- package/dist/commands/create.js +57 -0
- package/dist/commands/generate.js +97 -0
- package/dist/commands/method.js +70 -0
- package/dist/commands/remove.js +72 -0
- package/dist/index.js +138 -0
- package/dist/prompts/create-wizard.js +43 -0
- package/dist/prompts/generate-wizard.js +68 -0
- package/dist/templates/create-manifest.js +110 -0
- package/dist/templates/module-manifest.js +28 -0
- package/dist/types.js +2 -0
- package/dist/utils/config.js +53 -0
- package/dist/utils/main-patcher.js +59 -0
- package/dist/utils/marker-patch.js +63 -0
- package/dist/utils/method-patcher.js +271 -0
- package/dist/utils/migrations.js +17 -0
- package/dist/utils/module-paths.js +33 -0
- package/dist/utils/naming.js +160 -0
- package/dist/utils/openapi-patcher.js +47 -0
- package/dist/utils/template-renderer.js +51 -0
- package/package.json +49 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +83 -0
- package/templates/create/base/.env.example.hbs +7 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +46 -0
- package/templates/create/base/.gitignore.hbs +5 -0
- package/templates/create/base/.golangci.yml.hbs +32 -0
- package/templates/create/base/.vscode/settings.json.hbs +11 -0
- package/templates/create/base/AGENTS.md.hbs +68 -0
- package/templates/create/base/CLAUDE.md.hbs +1 -0
- package/templates/create/base/Makefile.hbs +93 -0
- package/templates/create/base/README.md.hbs +143 -0
- package/templates/create/base/cmd/api/main.go.hbs +116 -0
- package/templates/create/base/go.mod.hbs +11 -0
- package/templates/create/base/internal/platform/database/database.go.hbs +28 -0
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +36 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +46 -0
- package/templates/create/base/internal/shared/dberr/dberr.go.hbs +28 -0
- package/templates/create/base/internal/shared/httpx/httpx.go.hbs +37 -0
- package/templates/create/base/internal/shared/id/id.go.hbs +16 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +33 -0
- package/templates/create/base/internal/shared/middleware/logger.go.hbs +23 -0
- package/templates/create/base/internal/shared/middleware/requestid.go.hbs +36 -0
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +39 -0
- package/templates/create/base/migrations/.gitkeep.hbs +0 -0
- package/templates/create/features/docker-compose.yml.hbs +14 -0
- package/templates/create/features/docs/architecture.md.hbs +99 -0
- package/templates/create/features/docs/common/parameters.yaml.hbs +13 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +20 -0
- package/templates/create/features/docs/common/schemas.yaml.hbs +23 -0
- package/templates/create/features/docs/health/health-livez.yaml.hbs +13 -0
- package/templates/create/features/docs/health/health-readyz.yaml.hbs +21 -0
- package/templates/create/features/docs/openapi.yaml.hbs +33 -0
- package/templates/create/features/docs/patterns.md.hbs +119 -0
- package/templates/create/features/docs/techstack.md.hbs +38 -0
- package/templates/generate/module/docs/collection.yaml.hbs +36 -0
- package/templates/generate/module/docs/item.yaml.hbs +37 -0
- package/templates/generate/module/docs/schemas.yaml.hbs +13 -0
- package/templates/generate/module/dto.go.hbs +29 -0
- package/templates/generate/module/errors.go.hbs +28 -0
- package/templates/generate/module/handler.go.hbs +103 -0
- package/templates/generate/module/handler_test.go.hbs +105 -0
- package/templates/generate/module/migration.down.sql.hbs +1 -0
- package/templates/generate/module/migration.up.sql.hbs +5 -0
- package/templates/generate/module/minimal/dto.go.hbs +26 -0
- package/templates/generate/module/minimal/handler.go.hbs +24 -0
- package/templates/generate/module/minimal/handler_test.go.hbs +70 -0
- package/templates/generate/module/minimal/service.go.hbs +45 -0
- package/templates/generate/module/minimal/service_test.go.hbs +54 -0
- package/templates/generate/module/model/model.go.hbs +20 -0
- package/templates/generate/module/repository.go.hbs +49 -0
- package/templates/generate/module/service.go.hbs +97 -0
- 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,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
|
+
}
|