@nakedev/go-scaffold 0.1.2 → 0.1.3
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 +75 -0
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +59 -1
- package/dist/commands/method.js +3 -0
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +19 -2
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +66 -3
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/openapi-patcher.js +16 -0
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/version.js +24 -0
- package/package.json +2 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/Makefile.hbs +30 -5
- package/templates/create/base/README.md.hbs +36 -8
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +13 -0
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/handler.go.hbs +20 -3
- package/templates/generate/module/handler_test.go.hbs +49 -6
- package/templates/generate/module/minimal/handler.go.hbs +21 -3
- package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- package/dist/utils/module-paths.js +0 -33
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"sync/atomic"
|
|
8
|
+
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"github.com/gin-gonic/gin"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestAuthz_CachesWithinTTL(t *testing.T) {
|
|
15
|
+
var calls int32
|
|
16
|
+
a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
|
|
17
|
+
atomic.AddInt32(&calls, 1)
|
|
18
|
+
return map[string]struct{}{"x": {}}, nil
|
|
19
|
+
}, time.Minute)
|
|
20
|
+
|
|
21
|
+
if _, err := a.permsOf(context.Background(), "staff"); err != nil {
|
|
22
|
+
t.Fatalf("unexpected error: %v", err)
|
|
23
|
+
}
|
|
24
|
+
if _, err := a.permsOf(context.Background(), "staff"); err != nil {
|
|
25
|
+
t.Fatalf("unexpected error: %v", err)
|
|
26
|
+
}
|
|
27
|
+
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
28
|
+
t.Fatalf("want resolve called once for two calls within TTL (cache hit), got %d", got)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func TestAuthz_ReResolvesAfterTTLExpires(t *testing.T) {
|
|
33
|
+
var calls int32
|
|
34
|
+
a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
|
|
35
|
+
atomic.AddInt32(&calls, 1)
|
|
36
|
+
return map[string]struct{}{"x": {}}, nil
|
|
37
|
+
}, 10*time.Millisecond)
|
|
38
|
+
|
|
39
|
+
if _, err := a.permsOf(context.Background(), "staff"); err != nil {
|
|
40
|
+
t.Fatalf("unexpected error: %v", err)
|
|
41
|
+
}
|
|
42
|
+
time.Sleep(20 * time.Millisecond)
|
|
43
|
+
if _, err := a.permsOf(context.Background(), "staff"); err != nil {
|
|
44
|
+
t.Fatalf("unexpected error: %v", err)
|
|
45
|
+
}
|
|
46
|
+
if got := atomic.LoadInt32(&calls); got != 2 {
|
|
47
|
+
t.Fatalf("want resolve called again once the TTL expires, got %d calls", got)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func TestAuthz_Require_ForbidsMissingPermission(t *testing.T) {
|
|
52
|
+
gin.SetMode(gin.TestMode)
|
|
53
|
+
a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
|
|
54
|
+
return map[string]struct{}{"other:perm": {}}, nil
|
|
55
|
+
}, time.Minute)
|
|
56
|
+
|
|
57
|
+
r := gin.New()
|
|
58
|
+
// Require only records a *apperror.AppError via c.Error — Error(true) is
|
|
59
|
+
// what actually translates that into the response status code, same as
|
|
60
|
+
// the real router (see cmd/api/main.go).
|
|
61
|
+
r.Use(Error(true))
|
|
62
|
+
r.GET("/x", func(c *gin.Context) { c.Set(RoleKey, "staff") }, a.Require("role:manage"), func(c *gin.Context) {
|
|
63
|
+
c.Status(http.StatusOK)
|
|
64
|
+
})
|
|
65
|
+
w := httptest.NewRecorder()
|
|
66
|
+
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
|
|
67
|
+
if w.Code != http.StatusForbidden {
|
|
68
|
+
t.Fatalf("want 403 without the required permission, got %d", w.Code)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func TestAuthz_Require_AllowsGrantedPermission(t *testing.T) {
|
|
73
|
+
gin.SetMode(gin.TestMode)
|
|
74
|
+
a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
|
|
75
|
+
return map[string]struct{}{"role:manage": {}}, nil
|
|
76
|
+
}, time.Minute)
|
|
77
|
+
|
|
78
|
+
r := gin.New()
|
|
79
|
+
r.Use(Error(true))
|
|
80
|
+
r.GET("/x", func(c *gin.Context) { c.Set(RoleKey, "admin") }, a.Require("role:manage"), func(c *gin.Context) {
|
|
81
|
+
c.Status(http.StatusOK)
|
|
82
|
+
})
|
|
83
|
+
w := httptest.NewRecorder()
|
|
84
|
+
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
|
|
85
|
+
if w.Code != http.StatusOK {
|
|
86
|
+
t.Fatalf("want 200 with the required permission granted, got %d", w.Code)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- if an admin created a custom role after this migration and it's still
|
|
2
|
+
-- assigned to any user, dropping the roles table would orphan that value —
|
|
3
|
+
-- raise a clear, actionable error instead of a raw FK-violation.
|
|
4
|
+
DO $$
|
|
5
|
+
BEGIN
|
|
6
|
+
IF EXISTS (SELECT 1 FROM users WHERE role NOT IN ('staff', 'admin')) THEN
|
|
7
|
+
RAISE EXCEPTION 'cannot roll back add_roles: users still have a role other than staff/admin — reassign them to staff/admin first';
|
|
8
|
+
END IF;
|
|
9
|
+
END $$;
|
|
10
|
+
|
|
11
|
+
ALTER TABLE users DROP COLUMN role;
|
|
12
|
+
|
|
13
|
+
DROP TABLE role_permissions;
|
|
14
|
+
DROP TABLE permissions;
|
|
15
|
+
DROP TABLE roles;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
CREATE TABLE roles (
|
|
2
|
+
code VARCHAR(20) PRIMARY KEY,
|
|
3
|
+
name TEXT NOT NULL,
|
|
4
|
+
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
|
5
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
6
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE permissions (
|
|
10
|
+
code VARCHAR(50) PRIMARY KEY,
|
|
11
|
+
description TEXT NOT NULL
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE TABLE role_permissions (
|
|
15
|
+
role_code VARCHAR(20) NOT NULL REFERENCES roles(code) ON DELETE CASCADE,
|
|
16
|
+
permission_code VARCHAR(50) NOT NULL REFERENCES permissions(code) ON DELETE CASCADE,
|
|
17
|
+
PRIMARY KEY (role_code, permission_code)
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
INSERT INTO roles (code, name, is_system) VALUES
|
|
21
|
+
('staff', 'Staff', TRUE),
|
|
22
|
+
('admin', 'Admin', TRUE);
|
|
23
|
+
|
|
24
|
+
INSERT INTO permissions (code, description) VALUES
|
|
25
|
+
('role:manage', 'Create roles and change their permissions'),
|
|
26
|
+
('user:manage-role', 'Change a user''s assigned role'),
|
|
27
|
+
('user:read', 'List and view other users');
|
|
28
|
+
|
|
29
|
+
-- admin gets every permission that exists, staff gets none — no behavior
|
|
30
|
+
-- change for existing users until an admin explicitly grants something.
|
|
31
|
+
INSERT INTO role_permissions (role_code, permission_code)
|
|
32
|
+
SELECT 'admin', code FROM permissions;
|
|
33
|
+
|
|
34
|
+
ALTER TABLE users
|
|
35
|
+
ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'staff' REFERENCES roles(code);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"log/slog"
|
|
7
|
+
"os"
|
|
8
|
+
"os/signal"
|
|
9
|
+
"syscall"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"{{goModule}}/internal/platform/mail"
|
|
13
|
+
"{{goModule}}/internal/platform/queue"
|
|
14
|
+
"{{goModule}}/internal/shared/config"
|
|
15
|
+
// go-scaffold:imports
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func main() {
|
|
19
|
+
cfg := config.Load()
|
|
20
|
+
|
|
21
|
+
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
|
|
22
|
+
slog.SetDefault(logger)
|
|
23
|
+
|
|
24
|
+
srv, err := queue.NewServer(cfg.RedisURL)
|
|
25
|
+
if err != nil {
|
|
26
|
+
logger.Error("new queue server", "error", err)
|
|
27
|
+
os.Exit(1)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
srv.Handle(mail.TypeSendEmail, mail.HandleSendEmail(mail.Open(cfg)))
|
|
31
|
+
// go-scaffold:queue-handlers
|
|
32
|
+
|
|
33
|
+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
34
|
+
defer stop()
|
|
35
|
+
|
|
36
|
+
go func() {
|
|
37
|
+
logger.Info("worker started")
|
|
38
|
+
if err := srv.Start(); err != nil && !errors.Is(err, context.Canceled) {
|
|
39
|
+
logger.Error("worker server", "error", err)
|
|
40
|
+
os.Exit(1)
|
|
41
|
+
}
|
|
42
|
+
}()
|
|
43
|
+
|
|
44
|
+
<-ctx.Done()
|
|
45
|
+
logger.Info("shutting down worker")
|
|
46
|
+
|
|
47
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
48
|
+
defer cancel()
|
|
49
|
+
// asynq's Shutdown() blocks until in-flight tasks finish (or the process
|
|
50
|
+
// is killed) with no context/timeout parameter of its own — run it in a
|
|
51
|
+
// goroutine and race it against shutdownCtx so a stuck task can't hang
|
|
52
|
+
// the process forever.
|
|
53
|
+
done := make(chan struct{}, 1)
|
|
54
|
+
go func() {
|
|
55
|
+
srv.Shutdown()
|
|
56
|
+
close(done)
|
|
57
|
+
}()
|
|
58
|
+
select {
|
|
59
|
+
case <-done:
|
|
60
|
+
case <-shutdownCtx.Done():
|
|
61
|
+
logger.Warn("worker shutdown timed out")
|
|
62
|
+
}
|
|
63
|
+
logger.Info("worker stopped")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func parseLevel(s string) slog.Level {
|
|
67
|
+
switch s {
|
|
68
|
+
case "debug":
|
|
69
|
+
return slog.LevelDebug
|
|
70
|
+
case "warn":
|
|
71
|
+
return slog.LevelWarn
|
|
72
|
+
case "error":
|
|
73
|
+
return slog.LevelError
|
|
74
|
+
default:
|
|
75
|
+
return slog.LevelInfo
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
package cache
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"{{goModule}}/internal/shared/config"
|
|
5
|
+
|
|
6
|
+
"github.com/redis/go-redis/v9"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Open connects to Redis — it talks to a real external system, so it lives
|
|
10
|
+
// in platform/, not shared/. Used for ephemeral, TTL-bound data and as the
|
|
11
|
+
// backing store for the async task queue (platform/queue).
|
|
12
|
+
func Open(cfg config.Config) (*redis.Client, error) {
|
|
13
|
+
opts, err := redis.ParseURL(cfg.RedisURL)
|
|
14
|
+
if err != nil {
|
|
15
|
+
return nil, err
|
|
16
|
+
}
|
|
17
|
+
return redis.NewClient(opts), nil
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package mail
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"log/slog"
|
|
6
|
+
"net/smtp"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/shared/config"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Client sends plain-text email over SMTP (net/smtp already handles STARTTLS
|
|
12
|
+
// when the server offers it, which covers Gmail/SendGrid/Mailgun/SES/Resend
|
|
13
|
+
// SMTP relays on port 587) — talks to a real external system, so it lives in
|
|
14
|
+
// platform/, not shared/.
|
|
15
|
+
type Client struct {
|
|
16
|
+
host, port, username, password, from string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func Open(cfg config.Config) *Client {
|
|
20
|
+
return &Client{
|
|
21
|
+
host: cfg.SMTPHost,
|
|
22
|
+
port: cfg.SMTPPort,
|
|
23
|
+
username: cfg.SMTPUsername,
|
|
24
|
+
password: cfg.SMTPPassword,
|
|
25
|
+
from: cfg.SMTPFrom,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Send delivers a plain-text email. If SMTP isn't configured (dev default —
|
|
30
|
+
// SMTP_HOST unset), it logs the message instead of failing, so flows that
|
|
31
|
+
// send email stay testable without a real mail server.
|
|
32
|
+
func (c *Client) Send(to, subject, body string) error {
|
|
33
|
+
if c.host == "" {
|
|
34
|
+
// ponytail: no SMTP configured — log instead of failing. Upgrade path:
|
|
35
|
+
// set SMTP_HOST (+ USERNAME/PASSWORD/FROM) once a real environment needs it.
|
|
36
|
+
slog.Info("email not sent (SMTP not configured)", "to", to, "subject", subject, "body", body)
|
|
37
|
+
return nil
|
|
38
|
+
}
|
|
39
|
+
msg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\n%s\r\n", to, c.from, subject, body)
|
|
40
|
+
// nil Auth when no username is set — a relay/local catcher that doesn't
|
|
41
|
+
// require auth (or doesn't advertise AUTH at all) rejects a non-nil Auth
|
|
42
|
+
// outright, even with empty credentials.
|
|
43
|
+
var auth smtp.Auth
|
|
44
|
+
if c.username != "" {
|
|
45
|
+
auth = smtp.PlainAuth("", c.username, c.password, c.host)
|
|
46
|
+
}
|
|
47
|
+
return smtp.SendMail(c.host+":"+c.port, auth, c.from, []string{to}, []byte(msg))
|
|
48
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package mail
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
|
|
7
|
+
"github.com/hibiken/asynq"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
const TypeSendEmail = "email:send"
|
|
11
|
+
|
|
12
|
+
type sendEmailPayload struct {
|
|
13
|
+
To string
|
|
14
|
+
Subject string
|
|
15
|
+
Body string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func NewSendEmailTask(p sendEmailPayload) *asynq.Task {
|
|
19
|
+
payload, _ := json.Marshal(p)
|
|
20
|
+
return asynq.NewTask(TypeSendEmail, payload)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func HandleSendEmail(client *Client) asynq.Handler {
|
|
24
|
+
return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
|
|
25
|
+
var p sendEmailPayload
|
|
26
|
+
if err := json.Unmarshal(task.Payload(), &p); err != nil {
|
|
27
|
+
return err
|
|
28
|
+
}
|
|
29
|
+
return client.Send(p.To, p.Subject, p.Body)
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type AsyncClient struct {
|
|
34
|
+
q interface {
|
|
35
|
+
Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func NewAsyncClient(q interface {
|
|
40
|
+
Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error)
|
|
41
|
+
}) *AsyncClient {
|
|
42
|
+
return &AsyncClient{q: q}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Send enqueues the email instead of sending it inline, so the caller (an
|
|
46
|
+
// HTTP handler) returns in ~µs instead of blocking on SMTP. cmd/worker picks
|
|
47
|
+
// it up and calls Client.Send.
|
|
48
|
+
func (c *AsyncClient) Send(to, subject, body string) error {
|
|
49
|
+
task := NewSendEmailTask(sendEmailPayload{To: to, Subject: subject, Body: body})
|
|
50
|
+
_, err := c.q.Enqueue(task, asynq.MaxRetry(5))
|
|
51
|
+
return err
|
|
52
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package queue
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/hibiken/asynq"
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
// Client enqueues tasks to Redis. Created by cmd/api; each domain or platform
|
|
8
|
+
// package defines its own task types and calls Client.Enqueue.
|
|
9
|
+
type Client struct {
|
|
10
|
+
inner *asynq.Client
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
func NewClient(redisURL string) (*Client, error) {
|
|
14
|
+
opt, err := asynq.ParseRedisURI(redisURL)
|
|
15
|
+
if err != nil {
|
|
16
|
+
return nil, err
|
|
17
|
+
}
|
|
18
|
+
return &Client{inner: asynq.NewClient(opt)}, nil
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func (c *Client) Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error) {
|
|
22
|
+
info, err := c.inner.Enqueue(task, opts...)
|
|
23
|
+
if err != nil {
|
|
24
|
+
return "", err
|
|
25
|
+
}
|
|
26
|
+
return info.ID, nil
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func (c *Client) Close() error {
|
|
30
|
+
return c.inner.Close()
|
|
31
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package queue
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"log/slog"
|
|
7
|
+
|
|
8
|
+
"github.com/hibiken/asynq"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Server runs the background worker. Created by cmd/worker; handlers register
|
|
12
|
+
// their task types on the mux before Start is called.
|
|
13
|
+
type Server struct {
|
|
14
|
+
inner *asynq.Server
|
|
15
|
+
mux *asynq.ServeMux
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// slogAdapter wraps *slog.Logger to implement asynq.Logger. Asynq calls these
|
|
19
|
+
// like the stdlib `log` package (a plain message, sometimes several args
|
|
20
|
+
// meant to be concatenated) — fmt.Sprint joins them into one message string
|
|
21
|
+
// rather than passing them as slog key-value pairs, which would otherwise
|
|
22
|
+
// misinterpret a lone message argument as a key with no value.
|
|
23
|
+
type slogAdapter struct {
|
|
24
|
+
inner *slog.Logger
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (a *slogAdapter) Debug(args ...interface{}) { a.inner.Debug(fmt.Sprint(args...)) }
|
|
28
|
+
func (a *slogAdapter) Info(args ...interface{}) { a.inner.Info(fmt.Sprint(args...)) }
|
|
29
|
+
func (a *slogAdapter) Warn(args ...interface{}) { a.inner.Warn(fmt.Sprint(args...)) }
|
|
30
|
+
func (a *slogAdapter) Error(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
|
|
31
|
+
func (a *slogAdapter) Fatal(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
|
|
32
|
+
|
|
33
|
+
func NewServer(redisURL string) (*Server, error) {
|
|
34
|
+
opt, err := asynq.ParseRedisURI(redisURL)
|
|
35
|
+
if err != nil {
|
|
36
|
+
return nil, err
|
|
37
|
+
}
|
|
38
|
+
srv := &Server{
|
|
39
|
+
inner: asynq.NewServer(opt, asynq.Config{
|
|
40
|
+
Concurrency: 10,
|
|
41
|
+
Logger: &slogAdapter{inner: slog.Default()},
|
|
42
|
+
}),
|
|
43
|
+
mux: asynq.NewServeMux(),
|
|
44
|
+
}
|
|
45
|
+
// Global error handler for tasks that panic or return a non-Retryable error.
|
|
46
|
+
srv.mux.Use(func(next asynq.Handler) asynq.Handler {
|
|
47
|
+
return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
|
|
48
|
+
err := next.ProcessTask(ctx, task)
|
|
49
|
+
if err != nil {
|
|
50
|
+
slog.Error("task failed", "type", task.Type(), "error", err)
|
|
51
|
+
}
|
|
52
|
+
return err
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
return srv, nil
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func (s *Server) Handle(pattern string, handler asynq.Handler) {
|
|
59
|
+
s.mux.Handle(pattern, handler)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func (s *Server) Start() error {
|
|
63
|
+
return s.inner.Start(s.mux)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (s *Server) Shutdown() {
|
|
67
|
+
s.inner.Shutdown()
|
|
68
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
# development | production — the one gate for prod behavior (config.IsProd):
|
|
2
|
+
# hides error `details` from responses. Any other value makes the app refuse
|
|
3
|
+
# to boot (so a typo can't silently downgrade prod).
|
|
4
|
+
APP_ENV=development # prod: production
|
|
5
|
+
|
|
1
6
|
PORT=8080
|
|
2
7
|
DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable
|
|
3
8
|
LOG_LEVEL=info
|
|
@@ -5,3 +10,18 @@ AUTO_MIGRATE=true
|
|
|
5
10
|
DB_MAX_OPEN_CONNS=10
|
|
6
11
|
DB_MAX_IDLE_CONNS=10
|
|
7
12
|
DB_CONN_MAX_LIFETIME_MIN=5
|
|
13
|
+
|
|
14
|
+
# read only by `go test`, never by cmd/api — a separate database on purpose: the
|
|
15
|
+
# integration-test harness does DropTable+AutoMigrate on every run, which would
|
|
16
|
+
# otherwise wipe the schema `make migrate-up` built in DB_DSN above.
|
|
17
|
+
# Create it once: make db-create DB_NAME={{dbName}}_test
|
|
18
|
+
TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
|
|
19
|
+
|
|
20
|
+
# comma-separated frontend origins allowed to call this API with credentials (cookies)
|
|
21
|
+
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
|
22
|
+
{{#if observability}}
|
|
23
|
+
|
|
24
|
+
# OTLP/HTTP endpoint for trace export (e.g. localhost:4318) — empty disables
|
|
25
|
+
# tracing entirely: no exporter is created, no network calls are made
|
|
26
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=
|
|
27
|
+
{{/if}}
|
|
@@ -14,7 +14,9 @@ jobs:
|
|
|
14
14
|
env:
|
|
15
15
|
POSTGRES_USER: postgres
|
|
16
16
|
POSTGRES_PASSWORD: postgres
|
|
17
|
-
|
|
17
|
+
# the test database, not the app's — CI only runs tests, and the
|
|
18
|
+
# integration harness drops/recreates its tables on every run
|
|
19
|
+
POSTGRES_DB: {{dbName}}_test
|
|
18
20
|
ports:
|
|
19
21
|
- 5432:5432
|
|
20
22
|
options: >-
|
|
@@ -42,5 +44,5 @@ jobs:
|
|
|
42
44
|
version: latest
|
|
43
45
|
|
|
44
46
|
# TEST_DB_DSN not set: the default in handler_test.go already points at
|
|
45
|
-
# localhost:5432/{{dbName}} with postgres/postgres, matching the service above
|
|
47
|
+
# localhost:5432/{{dbName}}_test with postgres/postgres, matching the service above
|
|
46
48
|
- run: go test ./...
|
|
@@ -4,16 +4,32 @@ DB_USER ?= postgres
|
|
|
4
4
|
DB_NAME ?= {{dbName}}
|
|
5
5
|
PGPASSWORD ?= postgres
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
# which env file to load — defaults to .env (your local override, gitignored).
|
|
8
|
+
# override to run against another file, e.g. copy .env.example to .env.production,
|
|
9
|
+
# fill it in, then `make run ENV_FILE=.env.production`.
|
|
10
|
+
#
|
|
11
|
+
# Every target that needs config loads it the same way: drop whole-line comments
|
|
12
|
+
# (^#) AND trailing ` # ...` comments — the sed only strips a `#` preceded by
|
|
13
|
+
# whitespace, so a `#` inside a value (password, DSN) is kept. Without the sed,
|
|
14
|
+
# `xargs` hands the comment's words to `export` too: `PORT=8080 # prod: PORT=80`
|
|
15
|
+
# would export PORT twice and the comment's value would win. (Can't factor this
|
|
16
|
+
# into a make variable: a `#` in a variable value starts a make comment; in a
|
|
17
|
+
# recipe line it's passed to the shell untouched.)
|
|
18
|
+
ENV_FILE ?= .env
|
|
19
|
+
|
|
20
|
+
.PHONY: run build test fmt vet lint tidy db-create db-drop migrate-up migrate-down migrate-verify{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
|
|
8
21
|
|
|
9
22
|
run:
|
|
10
|
-
@[ -f
|
|
23
|
+
@[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); go run ./cmd/api
|
|
11
24
|
|
|
12
25
|
build:
|
|
13
26
|
go build -o bin/api ./cmd/api
|
|
14
27
|
|
|
28
|
+
# loads ENV_FILE like every other target, so TEST_DB_DSN set there reaches the
|
|
29
|
+
# integration tests. A bare `go test ./...` still works, but falls back to
|
|
30
|
+
# whatever defaults the test files carry.
|
|
15
31
|
test:
|
|
16
|
-
go test ./...
|
|
32
|
+
@[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); go test ./...
|
|
17
33
|
|
|
18
34
|
fmt:
|
|
19
35
|
gofmt -w .
|
|
@@ -69,10 +85,19 @@ db-drop:
|
|
|
69
85
|
fi
|
|
70
86
|
|
|
71
87
|
migrate-up:
|
|
72
|
-
migrate -path migrations -database "$$DB_DSN" up
|
|
88
|
+
@[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); migrate -path migrations -database "$$DB_DSN" up
|
|
73
89
|
|
|
74
90
|
migrate-down:
|
|
75
|
-
migrate -path migrations -database "$$DB_DSN" down 1
|
|
91
|
+
@[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); migrate -path migrations -database "$$DB_DSN" down 1
|
|
92
|
+
|
|
93
|
+
# runs up -> down-to-zero -> up against $DB_DSN to catch a bit-rotted down.sql
|
|
94
|
+
# (one that no longer reverses cleanly) before you actually need a rollback.
|
|
95
|
+
# point DB_DSN at a throwaway/test database first — this drops every table.
|
|
96
|
+
migrate-verify:
|
|
97
|
+
@[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); \
|
|
98
|
+
migrate -path migrations -database "$$DB_DSN" up && \
|
|
99
|
+
migrate -path migrations -database "$$DB_DSN" down -all && \
|
|
100
|
+
migrate -path migrations -database "$$DB_DSN" up
|
|
76
101
|
{{#if openapiDocs}}
|
|
77
102
|
|
|
78
103
|
# docs/openapi.yaml is hand-written and split across sibling files via relative
|
|
@@ -40,9 +40,13 @@ go mod tidy
|
|
|
40
40
|
make run # AUTO_MIGRATE=true creates the schema automatically in dev
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
`make run
|
|
44
|
-
|
|
45
|
-
runtime).
|
|
43
|
+
`make run`/`make test`/`make migrate-up`/`make migrate-down` load `.env` if
|
|
44
|
+
present (copy `.env.example` to `.env` to override defaults — the app itself
|
|
45
|
+
just reads `os.Getenv`, no `.env` parsing at runtime). Point them at another
|
|
46
|
+
file with `ENV_FILE`: copy `.env.example` to `.env.production`, fill it in,
|
|
47
|
+
then `make run ENV_FILE=.env.production`. Trailing `# ...` comments in the env
|
|
48
|
+
file are stripped before loading, so a `# prod: ...` note next to a value is
|
|
49
|
+
safe; a `#` inside a value (password, DSN) is kept.
|
|
46
50
|
|
|
47
51
|
`make db-create` connects to Postgres at `DB_HOST`/`DB_PORT`/`DB_USER` (default:
|
|
48
52
|
`localhost`/`5432`/`postgres`, matching `.env.example`) using the `psql`
|
|
@@ -68,15 +72,20 @@ make lint # golangci-lint run (see .golangci.yml)
|
|
|
68
72
|
make tidy # go mod tidy
|
|
69
73
|
make db-create # create the database itself (safe to re-run)
|
|
70
74
|
make db-drop # drop the database
|
|
71
|
-
make migrate-up # apply migrations (
|
|
75
|
+
make migrate-up # apply migrations (reads DB_DSN from ENV_FILE)
|
|
72
76
|
make migrate-down # roll back one migration
|
|
77
|
+
make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql (point DB_DSN at a throwaway DB)
|
|
73
78
|
{{#if docker}}make docker-up # docker compose up -d
|
|
74
79
|
make docker-down # docker compose down
|
|
75
80
|
{{/if}}```
|
|
76
81
|
|
|
77
82
|
## Migrations
|
|
78
83
|
|
|
79
|
-
Schema is managed with [golang-migrate](https://github.com/golang-migrate/migrate), files live in `migrations
|
|
84
|
+
Schema is managed with [golang-migrate](https://github.com/golang-migrate/migrate), files live in `migrations/`, named `<version>_<name>.{up,down}.sql`. `version` is a 14-digit UTC timestamp, not an incrementing counter — two people adding a migration off the same base branch get different filenames instead of both claiming the same number and colliding on merge. (`generate module` names its own migration the same way; existing sequential `0000NN_*` files from before this convention sort fine alongside timestamped ones either way.)
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
go-scaffold generate migration add_status_to_orders # reserves migrations/<version>_add_status_to_orders.{up,down}.sql, TODO-stubbed — you write the SQL
|
|
88
|
+
```
|
|
80
89
|
|
|
81
90
|
```bash
|
|
82
91
|
brew install golang-migrate
|
|
@@ -84,16 +93,31 @@ brew install golang-migrate
|
|
|
84
93
|
|
|
85
94
|
migrate -path migrations -database "$DB_DSN" up
|
|
86
95
|
migrate -path migrations -database "$DB_DSN" down 1
|
|
87
|
-
migrate create -ext sql -dir migrations -seq add_something
|
|
96
|
+
migrate create -ext sql -dir migrations -seq=false add_something # same as `go-scaffold generate migration`, if you'd rather not use the CLI
|
|
88
97
|
```
|
|
89
98
|
|
|
90
99
|
**dev:** leave `AUTO_MIGRATE=true` (default) so GORM's AutoMigrate creates the schema quickly.
|
|
91
100
|
**prod:** set `AUTO_MIGRATE=false` and run `migrate up` as a separate deploy step — versioned, has rollback (`down`), doesn't lock the table the way AutoMigrate does once there's real data.
|
|
92
101
|
|
|
102
|
+
With `AUTO_MIGRATE=false`, the app checks the DB's applied migration version
|
|
103
|
+
against the migration files baked into the binary (embedded at build time)
|
|
104
|
+
before it starts serving traffic — a stale or half-applied schema fails fast
|
|
105
|
+
at boot with a clear message, instead of failing later on whatever query
|
|
106
|
+
happens to hit the missing column first:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
DB schema is at migration 3, this binary expects 5 — run `make migrate-up`
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql before you actually need a rollback (point DB_DSN at a throwaway DB first — this drops every table)
|
|
114
|
+
```
|
|
115
|
+
|
|
93
116
|
## Env vars
|
|
94
117
|
|
|
95
118
|
| var | default | notes |
|
|
96
119
|
|---|---|---|
|
|
120
|
+
| `APP_ENV` | `development` | `development` or `production` — **the prod gate**: hides error `details` from responses. App refuses to boot on any other value |
|
|
97
121
|
| `PORT` | `8080` | |
|
|
98
122
|
| `DB_DSN` | `postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable` | used by both GORM and the `migrate` CLI |
|
|
99
123
|
| `LOG_LEVEL` | `info` | debug/info/warn/error |
|
|
@@ -101,17 +125,21 @@ migrate create -ext sql -dir migrations -seq add_something
|
|
|
101
125
|
| `DB_MAX_OPEN_CONNS` | `10` | |
|
|
102
126
|
| `DB_MAX_IDLE_CONNS` | `10` | |
|
|
103
127
|
| `DB_CONN_MAX_LIFETIME_MIN` | `5` | minutes |
|
|
128
|
+
| `CORS_ALLOWED_ORIGINS` | `http://localhost:3000` | comma-separated frontend origins allowed to call this API with credentials (cookies) |
|
|
104
129
|
|
|
105
130
|
## Tests
|
|
106
131
|
|
|
107
132
|
Integration tests (handler-level) run against a **real Postgres** instance (same engine as prod), each test in a transaction that's rolled back — no leftover rows. If the DB isn't reachable those tests **skip** (unit tests using a fake repo always run).
|
|
108
133
|
|
|
134
|
+
They use a **separate `{{dbName}}_test` database**, not the one `DB_DSN` points at. That's deliberate: the harness runs `DropTable` + `AutoMigrate` on every real run, so pointing it at your dev database would wipe whatever `make migrate-up` built there — FK constraints and seed data included. Create it once:
|
|
135
|
+
|
|
109
136
|
```bash
|
|
110
137
|
{{#if docker}}
|
|
111
138
|
make docker-up # local postgres first
|
|
112
139
|
{{/if}}
|
|
140
|
+
make db-create DB_NAME={{dbName}}_test
|
|
113
141
|
make test
|
|
114
|
-
# point
|
|
142
|
+
# point somewhere else entirely: TEST_DB_DSN=postgres://... go test ./...
|
|
115
143
|
```
|
|
116
144
|
|
|
117
145
|
## Error payload
|
|
@@ -120,7 +148,7 @@ make test
|
|
|
120
148
|
{"error":{"code":"VALIDATION_ERROR","message":"invalid input","details":{"email":"email"},"request_id":"a1b2..."}}
|
|
121
149
|
```
|
|
122
150
|
|
|
123
|
-
`code` is machine-readable, `details` names the field that failed, `request_id` correlates with server logs (header `X-Request-ID`).
|
|
151
|
+
`code` is machine-readable, `details` names the field that failed, `request_id` correlates with server logs (header `X-Request-ID`). `details` is **only returned outside production** (`APP_ENV` != `production`) — in prod it's stripped from every error so a direct caller can't learn the API's shape; the server log still has the full detail keyed by `request_id`.
|
|
124
152
|
{{#if openapiDocs}}
|
|
125
153
|
|
|
126
154
|
## API spec
|