@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,49 @@
|
|
|
1
|
+
package {{pkg}}
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
7
|
+
|
|
8
|
+
"github.com/google/uuid"
|
|
9
|
+
"gorm.io/gorm"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Repository = data access for {{pkg}} (the only place that touches the DB for this domain)
|
|
13
|
+
// every method takes ctx, so a cancelled request cancels the query too
|
|
14
|
+
type Repository struct {
|
|
15
|
+
db *gorm.DB
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func NewRepository(db *gorm.DB) *Repository {
|
|
19
|
+
return &Repository{db: db}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func (r *Repository) Create(ctx context.Context, m *model.{{pascalName}}) error {
|
|
23
|
+
return r.db.WithContext(ctx).Create(m).Error
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
27
|
+
var items []model.{{pascalName}}
|
|
28
|
+
err := r.db.WithContext(ctx).Order("id desc").Limit(limit).Offset(offset).Find(&items).Error
|
|
29
|
+
return items, err
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
33
|
+
var m model.{{pascalName}}
|
|
34
|
+
// "id = ?" explicit — PK is a UUID, not an int, this avoids GORM misinterpreting the arg
|
|
35
|
+
if err := r.db.WithContext(ctx).First(&m, "id = ?", id).Error; err != nil {
|
|
36
|
+
return nil, err
|
|
37
|
+
}
|
|
38
|
+
return &m, nil
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (r *Repository) Update(ctx context.Context, m *model.{{pascalName}}) error {
|
|
42
|
+
return r.db.WithContext(ctx).Save(m).Error
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func (r *Repository) Delete(ctx context.Context, id uuid.UUID) error {
|
|
46
|
+
return r.db.WithContext(ctx).Delete(&model.{{pascalName}}{}, "id = ?", id).Error
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// go-scaffold:repository-methods
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package {{pkg}}
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
|
|
7
|
+
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
8
|
+
"{{goModule}}/internal/shared/apperror"
|
|
9
|
+
"{{goModule}}/internal/shared/dberr"
|
|
10
|
+
"{{goModule}}/internal/shared/id"
|
|
11
|
+
|
|
12
|
+
"github.com/google/uuid"
|
|
13
|
+
"gorm.io/gorm"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// repository = what the service needs from the data layer (declared on the consumer side, so it can be mocked in tests)
|
|
17
|
+
type repository interface {
|
|
18
|
+
Create(ctx context.Context, m *model.{{pascalName}}) error
|
|
19
|
+
FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error)
|
|
20
|
+
FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error)
|
|
21
|
+
Update(ctx context.Context, m *model.{{pascalName}}) error
|
|
22
|
+
Delete(ctx context.Context, id uuid.UUID) error
|
|
23
|
+
// go-scaffold:repository-interface
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Service = business logic for {{pkg}} (rules live here, it knows nothing about HTTP)
|
|
27
|
+
// input shape validation happens in the handler (binding tags)
|
|
28
|
+
type Service struct {
|
|
29
|
+
repo repository
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func NewService(repo repository) *Service {
|
|
33
|
+
return &Service{repo: repo}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func (s *Service) Create(ctx context.Context) (*model.{{pascalName}}, error) {
|
|
37
|
+
// TODO: accept and set real fields from createInput
|
|
38
|
+
m := &model.{{pascalName}}{ID: id.New()}
|
|
39
|
+
if err := s.repo.Create(ctx, m); err != nil {
|
|
40
|
+
if dberr.IsDuplicate(err) {
|
|
41
|
+
return nil, errConflict()
|
|
42
|
+
}
|
|
43
|
+
return nil, apperror.NewInternal()
|
|
44
|
+
}
|
|
45
|
+
return m, nil
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (s *Service) List(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
49
|
+
items, err := s.repo.FindAll(ctx, limit, offset)
|
|
50
|
+
if err != nil {
|
|
51
|
+
return nil, apperror.NewInternal()
|
|
52
|
+
}
|
|
53
|
+
return items, nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func (s *Service) Get(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
57
|
+
m, err := s.repo.FindByID(ctx, id)
|
|
58
|
+
if err != nil {
|
|
59
|
+
return nil, wrapFindErr(err)
|
|
60
|
+
}
|
|
61
|
+
return m, nil
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func (s *Service) Update(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
65
|
+
m, err := s.repo.FindByID(ctx, id)
|
|
66
|
+
if err != nil {
|
|
67
|
+
return nil, wrapFindErr(err)
|
|
68
|
+
}
|
|
69
|
+
// TODO: apply real fields from updateInput before saving
|
|
70
|
+
if err := s.repo.Update(ctx, m); err != nil {
|
|
71
|
+
if dberr.IsDuplicate(err) {
|
|
72
|
+
return nil, errConflict()
|
|
73
|
+
}
|
|
74
|
+
return nil, apperror.NewInternal()
|
|
75
|
+
}
|
|
76
|
+
return m, nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
|
80
|
+
if err := s.repo.Delete(ctx, id); err != nil {
|
|
81
|
+
// deleting a {{pkg}} that's still referenced elsewhere → FK RESTRICT trips = 409, not 500
|
|
82
|
+
if dberr.IsForeignKey(err) {
|
|
83
|
+
return errHasReferences()
|
|
84
|
+
}
|
|
85
|
+
return apperror.NewInternal()
|
|
86
|
+
}
|
|
87
|
+
return nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func wrapFindErr(err error) error {
|
|
91
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
92
|
+
return errNotFound()
|
|
93
|
+
}
|
|
94
|
+
return apperror.NewInternal()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// go-scaffold:service-methods
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
"gorm.io/gorm"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// fakeRepo = mock of the repository interface, so the service can be tested without a DB
|
|
16
|
+
type fakeRepo struct {
|
|
17
|
+
err error
|
|
18
|
+
m *model.{{pascalName}}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func (f *fakeRepo) Create(context.Context, *model.{{pascalName}}) error { return f.err }
|
|
22
|
+
func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.{{pascalName}}, error) { return nil, f.err }
|
|
23
|
+
func (f *fakeRepo) FindByID(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
|
|
24
|
+
if f.err != nil {
|
|
25
|
+
return nil, f.err
|
|
26
|
+
}
|
|
27
|
+
return f.m, nil
|
|
28
|
+
}
|
|
29
|
+
func (f *fakeRepo) Update(context.Context, *model.{{pascalName}}) error { return f.err }
|
|
30
|
+
func (f *fakeRepo) Delete(context.Context, uuid.UUID) error { return f.err }
|
|
31
|
+
|
|
32
|
+
// go-scaffold:fake-repo-methods
|
|
33
|
+
|
|
34
|
+
func status(t *testing.T, err error) int {
|
|
35
|
+
t.Helper()
|
|
36
|
+
var appErr *apperror.AppError
|
|
37
|
+
if !errors.As(err, &appErr) {
|
|
38
|
+
t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
|
|
39
|
+
}
|
|
40
|
+
return appErr.HTTPStatus
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func TestService_Create_Duplicate(t *testing.T) {
|
|
44
|
+
svc := NewService(&fakeRepo{err: gorm.ErrDuplicatedKey})
|
|
45
|
+
_, err := svc.Create(context.Background())
|
|
46
|
+
if got := status(t, err); got != 409 {
|
|
47
|
+
t.Fatalf("want 409 conflict, got %d", got)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func TestService_Get_NotFound(t *testing.T) {
|
|
52
|
+
svc := NewService(&fakeRepo{err: gorm.ErrRecordNotFound})
|
|
53
|
+
_, err := svc.Get(context.Background(), uuid.New())
|
|
54
|
+
if got := status(t, err); got != 404 {
|
|
55
|
+
t.Fatalf("want 404, got %d", got)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func TestService_List_DBError_Becomes500(t *testing.T) {
|
|
60
|
+
svc := NewService(&fakeRepo{err: errors.New("connection refused")})
|
|
61
|
+
_, err := svc.List(context.Background(), 20, 0)
|
|
62
|
+
if got := status(t, err); got != 500 {
|
|
63
|
+
t.Fatalf("want 500, got %d", got)
|
|
64
|
+
}
|
|
65
|
+
}
|