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.
Files changed (55) hide show
  1. package/dist/config-patcher.d.ts +20 -50
  2. package/dist/config-patcher.d.ts.map +1 -1
  3. package/dist/config-patcher.js +138 -102
  4. package/dist/config-patcher.js.map +1 -1
  5. package/dist/index.js +75 -22
  6. package/dist/index.js.map +1 -1
  7. package/dist/installer.d.ts +0 -18
  8. package/dist/installer.d.ts.map +1 -1
  9. package/dist/installer.js +19 -39
  10. package/dist/installer.js.map +1 -1
  11. package/dist/types.d.ts +10 -6
  12. package/dist/types.d.ts.map +1 -1
  13. package/dist/uninstaller.d.ts +0 -23
  14. package/dist/uninstaller.d.ts.map +1 -1
  15. package/dist/uninstaller.js +22 -68
  16. package/dist/uninstaller.js.map +1 -1
  17. package/package.json +3 -2
  18. package/plugins/go-reviewer/.claude-plugin/plugin.json +12 -0
  19. package/plugins/go-reviewer/commands/go-review.md +424 -0
  20. package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/README.md +236 -0
  21. package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/main.go +678 -0
  22. package/plugins/go-scaffolder/.claude-plugin/plugin.json +12 -0
  23. package/plugins/go-scaffolder/commands/scaffold-service.md +802 -0
  24. package/plugins/go-scaffolder/reference-service/.env.example +27 -0
  25. package/plugins/go-scaffolder/reference-service/Dockerfile +55 -0
  26. package/plugins/go-scaffolder/reference-service/REFERENCE-SERVICE-NOTICE.md +104 -0
  27. package/plugins/go-scaffolder/reference-service/cmd/server/main.go +266 -0
  28. package/plugins/go-scaffolder/reference-service/config/config.go +67 -0
  29. package/plugins/go-scaffolder/reference-service/go.mod +17 -0
  30. package/plugins/go-scaffolder/reference-service/internal/domain/booking.go +118 -0
  31. package/plugins/go-scaffolder/reference-service/internal/handler/booking.go +242 -0
  32. package/plugins/go-scaffolder/reference-service/internal/handler/booking_test.go +451 -0
  33. package/plugins/go-scaffolder/reference-service/internal/repository/booking_postgres.go +124 -0
  34. package/plugins/go-scaffolder/reference-service/internal/usecase/booking.go +181 -0
  35. package/plugins/go-standards/.claude-plugin/plugin.json +22 -0
  36. package/plugins/go-standards/commands/go-standards-check.md +232 -0
  37. package/plugins/go-standards/skills/concurrency.md +336 -0
  38. package/plugins/go-standards/skills/config.md +267 -0
  39. package/plugins/go-standards/skills/error-handling.md +286 -0
  40. package/plugins/go-standards/skills/http-chi.md +390 -0
  41. package/plugins/go-standards/skills/logging-observability.md +340 -0
  42. package/plugins/go-standards/skills/naming-and-style.md +315 -0
  43. package/plugins/go-standards/skills/project-layout.md +313 -0
  44. package/plugins/go-standards/skills/testing.md +366 -0
  45. package/plugins/java2go-porter/.claude-plugin/plugin.json +21 -0
  46. package/plugins/java2go-porter/agents/analyzer.md +232 -0
  47. package/plugins/java2go-porter/agents/reviewer.md +241 -0
  48. package/plugins/java2go-porter/agents/test-pairer.md +365 -0
  49. package/plugins/java2go-porter/agents/translator.md +419 -0
  50. package/plugins/java2go-porter/commands/port-java-service.md +149 -0
  51. package/plugins/java2go-porter/skills/idiom-mapping.md +75 -0
  52. package/plugins/migration-safety/.claude-plugin/plugin.json +20 -0
  53. package/plugins/migration-safety/commands/gen-characterization-test.md +452 -0
  54. package/plugins/migration-safety/commands/strangler-plan.md +356 -0
  55. 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
+ ```