ga-plugins-cli 0.1.0 → 0.1.2
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/dist/config-patcher.d.ts +20 -50
- package/dist/config-patcher.d.ts.map +1 -1
- package/dist/config-patcher.js +138 -102
- package/dist/config-patcher.js.map +1 -1
- package/dist/index.js +75 -22
- package/dist/index.js.map +1 -1
- package/dist/installer.d.ts +0 -18
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +19 -39
- package/dist/installer.js.map +1 -1
- package/dist/types.d.ts +10 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/uninstaller.d.ts +0 -23
- package/dist/uninstaller.d.ts.map +1 -1
- package/dist/uninstaller.js +22 -68
- package/dist/uninstaller.js.map +1 -1
- package/package.json +3 -2
- package/plugins/go-reviewer/.claude-plugin/plugin.json +12 -0
- package/plugins/go-reviewer/commands/go-review.md +424 -0
- package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/README.md +236 -0
- package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/main.go +678 -0
- package/plugins/go-scaffolder/.claude-plugin/plugin.json +12 -0
- package/plugins/go-scaffolder/commands/scaffold-service.md +802 -0
- package/plugins/go-scaffolder/reference-service/.env.example +27 -0
- package/plugins/go-scaffolder/reference-service/Dockerfile +55 -0
- package/plugins/go-scaffolder/reference-service/REFERENCE-SERVICE-NOTICE.md +104 -0
- package/plugins/go-scaffolder/reference-service/cmd/server/main.go +266 -0
- package/plugins/go-scaffolder/reference-service/config/config.go +67 -0
- package/plugins/go-scaffolder/reference-service/go.mod +17 -0
- package/plugins/go-scaffolder/reference-service/internal/domain/booking.go +118 -0
- package/plugins/go-scaffolder/reference-service/internal/handler/booking.go +242 -0
- package/plugins/go-scaffolder/reference-service/internal/handler/booking_test.go +451 -0
- package/plugins/go-scaffolder/reference-service/internal/repository/booking_postgres.go +124 -0
- package/plugins/go-scaffolder/reference-service/internal/usecase/booking.go +181 -0
- package/plugins/go-standards/.claude-plugin/plugin.json +22 -0
- package/plugins/go-standards/commands/go-standards-check.md +232 -0
- package/plugins/go-standards/skills/concurrency.md +336 -0
- package/plugins/go-standards/skills/config.md +267 -0
- package/plugins/go-standards/skills/error-handling.md +286 -0
- package/plugins/go-standards/skills/http-chi.md +390 -0
- package/plugins/go-standards/skills/logging-observability.md +340 -0
- package/plugins/go-standards/skills/naming-and-style.md +315 -0
- package/plugins/go-standards/skills/project-layout.md +313 -0
- package/plugins/go-standards/skills/testing.md +366 -0
- package/plugins/java2go-porter/.claude-plugin/plugin.json +21 -0
- package/plugins/java2go-porter/agents/analyzer.md +232 -0
- package/plugins/java2go-porter/agents/reviewer.md +241 -0
- package/plugins/java2go-porter/agents/test-pairer.md +365 -0
- package/plugins/java2go-porter/agents/translator.md +419 -0
- package/plugins/java2go-porter/commands/port-java-service.md +149 -0
- package/plugins/java2go-porter/skills/idiom-mapping.md +75 -0
- package/plugins/migration-safety/.claude-plugin/plugin.json +20 -0
- package/plugins/migration-safety/commands/gen-characterization-test.md +452 -0
- package/plugins/migration-safety/commands/strangler-plan.md +356 -0
- package/plugins/migration-safety/skills/strangler-fig.md +382 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# Error Handling Standard
|
|
2
|
+
|
|
3
|
+
Go does not have exceptions. Errors are values — treat them accordingly.
|
|
4
|
+
Every pattern below has a direct Java equivalent called out so the migration
|
|
5
|
+
path is clear.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Rule 1 — Return errors; never panic in business code
|
|
10
|
+
|
|
11
|
+
`panic` is reserved for truly unrecoverable situations at program startup
|
|
12
|
+
(e.g., a required config value is missing, a dependency cannot be
|
|
13
|
+
initialized). Business logic, handlers, usecases, and repositories must
|
|
14
|
+
return errors.
|
|
15
|
+
|
|
16
|
+
### Java equivalent
|
|
17
|
+
- Java `throw new RuntimeException(...)` → Go `return nil, err`
|
|
18
|
+
- Java `throw new IllegalArgumentException(...)` → Go `return nil, domain.ErrInvalidInput`
|
|
19
|
+
|
|
20
|
+
### DO
|
|
21
|
+
|
|
22
|
+
```go
|
|
23
|
+
// internal/usecase/user_usecase.go
|
|
24
|
+
func (uc *UserUsecase) Create(ctx context.Context, input CreateUserInput) (*domain.User, error) {
|
|
25
|
+
if input.Email == "" {
|
|
26
|
+
return nil, fmt.Errorf("create user: %w", domain.ErrInvalidInput)
|
|
27
|
+
}
|
|
28
|
+
u, err := uc.repo.Create(ctx, &domain.User{Email: input.Email})
|
|
29
|
+
if err != nil {
|
|
30
|
+
return nil, fmt.Errorf("create user: %w", err)
|
|
31
|
+
}
|
|
32
|
+
return u, nil
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### DO NOT
|
|
37
|
+
|
|
38
|
+
```go
|
|
39
|
+
// WRONG: panic in business code
|
|
40
|
+
func (uc *UserUsecase) Create(ctx context.Context, input CreateUserInput) *domain.User {
|
|
41
|
+
if input.Email == "" {
|
|
42
|
+
panic("email is required") // WRONG — callers cannot recover from this cleanly
|
|
43
|
+
}
|
|
44
|
+
u, err := uc.repo.Create(ctx, &domain.User{Email: input.Email})
|
|
45
|
+
if err != nil {
|
|
46
|
+
panic(err) // WRONG
|
|
47
|
+
}
|
|
48
|
+
return u
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Rule 2 — Wrap errors with context using `%w`
|
|
55
|
+
|
|
56
|
+
Every error that propagates upward must carry enough context to understand
|
|
57
|
+
what operation failed without reading stack traces. Use `fmt.Errorf` with
|
|
58
|
+
the `%w` verb to wrap, preserving the original error for `errors.Is` /
|
|
59
|
+
`errors.As` inspection.
|
|
60
|
+
|
|
61
|
+
Convention for the message: `"<verb> <noun>: <original>"` — lowercase, no
|
|
62
|
+
trailing period.
|
|
63
|
+
|
|
64
|
+
### DO
|
|
65
|
+
|
|
66
|
+
```go
|
|
67
|
+
func (r *userRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
|
68
|
+
var u domain.User
|
|
69
|
+
if err := r.db.QueryRowContext(ctx, queryFindByID, id).Scan(&u.ID, &u.Email, &u.Name); err != nil {
|
|
70
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
71
|
+
return nil, fmt.Errorf("find user by id %s: %w", id, domain.ErrNotFound)
|
|
72
|
+
}
|
|
73
|
+
return nil, fmt.Errorf("find user by id %s: %w", id, err)
|
|
74
|
+
}
|
|
75
|
+
return &u, nil
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func (uc *UserUsecase) Get(ctx context.Context, id string) (*domain.User, error) {
|
|
79
|
+
u, err := uc.repo.FindByID(ctx, id)
|
|
80
|
+
if err != nil {
|
|
81
|
+
return nil, fmt.Errorf("get user: %w", err) // adds layer, preserves sentinel
|
|
82
|
+
}
|
|
83
|
+
return u, nil
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A log at the top will show the full chain:
|
|
88
|
+
```
|
|
89
|
+
get user: find user by id abc123: not found
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### DO NOT
|
|
93
|
+
|
|
94
|
+
```go
|
|
95
|
+
// WRONG: swallow context
|
|
96
|
+
func (r *userRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
|
97
|
+
var u domain.User
|
|
98
|
+
err := r.db.QueryRowContext(ctx, queryFindByID, id).Scan(&u.ID, &u.Email)
|
|
99
|
+
if err != nil {
|
|
100
|
+
return nil, errors.New("database error") // WRONG: caller cannot inspect original
|
|
101
|
+
}
|
|
102
|
+
return &u, nil
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// WRONG: use %v instead of %w (loses inspectability)
|
|
106
|
+
return nil, fmt.Errorf("find user: %v", err) // WRONG: errors.Is() won't work
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Rule 3 — Sentinel errors for expected domain cases
|
|
112
|
+
|
|
113
|
+
Define sentinel errors in `internal/domain/errors.go`. Use them to signal
|
|
114
|
+
known, expected conditions that callers must handle differently from
|
|
115
|
+
unexpected infrastructure failures.
|
|
116
|
+
|
|
117
|
+
### DO
|
|
118
|
+
|
|
119
|
+
```go
|
|
120
|
+
// internal/domain/errors.go
|
|
121
|
+
package domain
|
|
122
|
+
|
|
123
|
+
import "errors"
|
|
124
|
+
|
|
125
|
+
var (
|
|
126
|
+
ErrNotFound = errors.New("not found")
|
|
127
|
+
ErrAlreadyExists = errors.New("already exists")
|
|
128
|
+
ErrInvalidInput = errors.New("invalid input")
|
|
129
|
+
ErrUnauthorized = errors.New("unauthorized")
|
|
130
|
+
ErrForbidden = errors.New("forbidden")
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```go
|
|
135
|
+
// Caller inspects with errors.Is — works through wrapping chains
|
|
136
|
+
user, err := uc.Get(ctx, id)
|
|
137
|
+
if errors.Is(err, domain.ErrNotFound) {
|
|
138
|
+
respondError(w, http.StatusNotFound, "user not found", "NOT_FOUND")
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if err != nil {
|
|
142
|
+
respondError(w, http.StatusInternalServerError, "internal error", "INTERNAL")
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### DO NOT
|
|
148
|
+
|
|
149
|
+
```go
|
|
150
|
+
// WRONG: string comparison to detect error types
|
|
151
|
+
if err.Error() == "not found" { // WRONG: fragile, breaks on wrap
|
|
152
|
+
...
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// WRONG: bespoke error struct per usecase without sentinel
|
|
156
|
+
type UserNotFoundError struct{ ID string }
|
|
157
|
+
func (e UserNotFoundError) Error() string { return "user " + e.ID + " not found" }
|
|
158
|
+
// Creates an explosion of error types — use sentinels + wrapped context instead
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Rule 4 — Error type hierarchy
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
domain/errors.go — sentinel errors (ErrNotFound, ErrInvalidInput, ...)
|
|
167
|
+
repository layer — wraps stdlib/driver errors into domain sentinels
|
|
168
|
+
usecase layer — wraps domain errors with operation context
|
|
169
|
+
handler layer — maps domain sentinels to HTTP status codes
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Infrastructure errors (DB connectivity, timeout) surface as non-sentinel
|
|
173
|
+
errors and map to 500. Domain errors (not found, invalid) map to 4xx.
|
|
174
|
+
|
|
175
|
+
```go
|
|
176
|
+
// internal/handler/respond.go
|
|
177
|
+
func respondError(w http.ResponseWriter, err error) {
|
|
178
|
+
switch {
|
|
179
|
+
case errors.Is(err, domain.ErrNotFound):
|
|
180
|
+
writeErrorBody(w, http.StatusNotFound, err.Error(), "NOT_FOUND")
|
|
181
|
+
case errors.Is(err, domain.ErrAlreadyExists):
|
|
182
|
+
writeErrorBody(w, http.StatusConflict, err.Error(), "CONFLICT")
|
|
183
|
+
case errors.Is(err, domain.ErrInvalidInput):
|
|
184
|
+
writeErrorBody(w, http.StatusBadRequest, err.Error(), "BAD_REQUEST")
|
|
185
|
+
case errors.Is(err, domain.ErrUnauthorized):
|
|
186
|
+
writeErrorBody(w, http.StatusUnauthorized, err.Error(), "UNAUTHORIZED")
|
|
187
|
+
case errors.Is(err, domain.ErrForbidden):
|
|
188
|
+
writeErrorBody(w, http.StatusForbidden, err.Error(), "FORBIDDEN")
|
|
189
|
+
default:
|
|
190
|
+
writeErrorBody(w, http.StatusInternalServerError, "internal server error", "INTERNAL")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Rule 5 — Never log AND return an error
|
|
198
|
+
|
|
199
|
+
The function that logs an error is the one that handles it (i.e., stops
|
|
200
|
+
propagation). If you return an error to the caller you trust the caller to
|
|
201
|
+
log or handle it. Doing both results in duplicate log entries and confusion
|
|
202
|
+
about where the error originates.
|
|
203
|
+
|
|
204
|
+
### Java equivalent
|
|
205
|
+
Java's common pattern of `logger.error("...", e); throw e;` is a bug in Go.
|
|
206
|
+
|
|
207
|
+
### DO
|
|
208
|
+
|
|
209
|
+
```go
|
|
210
|
+
// repository: wraps and returns — does NOT log
|
|
211
|
+
func (r *userRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
|
212
|
+
// ... query ...
|
|
213
|
+
if err != nil {
|
|
214
|
+
return nil, fmt.Errorf("find user by id: %w", err) // return only
|
|
215
|
+
}
|
|
216
|
+
return &u, nil
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// handler: the final handler logs once and responds
|
|
220
|
+
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
221
|
+
user, err := h.uc.Get(r.Context(), chi.URLParam(r, "id"))
|
|
222
|
+
if err != nil {
|
|
223
|
+
h.logger.Error("get user", zap.Error(err)) // log once here
|
|
224
|
+
respondError(w, err)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
respondJSON(w, http.StatusOK, user)
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### DO NOT
|
|
232
|
+
|
|
233
|
+
```go
|
|
234
|
+
// WRONG: log AND return at every layer
|
|
235
|
+
func (r *userRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
|
236
|
+
// ... query ...
|
|
237
|
+
if err != nil {
|
|
238
|
+
r.logger.Error("db error", zap.Error(err)) // WRONG: log here...
|
|
239
|
+
return nil, fmt.Errorf("find user: %w", err)
|
|
240
|
+
}
|
|
241
|
+
return &u, nil
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func (uc *UserUsecase) Get(ctx context.Context, id string) (*domain.User, error) {
|
|
245
|
+
u, err := uc.repo.FindByID(ctx, id)
|
|
246
|
+
if err != nil {
|
|
247
|
+
uc.logger.Error("usecase error", zap.Error(err)) // WRONG: ...and again here
|
|
248
|
+
return nil, fmt.Errorf("get user: %w", err)
|
|
249
|
+
}
|
|
250
|
+
return u, nil
|
|
251
|
+
}
|
|
252
|
+
// Result: same error logged 3 times at different layers
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Rule 6 — panic is acceptable only during init
|
|
258
|
+
|
|
259
|
+
```go
|
|
260
|
+
// cmd/server/main.go — panic at startup is acceptable
|
|
261
|
+
cfg, err := config.Load()
|
|
262
|
+
if err != nil {
|
|
263
|
+
log.Fatalf("loading config: %v", err) // os.Exit(1) via log.Fatal — ok at startup
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
db, err := sql.Open("postgres", cfg.DSN)
|
|
267
|
+
if err != nil {
|
|
268
|
+
log.Fatalf("opening db: %v", err)
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Using `log.Fatalf` (which calls `os.Exit(1)`) at startup is fine. Using
|
|
273
|
+
`panic` inside a request handler, usecase, or repository is never acceptable.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Quick Reference: Java → Go Error Mapping
|
|
278
|
+
|
|
279
|
+
| Java | Go |
|
|
280
|
+
|------|----|
|
|
281
|
+
| `throw new NotFoundException(id)` | `return nil, fmt.Errorf("find %s: %w", id, domain.ErrNotFound)` |
|
|
282
|
+
| `throw new IllegalArgumentException("...")` | `return nil, fmt.Errorf("validate input: %w", domain.ErrInvalidInput)` |
|
|
283
|
+
| `catch (Exception e) { throw new RuntimeException(e); }` | `return nil, fmt.Errorf("operation: %w", err)` |
|
|
284
|
+
| `try { ... } catch (Exception e) { logger.error(...); }` | `if err != nil { logger.Error(...); }` — do not re-throw |
|
|
285
|
+
| `Optional<User>.orElseThrow(...)` | `if u == nil { return nil, domain.ErrNotFound }` |
|
|
286
|
+
| Checked exception in signature | error return value (compiler enforces handling) |
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# HTTP / chi Standard
|
|
2
|
+
|
|
3
|
+
All HTTP services use github.com/go-chi/chi/v5 as the router. This skill
|
|
4
|
+
defines canonical patterns for router setup, handler signature, request
|
|
5
|
+
decoding, response helpers, middleware, and health endpoints.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Router Setup
|
|
10
|
+
|
|
11
|
+
Always create the router in `cmd/server/main.go`. Apply global middleware in
|
|
12
|
+
this fixed order so request IDs and trace context are available to all
|
|
13
|
+
subsequent middleware.
|
|
14
|
+
|
|
15
|
+
```go
|
|
16
|
+
import (
|
|
17
|
+
"github.com/go-chi/chi/v5"
|
|
18
|
+
"github.com/go-chi/chi/v5/middleware"
|
|
19
|
+
"github.com/go-chi/cors"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
r := chi.NewRouter()
|
|
23
|
+
|
|
24
|
+
// Order matters — RequestID must be first so all downstream middleware can read it
|
|
25
|
+
r.Use(middleware.RequestID)
|
|
26
|
+
r.Use(middleware.RealIP)
|
|
27
|
+
r.Use(middleware.Logger) // chi's built-in access log (swap for zap middleware in prod)
|
|
28
|
+
r.Use(middleware.Recoverer) // catches panics, returns 500
|
|
29
|
+
r.Use(middleware.Compress(5))
|
|
30
|
+
|
|
31
|
+
r.Use(cors.Handler(cors.Options{
|
|
32
|
+
AllowedOrigins: []string{"https://*.yourdomain.com"},
|
|
33
|
+
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
34
|
+
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
|
35
|
+
ExposedHeaders: []string{"X-Request-ID"},
|
|
36
|
+
AllowCredentials: true,
|
|
37
|
+
MaxAge: 300,
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
r.Route("/api/v1", func(r chi.Router) {
|
|
41
|
+
r.Mount("/users", userHandler.Routes())
|
|
42
|
+
r.Mount("/orders", orderHandler.Routes())
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
r.Get("/health", healthHandler.Health)
|
|
46
|
+
r.Get("/ready", healthHandler.Ready)
|
|
47
|
+
r.Handle("/metrics", promhttp.Handler())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Handler Type and Constructor
|
|
53
|
+
|
|
54
|
+
Each resource gets its own handler struct. The struct holds only the usecase
|
|
55
|
+
interface and logger — never a concrete repository, never a DB connection.
|
|
56
|
+
|
|
57
|
+
```go
|
|
58
|
+
// internal/handler/user_handler.go
|
|
59
|
+
package handler
|
|
60
|
+
|
|
61
|
+
import (
|
|
62
|
+
"net/http"
|
|
63
|
+
|
|
64
|
+
"github.com/go-chi/chi/v5"
|
|
65
|
+
"go.uber.org/zap"
|
|
66
|
+
|
|
67
|
+
"github.com/zokypesch/go-ga-lib/internal/domain"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
type UserHandler struct {
|
|
71
|
+
uc domain.UserUsecase
|
|
72
|
+
logger *zap.Logger
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func NewUserHandler(uc domain.UserUsecase, logger *zap.Logger) *UserHandler {
|
|
76
|
+
return &UserHandler{uc: uc, logger: logger}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Routes registers all user routes on a chi sub-router.
|
|
80
|
+
func (h *UserHandler) Routes() chi.Router {
|
|
81
|
+
r := chi.NewRouter()
|
|
82
|
+
r.Get("/", h.List)
|
|
83
|
+
r.Post("/", h.Create)
|
|
84
|
+
r.Get("/{id}", h.Get)
|
|
85
|
+
r.Put("/{id}", h.Update)
|
|
86
|
+
r.Delete("/{id}", h.Delete)
|
|
87
|
+
return r
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Handler Signature
|
|
94
|
+
|
|
95
|
+
Every handler has the standard `http.HandlerFunc` signature. Handlers are
|
|
96
|
+
thin: decode request → call usecase → encode response. No business logic here.
|
|
97
|
+
|
|
98
|
+
```go
|
|
99
|
+
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
100
|
+
var input domain.CreateUserInput
|
|
101
|
+
if err := decodeJSON(r, &input); err != nil {
|
|
102
|
+
respondError(w, http.StatusBadRequest, err.Error(), "BAD_REQUEST")
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
user, err := h.uc.Create(r.Context(), input)
|
|
107
|
+
if err != nil {
|
|
108
|
+
h.logger.Error("create user", zap.Error(err), zap.String("request_id", middleware.GetReqID(r.Context())))
|
|
109
|
+
respondError(w, err)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
respondJSON(w, http.StatusCreated, user)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
117
|
+
id := chi.URLParam(r, "id")
|
|
118
|
+
|
|
119
|
+
user, err := h.uc.Get(r.Context(), id)
|
|
120
|
+
if err != nil {
|
|
121
|
+
h.logger.Error("get user", zap.Error(err), zap.String("id", id))
|
|
122
|
+
respondError(w, err)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
respondJSON(w, http.StatusOK, user)
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Request Decoding
|
|
133
|
+
|
|
134
|
+
Use a shared `decodeJSON` helper. Always enforce a size limit on the request
|
|
135
|
+
body to prevent OOM attacks.
|
|
136
|
+
|
|
137
|
+
```go
|
|
138
|
+
// internal/handler/decode.go
|
|
139
|
+
package handler
|
|
140
|
+
|
|
141
|
+
import (
|
|
142
|
+
"encoding/json"
|
|
143
|
+
"errors"
|
|
144
|
+
"fmt"
|
|
145
|
+
"io"
|
|
146
|
+
"net/http"
|
|
147
|
+
"strings"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const maxBodyBytes = 1 << 20 // 1 MB
|
|
151
|
+
|
|
152
|
+
func decodeJSON(r *http.Request, dst any) error {
|
|
153
|
+
r.Body = http.MaxBytesReader(nil, r.Body, maxBodyBytes)
|
|
154
|
+
dec := json.NewDecoder(r.Body)
|
|
155
|
+
dec.DisallowUnknownFields()
|
|
156
|
+
|
|
157
|
+
if err := dec.Decode(dst); err != nil {
|
|
158
|
+
var syntaxErr *json.SyntaxError
|
|
159
|
+
var unmarshalErr *json.UnmarshalTypeError
|
|
160
|
+
switch {
|
|
161
|
+
case errors.As(err, &syntaxErr):
|
|
162
|
+
return fmt.Errorf("malformed JSON at position %d", syntaxErr.Offset)
|
|
163
|
+
case errors.As(err, &unmarshalErr):
|
|
164
|
+
return fmt.Errorf("invalid value for field %q", unmarshalErr.Field)
|
|
165
|
+
case errors.Is(err, io.EOF):
|
|
166
|
+
return errors.New("request body must not be empty")
|
|
167
|
+
case strings.HasPrefix(err.Error(), "json: unknown field"):
|
|
168
|
+
field := strings.TrimPrefix(err.Error(), "json: unknown field ")
|
|
169
|
+
return fmt.Errorf("unknown field %s", field)
|
|
170
|
+
default:
|
|
171
|
+
return fmt.Errorf("decoding request: %w", err)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return nil
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Response Helpers
|
|
181
|
+
|
|
182
|
+
All responses use a consistent JSON envelope. Errors always include a
|
|
183
|
+
machine-readable `code` field so clients can branch on it.
|
|
184
|
+
|
|
185
|
+
```go
|
|
186
|
+
// internal/handler/respond.go
|
|
187
|
+
package handler
|
|
188
|
+
|
|
189
|
+
import (
|
|
190
|
+
"encoding/json"
|
|
191
|
+
"errors"
|
|
192
|
+
"net/http"
|
|
193
|
+
|
|
194
|
+
"github.com/zokypesch/go-ga-lib/internal/domain"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
type errorBody struct {
|
|
198
|
+
Error string `json:"error"`
|
|
199
|
+
Code string `json:"code"`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func respondJSON(w http.ResponseWriter, status int, data any) {
|
|
203
|
+
w.Header().Set("Content-Type", "application/json")
|
|
204
|
+
w.WriteHeader(status)
|
|
205
|
+
if data != nil {
|
|
206
|
+
_ = json.NewEncoder(w).Encode(data)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func respondError(w http.ResponseWriter, err error) {
|
|
211
|
+
switch {
|
|
212
|
+
case errors.Is(err, domain.ErrNotFound):
|
|
213
|
+
writeErrorBody(w, http.StatusNotFound, err.Error(), "NOT_FOUND")
|
|
214
|
+
case errors.Is(err, domain.ErrAlreadyExists):
|
|
215
|
+
writeErrorBody(w, http.StatusConflict, err.Error(), "CONFLICT")
|
|
216
|
+
case errors.Is(err, domain.ErrInvalidInput):
|
|
217
|
+
writeErrorBody(w, http.StatusBadRequest, err.Error(), "BAD_REQUEST")
|
|
218
|
+
case errors.Is(err, domain.ErrUnauthorized):
|
|
219
|
+
writeErrorBody(w, http.StatusUnauthorized, err.Error(), "UNAUTHORIZED")
|
|
220
|
+
case errors.Is(err, domain.ErrForbidden):
|
|
221
|
+
writeErrorBody(w, http.StatusForbidden, err.Error(), "FORBIDDEN")
|
|
222
|
+
default:
|
|
223
|
+
writeErrorBody(w, http.StatusInternalServerError, "internal server error", "INTERNAL")
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// respondErrorStatus allows callers to supply an explicit status code + code string
|
|
228
|
+
// (e.g., from decodeJSON where no domain sentinel applies).
|
|
229
|
+
func respondErrorStatus(w http.ResponseWriter, status int, message, code string) {
|
|
230
|
+
writeErrorBody(w, status, message, code)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
func writeErrorBody(w http.ResponseWriter, status int, message, code string) {
|
|
234
|
+
w.Header().Set("Content-Type", "application/json")
|
|
235
|
+
w.WriteHeader(status)
|
|
236
|
+
_ = json.NewEncoder(w).Encode(errorBody{Error: message, Code: code})
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Auth Middleware
|
|
243
|
+
|
|
244
|
+
Request-scoped values travel through `context.Context`. Define typed keys
|
|
245
|
+
(never string keys) to avoid collisions.
|
|
246
|
+
|
|
247
|
+
```go
|
|
248
|
+
// internal/handler/middleware/auth.go
|
|
249
|
+
package middleware
|
|
250
|
+
|
|
251
|
+
import (
|
|
252
|
+
"context"
|
|
253
|
+
"net/http"
|
|
254
|
+
"strings"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
type contextKey string
|
|
258
|
+
|
|
259
|
+
const claimsKey contextKey = "claims"
|
|
260
|
+
|
|
261
|
+
type Claims struct {
|
|
262
|
+
UserID string
|
|
263
|
+
Email string
|
|
264
|
+
Roles []string
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// BearerAuth validates the Authorization header and stores claims in context.
|
|
268
|
+
func BearerAuth(next http.Handler) http.Handler {
|
|
269
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
270
|
+
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
271
|
+
if token == "" {
|
|
272
|
+
http.Error(w, `{"error":"missing token","code":"UNAUTHORIZED"}`, http.StatusUnauthorized)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
claims, err := validateToken(token)
|
|
277
|
+
if err != nil {
|
|
278
|
+
http.Error(w, `{"error":"invalid token","code":"UNAUTHORIZED"}`, http.StatusUnauthorized)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
ctx := context.WithValue(r.Context(), claimsKey, claims)
|
|
283
|
+
next.ServeHTTP(w, r.WithContext(ctx))
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ClaimsFromContext extracts validated claims — returns false if not present.
|
|
288
|
+
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
|
|
289
|
+
c, ok := ctx.Value(claimsKey).(*Claims)
|
|
290
|
+
return c, ok
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Apply per-route group, not globally:
|
|
295
|
+
|
|
296
|
+
```go
|
|
297
|
+
r.Route("/api/v1", func(r chi.Router) {
|
|
298
|
+
r.Use(middleware.BearerAuth) // protects everything under /api/v1
|
|
299
|
+
r.Mount("/users", userHandler.Routes())
|
|
300
|
+
})
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Health and Readiness Endpoints
|
|
306
|
+
|
|
307
|
+
Every service exposes `/health` (liveness) and `/ready` (readiness). These
|
|
308
|
+
are used by Kubernetes probes and must never be behind auth middleware.
|
|
309
|
+
|
|
310
|
+
```go
|
|
311
|
+
// internal/handler/health_handler.go
|
|
312
|
+
package handler
|
|
313
|
+
|
|
314
|
+
import (
|
|
315
|
+
"context"
|
|
316
|
+
"database/sql"
|
|
317
|
+
"net/http"
|
|
318
|
+
"time"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
type HealthHandler struct {
|
|
322
|
+
db *sql.DB
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
func NewHealthHandler(db *sql.DB) *HealthHandler {
|
|
326
|
+
return &HealthHandler{db: db}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Health is the liveness probe — always 200 if the process is running.
|
|
330
|
+
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
|
|
331
|
+
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Ready is the readiness probe — 503 if dependencies are not available.
|
|
335
|
+
func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
|
|
336
|
+
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
337
|
+
defer cancel()
|
|
338
|
+
|
|
339
|
+
if err := h.db.PingContext(ctx); err != nil {
|
|
340
|
+
respondJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
341
|
+
"status": "not ready",
|
|
342
|
+
"reason": "database unreachable",
|
|
343
|
+
})
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
respondJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## Route Grouping Convention
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
GET /api/v1/users → List
|
|
357
|
+
POST /api/v1/users → Create
|
|
358
|
+
GET /api/v1/users/{id} → Get
|
|
359
|
+
PUT /api/v1/users/{id} → Update (full replacement)
|
|
360
|
+
PATCH /api/v1/users/{id} → PartialUpdate
|
|
361
|
+
DELETE /api/v1/users/{id} → Delete
|
|
362
|
+
|
|
363
|
+
GET /health → Health (liveness)
|
|
364
|
+
GET /ready → Ready (readiness)
|
|
365
|
+
GET /metrics → Prometheus scrape endpoint
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## DO NOT
|
|
371
|
+
|
|
372
|
+
```go
|
|
373
|
+
// WRONG: using default ServeMux
|
|
374
|
+
http.HandleFunc("/users", handler) // WRONG: no URL params, no middleware chaining
|
|
375
|
+
|
|
376
|
+
// WRONG: business logic in handler
|
|
377
|
+
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
378
|
+
var u domain.User
|
|
379
|
+
json.NewDecoder(r.Body).Decode(&u)
|
|
380
|
+
// WRONG: password hashing, email validation, and DB writes directly in handler
|
|
381
|
+
u.Password = bcrypt.GenerateFromPassword(...)
|
|
382
|
+
db.Insert(u)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// WRONG: string context keys
|
|
386
|
+
ctx = context.WithValue(ctx, "userID", id) // WRONG: use typed keys
|
|
387
|
+
|
|
388
|
+
// WRONG: ignoring Body close / size limit
|
|
389
|
+
json.NewDecoder(r.Body).Decode(&input) // WRONG: no size limit, no close
|
|
390
|
+
```
|