@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,36 @@
|
|
|
1
|
+
package apperror
|
|
2
|
+
|
|
3
|
+
import "net/http"
|
|
4
|
+
|
|
5
|
+
// AppError is an error that knows both its HTTP status and its payload.
|
|
6
|
+
// Lower layers (service/repo) can return one without importing net/http themselves.
|
|
7
|
+
type AppError struct {
|
|
8
|
+
HTTPStatus int `json:"-"`
|
|
9
|
+
Code string `json:"code"`
|
|
10
|
+
Message string `json:"message"`
|
|
11
|
+
Details any `json:"details,omitempty"`
|
|
12
|
+
RequestID string `json:"request_id,omitempty"` // filled in by the error middleware
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func (e *AppError) Error() string { return e.Message }
|
|
16
|
+
|
|
17
|
+
func New(status int, code, msg string) *AppError {
|
|
18
|
+
return &AppError{HTTPStatus: status, Code: code, Message: msg}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func NewValidation(msg string, details any) *AppError {
|
|
22
|
+
return &AppError{HTTPStatus: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: msg, Details: details}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func NewNotFound(msg string) *AppError {
|
|
26
|
+
return &AppError{HTTPStatus: http.StatusNotFound, Code: "NOT_FOUND", Message: msg}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func NewConflict(msg string) *AppError {
|
|
30
|
+
return &AppError{HTTPStatus: http.StatusConflict, Code: "CONFLICT", Message: msg}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func NewInternal() *AppError {
|
|
34
|
+
// ponytail: never leak internal details to the client, log separately in middleware
|
|
35
|
+
return &AppError{HTTPStatus: http.StatusInternalServerError, Code: "INTERNAL", Message: "internal server error"}
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package config
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"strconv"
|
|
6
|
+
"time"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Config loads from env (with dev-friendly defaults).
|
|
10
|
+
type Config struct {
|
|
11
|
+
Port string
|
|
12
|
+
DBDSN string
|
|
13
|
+
LogLevel string
|
|
14
|
+
AutoMigrate bool
|
|
15
|
+
DBMaxOpenConns int
|
|
16
|
+
DBMaxIdleConns int
|
|
17
|
+
DBConnMaxLifetime time.Duration
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func Load() Config {
|
|
21
|
+
return Config{
|
|
22
|
+
Port: env("PORT", "8080"),
|
|
23
|
+
DBDSN: env("DB_DSN", "postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable"),
|
|
24
|
+
LogLevel: env("LOG_LEVEL", "info"),
|
|
25
|
+
AutoMigrate: env("AUTO_MIGRATE", "true") == "true",
|
|
26
|
+
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 10),
|
|
27
|
+
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 10),
|
|
28
|
+
DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MIN", 5)) * time.Minute,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func env(k, def string) string {
|
|
33
|
+
if v := os.Getenv(k); v != "" {
|
|
34
|
+
return v
|
|
35
|
+
}
|
|
36
|
+
return def
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func envInt(k string, def int) int {
|
|
40
|
+
if v := os.Getenv(k); v != "" {
|
|
41
|
+
if n, err := strconv.Atoi(v); err == nil {
|
|
42
|
+
return n
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return def
|
|
46
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Package dberr translates driver/GORM errors into "which constraint kind
|
|
2
|
+
// broke" — glue with no I/O, so it lives in shared/, not platform/.
|
|
3
|
+
// Centralized so every domain maps DB errors to HTTP status the same way,
|
|
4
|
+
// instead of copy-pasting the logic per module.
|
|
5
|
+
package dberr
|
|
6
|
+
|
|
7
|
+
import (
|
|
8
|
+
"errors"
|
|
9
|
+
"strings"
|
|
10
|
+
|
|
11
|
+
"gorm.io/gorm"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// IsDuplicate reports a unique constraint violation — gorm.ErrDuplicatedKey
|
|
15
|
+
// (requires TranslateError:true), with a string fallback in case the driver
|
|
16
|
+
// doesn't translate: postgres "duplicate key value violates unique constraint".
|
|
17
|
+
func IsDuplicate(err error) bool {
|
|
18
|
+
return errors.Is(err, gorm.ErrDuplicatedKey) ||
|
|
19
|
+
strings.Contains(strings.ToLower(err.Error()), "unique constraint")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// IsForeignKey reports a foreign key violation (referencing a missing parent,
|
|
23
|
+
// or deleting a parent that's still referenced).
|
|
24
|
+
// gorm.ErrForeignKeyViolated + string fallback: postgres "...foreign key constraint...".
|
|
25
|
+
func IsForeignKey(err error) bool {
|
|
26
|
+
return errors.Is(err, gorm.ErrForeignKeyViolated) ||
|
|
27
|
+
strings.Contains(strings.ToLower(err.Error()), "foreign key")
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package httpx
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"strings"
|
|
6
|
+
|
|
7
|
+
"{{goModule}}/internal/shared/apperror"
|
|
8
|
+
|
|
9
|
+
"github.com/gin-gonic/gin"
|
|
10
|
+
"github.com/go-playground/validator/v10"
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// ParseID reads the ":id" route param as a UUID — shared by every domain
|
|
15
|
+
// whose resource is identified by id.
|
|
16
|
+
func ParseID(c *gin.Context) (uuid.UUID, bool) {
|
|
17
|
+
id, err := uuid.Parse(c.Param("id"))
|
|
18
|
+
if err != nil {
|
|
19
|
+
c.Error(apperror.NewValidation("invalid id", nil))
|
|
20
|
+
return uuid.Nil, false
|
|
21
|
+
}
|
|
22
|
+
return id, true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// BindErr converts a validator error into a validation payload with a field
|
|
26
|
+
// map (shared by every domain).
|
|
27
|
+
func BindErr(err error) *apperror.AppError {
|
|
28
|
+
var ve validator.ValidationErrors
|
|
29
|
+
if errors.As(err, &ve) {
|
|
30
|
+
details := make(map[string]string, len(ve))
|
|
31
|
+
for _, fe := range ve {
|
|
32
|
+
details[strings.ToLower(fe.Field())] = fe.Tag()
|
|
33
|
+
}
|
|
34
|
+
return apperror.NewValidation("invalid input", details)
|
|
35
|
+
}
|
|
36
|
+
return apperror.NewValidation("invalid json body", nil)
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Package id creates entity identifiers — "the whole system uses UUID v7" is
|
|
2
|
+
// decided in one place, so changing the scheme is a one-file change.
|
|
3
|
+
package id
|
|
4
|
+
|
|
5
|
+
import "github.com/google/uuid"
|
|
6
|
+
|
|
7
|
+
// New returns a UUID v7 (time-ordered, so rows sort by creation time and the
|
|
8
|
+
// B-tree takes fewer page splits than v4 random IDs under heavy writes).
|
|
9
|
+
// Falls back to v4 if entropy fails (practically impossible — reads
|
|
10
|
+
// crypto/rand), so the entity-creation path never has to handle the error.
|
|
11
|
+
func New() uuid.UUID {
|
|
12
|
+
if v7, err := uuid.NewV7(); err == nil {
|
|
13
|
+
return v7
|
|
14
|
+
}
|
|
15
|
+
return uuid.New()
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"log/slog"
|
|
6
|
+
|
|
7
|
+
"{{goModule}}/internal/shared/apperror"
|
|
8
|
+
|
|
9
|
+
"github.com/gin-gonic/gin"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Error reads the last error attached via c.Error() and renders it once, so
|
|
13
|
+
// handlers only ever do c.Error(err); return — no c.JSON per call site.
|
|
14
|
+
func Error() gin.HandlerFunc {
|
|
15
|
+
return func(c *gin.Context) {
|
|
16
|
+
c.Next()
|
|
17
|
+
|
|
18
|
+
err := c.Errors.Last()
|
|
19
|
+
if err == nil {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
var appErr *apperror.AppError
|
|
24
|
+
if !errors.As(err.Err, &appErr) {
|
|
25
|
+
// Unexpected error: log the real thing, answer the client generically.
|
|
26
|
+
slog.Error("unhandled error", "error", err.Err, "request_id", c.GetString(RequestIDKey))
|
|
27
|
+
appErr = apperror.NewInternal()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
appErr.RequestID = c.GetString(RequestIDKey)
|
|
31
|
+
c.JSON(appErr.HTTPStatus, gin.H{"error": appErr})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"log/slog"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/gin-gonic/gin"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Logger logs each request as structured JSON instead of gin's default text logger.
|
|
11
|
+
func Logger(log *slog.Logger) gin.HandlerFunc {
|
|
12
|
+
return func(c *gin.Context) {
|
|
13
|
+
start := time.Now()
|
|
14
|
+
c.Next()
|
|
15
|
+
log.Info("request",
|
|
16
|
+
"method", c.Request.Method,
|
|
17
|
+
"path", c.Request.URL.Path,
|
|
18
|
+
"status", c.Writer.Status(),
|
|
19
|
+
"duration_ms", time.Since(start).Milliseconds(),
|
|
20
|
+
"request_id", c.GetString(RequestIDKey),
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/rand"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
|
|
7
|
+
"github.com/gin-gonic/gin"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
const (
|
|
11
|
+
RequestIDKey = "request_id"
|
|
12
|
+
requestIDHeader = "X-Request-ID"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// RequestID reads X-Request-ID from the client, generating one if absent,
|
|
16
|
+
// stores it in context, and echoes it back in the response header — used to
|
|
17
|
+
// correlate logs/errors across services.
|
|
18
|
+
func RequestID() gin.HandlerFunc {
|
|
19
|
+
return func(c *gin.Context) {
|
|
20
|
+
id := c.GetHeader(requestIDHeader)
|
|
21
|
+
if id == "" {
|
|
22
|
+
id = newID()
|
|
23
|
+
}
|
|
24
|
+
c.Set(RequestIDKey, id)
|
|
25
|
+
c.Header(requestIDHeader, id)
|
|
26
|
+
c.Next()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func newID() string {
|
|
31
|
+
b := make([]byte, 8)
|
|
32
|
+
if _, err := rand.Read(b); err != nil {
|
|
33
|
+
return "unknown"
|
|
34
|
+
}
|
|
35
|
+
return hex.EncodeToString(b)
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package pagination
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strconv"
|
|
5
|
+
|
|
6
|
+
"github.com/gin-gonic/gin"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
const (
|
|
10
|
+
defaultLimit = 20
|
|
11
|
+
maxLimit = 100
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Params is a validated limit/offset pair (default 20, capped at 100).
|
|
15
|
+
type Params struct {
|
|
16
|
+
Limit int
|
|
17
|
+
Offset int
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Parse reads ?limit=&offset= off the request so every feature parses the
|
|
21
|
+
// same way.
|
|
22
|
+
func Parse(c *gin.Context) Params {
|
|
23
|
+
p := Params{Limit: defaultLimit}
|
|
24
|
+
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
|
|
25
|
+
p.Limit = v
|
|
26
|
+
}
|
|
27
|
+
if p.Limit > maxLimit {
|
|
28
|
+
p.Limit = maxLimit
|
|
29
|
+
}
|
|
30
|
+
if v, err := strconv.Atoi(c.Query("offset")); err == nil && v >= 0 {
|
|
31
|
+
p.Offset = v
|
|
32
|
+
}
|
|
33
|
+
return p
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Response wraps a list so every feature responds with the same shape.
|
|
37
|
+
func (p Params) Response(data any) gin.H {
|
|
38
|
+
return gin.H{"data": data, "limit": p.Limit, "offset": p.Offset}
|
|
39
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Architecture Decision Record: {{projectName}}
|
|
2
|
+
|
|
3
|
+
> Status: generated by `@nakedev/go-scaffold`
|
|
4
|
+
> Scope: initial scaffold
|
|
5
|
+
|
|
6
|
+
## 1. Topology
|
|
7
|
+
|
|
8
|
+
**Decision:** Single Go binary (`cmd/api`), REST over Gin.
|
|
9
|
+
**Rationale:** One process serves all domains; a second entry point
|
|
10
|
+
(`cmd/worker`, `cmd/migrate`) gets its own `main.go` only once there's a real
|
|
11
|
+
second process — `main.go` has no importers, so adding one later touches no
|
|
12
|
+
other file.
|
|
13
|
+
|
|
14
|
+
## 2. Package Layout
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
cmd/
|
|
18
|
+
└── api/main.go # config, slog, graceful shutdown, wires domains
|
|
19
|
+
internal/
|
|
20
|
+
├── platform/ # talks to real external systems (DB, later: cache, queue, mail, ...)
|
|
21
|
+
│ └── database/
|
|
22
|
+
├── shared/ # pure logic/framework glue, no I/O
|
|
23
|
+
│ ├── config/ apperror/ dberr/ httpx/ middleware/ pagination/
|
|
24
|
+
└── app/ # domain packages (one per feature)
|
|
25
|
+
└── <domain>/
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Decision:** `platform/` vs `shared/` is split by whether the code talks to
|
|
29
|
+
a real external system.
|
|
30
|
+
**Rationale:** Code that does I/O against Postgres/cache/queue/SMTP/S3 →
|
|
31
|
+
`platform/`. Pure logic or HTTP-framework glue with no I/O → `shared/`. Never
|
|
32
|
+
add a `shared/utils` or similarly generic package — name it after what it
|
|
33
|
+
actually does.
|
|
34
|
+
|
|
35
|
+
## 3. API Style
|
|
36
|
+
|
|
37
|
+
**Decision:** REST over Gin, one handler package per domain, every route
|
|
38
|
+
grouped under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}.
|
|
39
|
+
**Rationale:** `handler → service → repository`, one direction, all three
|
|
40
|
+
layers in the same package — no cross-domain imports. The prefix is a
|
|
41
|
+
single project-wide choice made at `create` time (`--api-prefix`) — there is
|
|
42
|
+
no per-domain versioning; a domain that needs a real breaking change gets a
|
|
43
|
+
new domain package (or a new field on the existing DTO), not a duplicated
|
|
44
|
+
model pointed at the same table under a different URL.
|
|
45
|
+
|
|
46
|
+
## 4. Response and Error Model
|
|
47
|
+
|
|
48
|
+
**Decision:** Central `apperror.AppError` (HTTP status + machine-readable
|
|
49
|
+
`code` + message), rendered once by `middleware.Error()`.
|
|
50
|
+
**Rationale:** Handlers only ever do `c.Error(err); return` — no `c.JSON` per
|
|
51
|
+
call site, no error-shape drift between domains.
|
|
52
|
+
|
|
53
|
+
- Domain-specific errors live in each package's `errors.go` (e.g.
|
|
54
|
+
`USER_NOT_FOUND`, not a bare `NOT_FOUND`)
|
|
55
|
+
- DB errors are classified once in `shared/dberr` (`IsDuplicate`,
|
|
56
|
+
`IsForeignKey`) and mapped to the right HTTP status per domain
|
|
57
|
+
|
|
58
|
+
## 5. Pagination
|
|
59
|
+
|
|
60
|
+
**Decision:** Shared `limit`/`offset` parsing and response envelope
|
|
61
|
+
(`shared/pagination`), used by every list endpoint.
|
|
62
|
+
**Rationale:** One implementation, one response shape (`{data, limit,
|
|
63
|
+
offset}`) — no per-domain reinvention.
|
|
64
|
+
|
|
65
|
+
## 6. Persistence
|
|
66
|
+
|
|
67
|
+
**Decision:** PostgreSQL + GORM, schema managed by
|
|
68
|
+
[golang-migrate](https://github.com/golang-migrate/migrate).
|
|
69
|
+
**Rationale:** `AUTO_MIGRATE=true` runs GORM's AutoMigrate in dev for speed;
|
|
70
|
+
prod runs `migrate up` as a separate, versioned, rollback-capable step —
|
|
71
|
+
AutoMigrate is add-only and locks tables once there's real data.
|
|
72
|
+
|
|
73
|
+
{{#if docker}}- `docker-compose.yml` provides the local Postgres instance
|
|
74
|
+
{{/if}}
|
|
75
|
+
## 7. IDs
|
|
76
|
+
|
|
77
|
+
**Decision:** UUID v7 for every entity, generated app-side
|
|
78
|
+
(`shared/id.New()`), not by a DB default.
|
|
79
|
+
**Rationale:** Time-ordered IDs sort by creation time, reducing B-tree page
|
|
80
|
+
splits versus random v4 under heavy writes — and the app has the ID before
|
|
81
|
+
insert, so it doesn't need `gen_random_uuid()`.
|
|
82
|
+
|
|
83
|
+
{{#if openapiDocs}}
|
|
84
|
+
## 8. API Documentation
|
|
85
|
+
|
|
86
|
+
**Decision:** Hand-written OpenAPI spec (`docs/openapi.yaml`), split by
|
|
87
|
+
domain module, served under `/docs` (so relative `$ref`s to sibling files
|
|
88
|
+
resolve over HTTP too — the index alone isn't enough for a renderer like
|
|
89
|
+
Scalar/Swagger UI/Redoc to follow them).
|
|
90
|
+
**Rationale:** Cheap while the endpoint count is low; switch to
|
|
91
|
+
comment-generated (swaggo) if hand-updates start drifting.
|
|
92
|
+
{{/if}}
|
|
93
|
+
|
|
94
|
+
## Evolution Notes
|
|
95
|
+
|
|
96
|
+
- `go-scaffold generate module <name>` adds a new domain package and
|
|
97
|
+
wires it into `cmd/api/main.go`
|
|
98
|
+
- This document only reflects the initial scaffold — update it as the real
|
|
99
|
+
architecture evolves
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
IdParam:
|
|
2
|
+
name: id
|
|
3
|
+
in: path
|
|
4
|
+
required: true
|
|
5
|
+
schema: { type: string, format: uuid }
|
|
6
|
+
Limit:
|
|
7
|
+
name: limit
|
|
8
|
+
in: query
|
|
9
|
+
schema: { type: integer, default: 20, maximum: 100 }
|
|
10
|
+
Offset:
|
|
11
|
+
name: offset
|
|
12
|
+
in: query
|
|
13
|
+
schema: { type: integer, default: 0, minimum: 0 }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
ValidationError:
|
|
2
|
+
description: invalid input
|
|
3
|
+
content:
|
|
4
|
+
application/json:
|
|
5
|
+
schema: { $ref: './schemas.yaml#/Error' }
|
|
6
|
+
NotFoundError:
|
|
7
|
+
description: not found
|
|
8
|
+
content:
|
|
9
|
+
application/json:
|
|
10
|
+
schema: { $ref: './schemas.yaml#/Error' }
|
|
11
|
+
ConflictError:
|
|
12
|
+
description: conflict (duplicate, or resource still referenced)
|
|
13
|
+
content:
|
|
14
|
+
application/json:
|
|
15
|
+
schema: { $ref: './schemas.yaml#/Error' }
|
|
16
|
+
UnprocessableEntityError:
|
|
17
|
+
description: input well-formed but references a resource that doesn't exist
|
|
18
|
+
content:
|
|
19
|
+
application/json:
|
|
20
|
+
schema: { $ref: './schemas.yaml#/Error' }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
PageEnvelope:
|
|
2
|
+
type: object
|
|
3
|
+
description: "envelope shared by every list endpoint — data is an array of that resource"
|
|
4
|
+
properties:
|
|
5
|
+
limit: { type: integer }
|
|
6
|
+
offset: { type: integer }
|
|
7
|
+
|
|
8
|
+
Error:
|
|
9
|
+
type: object
|
|
10
|
+
properties:
|
|
11
|
+
error:
|
|
12
|
+
type: object
|
|
13
|
+
properties:
|
|
14
|
+
code: { type: string, description: "machine-readable, e.g. USER_NOT_FOUND, VALIDATION_ERROR" }
|
|
15
|
+
message: { type: string }
|
|
16
|
+
details: { type: object, additionalProperties: true, nullable: true, description: "only present for VALIDATION_ERROR, field->tag that failed" }
|
|
17
|
+
request_id: { type: string, description: "correlates with server logs (header X-Request-ID)" }
|
|
18
|
+
example:
|
|
19
|
+
error:
|
|
20
|
+
code: "VALIDATION_ERROR"
|
|
21
|
+
message: "invalid input"
|
|
22
|
+
details: { email: "email" }
|
|
23
|
+
request_id: "a1b2c3d4e5f6a7b8"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
get:
|
|
2
|
+
summary: Readiness probe (ping DB)
|
|
3
|
+
operationId: getReadyz
|
|
4
|
+
tags: [health]
|
|
5
|
+
responses:
|
|
6
|
+
"200":
|
|
7
|
+
description: ready
|
|
8
|
+
content:
|
|
9
|
+
application/json:
|
|
10
|
+
schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
status: { type: string, example: ok }
|
|
14
|
+
"503":
|
|
15
|
+
description: DB unreachable
|
|
16
|
+
content:
|
|
17
|
+
application/json:
|
|
18
|
+
schema:
|
|
19
|
+
type: object
|
|
20
|
+
properties:
|
|
21
|
+
status: { type: string, example: unavailable }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# ponytail: hand-written, not generated from annotations — cheap while the
|
|
2
|
+
# endpoint count is low. Ceiling: must be updated by hand whenever an
|
|
3
|
+
# endpoint/DTO changes beyond what `generate module` scaffolds.
|
|
4
|
+
# Switch to swaggo (comment-generated) if drift becomes a recurring problem.
|
|
5
|
+
# multi-file: split by domain module (health/<domain>) + common/ for shared
|
|
6
|
+
# pieces — this file is just the index. `generate module <name>`
|
|
7
|
+
# adds its paths/schemas here automatically; `generate method` does not
|
|
8
|
+
# (endpoint-specific docs stay hand-written).
|
|
9
|
+
openapi: 3.0.3
|
|
10
|
+
info:
|
|
11
|
+
title: {{projectName}} API
|
|
12
|
+
version: "1.0.0"
|
|
13
|
+
description: Every resource uses UUID v7 as its id; list endpoints return the {data, limit, offset} envelope.
|
|
14
|
+
servers:
|
|
15
|
+
- url: http://localhost:8080
|
|
16
|
+
|
|
17
|
+
paths:
|
|
18
|
+
/livez:
|
|
19
|
+
$ref: './health/health-livez.yaml'
|
|
20
|
+
/readyz:
|
|
21
|
+
$ref: './health/health-readyz.yaml'
|
|
22
|
+
# go-scaffold:paths
|
|
23
|
+
|
|
24
|
+
components:
|
|
25
|
+
schemas:
|
|
26
|
+
PageEnvelope: { $ref: './common/schemas.yaml#/PageEnvelope' }
|
|
27
|
+
Error: { $ref: './common/schemas.yaml#/Error' }
|
|
28
|
+
# go-scaffold:schemas
|
|
29
|
+
responses:
|
|
30
|
+
ValidationError: { $ref: './common/responses.yaml#/ValidationError' }
|
|
31
|
+
NotFoundError: { $ref: './common/responses.yaml#/NotFoundError' }
|
|
32
|
+
ConflictError: { $ref: './common/responses.yaml#/ConflictError' }
|
|
33
|
+
UnprocessableEntityError: { $ref: './common/responses.yaml#/UnprocessableEntityError' }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Coding Patterns: {{projectName}}
|
|
2
|
+
|
|
3
|
+
> Status: generated by `@nakedev/go-scaffold`
|
|
4
|
+
> Last updated: scaffold creation time
|
|
5
|
+
|
|
6
|
+
## Domain Package Shape
|
|
7
|
+
|
|
8
|
+
Every domain generated by `go-scaffold generate module <name>` looks
|
|
9
|
+
like this — copy the shape by hand if you ever add one without the CLI:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
internal/app/<domain>/
|
|
13
|
+
├── model/ # domain model(s) + GORM table(s) — a folder, one file per table
|
|
14
|
+
│ └── model.go
|
|
15
|
+
├── dto.go # request/response structs + mapping (never leak the model directly)
|
|
16
|
+
├── errors.go # domain error catalog (<DOMAIN>_NOT_FOUND, ...)
|
|
17
|
+
├── repository.go # the only place that touches the DB for this domain, every method takes ctx
|
|
18
|
+
├── service.go # business logic; declares the repository interface it needs (mockable in tests)
|
|
19
|
+
├── handler.go # HTTP: routing, bind, delegate, respond
|
|
20
|
+
├── service_test.go # unit test, fake repo, no DB
|
|
21
|
+
└── handler_test.go # integration test, real Postgres, transaction rolled back per test
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Model
|
|
25
|
+
|
|
26
|
+
- Lives in its own `model` subpackage (`internal/app/<domain>/model`), imported
|
|
27
|
+
by the rest of the domain as `model.<Type>`
|
|
28
|
+
- A folder, not a single file, so a domain with more than one table (e.g.
|
|
29
|
+
`order` + `order_item`) adds one file per table instead of growing a single
|
|
30
|
+
file — `generate module` only ever creates the first one
|
|
31
|
+
- Every consumer inside `cmd/api/main.go` imports each domain's `model`
|
|
32
|
+
package under an alias (`ordermodel`, `usermodel`, ...) since they all share
|
|
33
|
+
the package name `model`
|
|
34
|
+
|
|
35
|
+
## Layer Conventions
|
|
36
|
+
|
|
37
|
+
### Handler
|
|
38
|
+
- Owns routing, request binding, and calling the service
|
|
39
|
+
- No business logic — `c.Error(err); return` on failure, nothing more
|
|
40
|
+
- Reads pagination via `pagination.Parse(c)`, wraps list responses with
|
|
41
|
+
`p.Response(out)`
|
|
42
|
+
- Reads the `:id` param via `httpx.ParseID(c)`
|
|
43
|
+
|
|
44
|
+
### Service
|
|
45
|
+
- Contains the business logic, knows nothing about HTTP
|
|
46
|
+
- Declares a `repository` interface for what it needs from the data layer —
|
|
47
|
+
this is what makes it mockable in `service_test.go` without a DB
|
|
48
|
+
- Generates the ID itself via `id.New()` before calling `repository.Create`
|
|
49
|
+
- Maps DB errors to domain errors with `dberr.IsDuplicate` /
|
|
50
|
+
`dberr.IsForeignKey`, never lets a raw DB error escape
|
|
51
|
+
|
|
52
|
+
### Repository
|
|
53
|
+
- The only file per domain that talks to GORM
|
|
54
|
+
- Every method takes `ctx context.Context` first, so a cancelled request
|
|
55
|
+
cancels the query
|
|
56
|
+
- Look up by ID with an explicit `"id = ?"` — the PK is a UUID, not an int,
|
|
57
|
+
and GORM can misinterpret a bare struct arg
|
|
58
|
+
|
|
59
|
+
### DTOs
|
|
60
|
+
- `createInput`/`updateInput` (request, `binding:` tags) and `response`
|
|
61
|
+
(what's actually sent back) are separate types from the model — adding a
|
|
62
|
+
DB column later doesn't leak it to clients until you decide to
|
|
63
|
+
|
|
64
|
+
### Error Catalog
|
|
65
|
+
- One function per error, not a shared `var` — the error middleware writes
|
|
66
|
+
the request ID onto the returned pointer, so a shared instance would race
|
|
67
|
+
across concurrent requests
|
|
68
|
+
- Codes are domain-specific (`ORDER_NOT_FOUND`), never the generic
|
|
69
|
+
`apperror.NewNotFound()` directly from a handler
|
|
70
|
+
|
|
71
|
+
## Domains With a Foreign Key (Relations) — 3 Rules
|
|
72
|
+
|
|
73
|
+
The CLI does not scaffold relations between domains; when you add one by
|
|
74
|
+
hand, follow these three rules (see `internal/app/order` for a worked example
|
|
75
|
+
if this project has one):
|
|
76
|
+
|
|
77
|
+
1. **Reference by ID column only** (`UserID uuid.UUID`). No GORM
|
|
78
|
+
associations / belongs-to — that's what keeps one domain package from
|
|
79
|
+
importing another.
|
|
80
|
+
2. **Declare the FK constraint in migration SQL**
|
|
81
|
+
(`REFERENCES ... ON DELETE ...`), not a GORM tag — AutoMigrate doesn't
|
|
82
|
+
create the constraint, which would make dev and prod schemas diverge.
|
|
83
|
+
3. **Map the FK error to the right status** via `dberr.IsForeignKey` —
|
|
84
|
+
inserting a reference to a missing parent, or deleting a parent that
|
|
85
|
+
still has children, is a client error (409/422), not a 500. Don't
|
|
86
|
+
pre-check existence before insert; let the DB enforce it atomically and
|
|
87
|
+
catch the error — a pre-check has a TOCTOU race.
|
|
88
|
+
|
|
89
|
+
## Adding One Endpoint — `generate method`
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
go-scaffold generate method <domain> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Patches `handler.go`/`service.go` (and `repository.go` + the `repository`
|
|
96
|
+
interface + its `fakeRepo` test stub, for a `get --get-mode one --field`
|
|
97
|
+
lookup) in place, at the `// go-scaffold:*` marker comments near the end of
|
|
98
|
+
each file. **Don't delete those markers** — they're where the next
|
|
99
|
+
`generate method` call inserts. The method body is always left as a `TODO`
|
|
100
|
+
that compiles and returns a clean `500` (`apperror.NewInternal()`) rather
|
|
101
|
+
than guessing at business logic — same spirit as `generate module`'s
|
|
102
|
+
placeholder fields.
|
|
103
|
+
|
|
104
|
+
## Testing Conventions
|
|
105
|
+
|
|
106
|
+
- Unit and integration tests live in the same directory as the code under
|
|
107
|
+
test (Go convention) — never a separate `test/` folder. `test/` is only
|
|
108
|
+
for e2e black-box suites or fixtures.
|
|
109
|
+
- `service_test.go` uses a fake repository — no DB required, always runs.
|
|
110
|
+
- `handler_test.go` runs against a real Postgres instance inside a
|
|
111
|
+
transaction that's rolled back after each test — skips automatically if
|
|
112
|
+
the DB isn't reachable.
|
|
113
|
+
|
|
114
|
+
## Docs Maintenance
|
|
115
|
+
|
|
116
|
+
- This document describes the output produced by `go-scaffold create` for
|
|
117
|
+
the current configuration
|
|
118
|
+
- If new domains are generated or the architecture changes, update this doc
|
|
119
|
+
to match the real project
|