rapidkit 0.25.5 → 0.25.7

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.
@@ -0,0 +1,1459 @@
1
+ import {c,b as b$2,d as d$1,e,f,a}from'./chunk-U7XJZHU6.js';import {b as b$1}from'./chunk-Q7ULIFQA.js';import {promises}from'fs';import d from'path';import o from'chalk';import _ from'ora';import {execa}from'execa';function T(e){return `package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "log/slog"
8
+ "net/http"
9
+ "os"
10
+ "os/signal"
11
+ "syscall"
12
+ "time"
13
+
14
+ _ "${e.module_path}/docs"
15
+ "${e.module_path}/internal/config"
16
+ "${e.module_path}/internal/server"
17
+ )
18
+
19
+ // Build-time variables \u2014 injected via -ldflags.
20
+ var (
21
+ version = "dev"
22
+ commit = "none"
23
+ date = "unknown"
24
+ )
25
+
26
+ func main() {
27
+ cfg := config.Load()
28
+
29
+ log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
30
+ Level: config.ParseLogLevel(cfg.LogLevel),
31
+ }))
32
+ slog.SetDefault(log)
33
+
34
+ r := server.NewRouter(cfg)
35
+
36
+ srv := &http.Server{
37
+ Addr: ":" + cfg.Port,
38
+ Handler: r,
39
+ ReadTimeout: 5 * time.Second,
40
+ WriteTimeout: 10 * time.Second,
41
+ IdleTimeout: 30 * time.Second,
42
+ }
43
+
44
+ go func() {
45
+ slog.Info("starting", "port", cfg.Port, "version", version, "commit", commit, "date", date, "env", cfg.Env)
46
+ fmt.Printf("\\n\u{1F680} Server \u2192 http://127.0.0.1:%s\\n", cfg.Port)
47
+ fmt.Printf("\u{1F4D6} Docs \u2192 http://127.0.0.1:%s/docs\\n\\n", cfg.Port)
48
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
49
+ slog.Error("server error", "err", err)
50
+ os.Exit(1)
51
+ }
52
+ }()
53
+
54
+ quit := make(chan os.Signal, 1)
55
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
56
+ <-quit
57
+
58
+ slog.Info("shutting down\u2026")
59
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
60
+ defer cancel()
61
+
62
+ if err := srv.Shutdown(ctx); err != nil {
63
+ slog.Error("forced shutdown", "err", err)
64
+ os.Exit(1)
65
+ }
66
+ slog.Info("server stopped")
67
+ }
68
+ `}function O(e){return `module ${e.module_path}
69
+
70
+ go ${e.go_version}
71
+
72
+ require (
73
+ github.com/gin-gonic/gin v1.10.0
74
+ github.com/swaggo/files v1.0.1
75
+ github.com/swaggo/gin-swagger v1.6.0
76
+ github.com/swaggo/swag v1.16.3
77
+ )
78
+ `}function N(e){return `package config
79
+
80
+ import (
81
+ "log/slog"
82
+ "os"
83
+ "strings"
84
+ )
85
+
86
+ // Config holds application configuration loaded from environment variables.
87
+ type Config struct {
88
+ Port string
89
+ Env string
90
+ GinMode string
91
+ LogLevel string
92
+ }
93
+
94
+ // Load reads configuration from environment variables with sensible defaults.
95
+ func Load() *Config {
96
+ env := getEnv("APP_ENV", "development")
97
+ return &Config{
98
+ Port: getEnv("PORT", "${e.port}"),
99
+ Env: env,
100
+ GinMode: getEnv("GIN_MODE", "debug"),
101
+ LogLevel: getEnv("LOG_LEVEL", defaultLogLevel(env)),
102
+ }
103
+ }
104
+
105
+ // ParseLogLevel maps a level string to the corresponding slog.Level.
106
+ // Falls back to Info for unrecognised values.
107
+ func ParseLogLevel(s string) slog.Level {
108
+ switch strings.ToLower(s) {
109
+ case "debug":
110
+ return slog.LevelDebug
111
+ case "warn", "warning":
112
+ return slog.LevelWarn
113
+ case "error":
114
+ return slog.LevelError
115
+ default:
116
+ return slog.LevelInfo
117
+ }
118
+ }
119
+
120
+ func defaultLogLevel(env string) string {
121
+ if env == "development" {
122
+ return "debug"
123
+ }
124
+ return "info"
125
+ }
126
+
127
+ func getEnv(key, fallback string) string {
128
+ if v, ok := os.LookupEnv(key); ok && v != "" {
129
+ return v
130
+ }
131
+ return fallback
132
+ }
133
+ `}function b(e){return `package server
134
+
135
+ import (
136
+ "net/http"
137
+
138
+ "github.com/gin-gonic/gin"
139
+ ginSwagger "github.com/swaggo/gin-swagger"
140
+ swaggerFiles "github.com/swaggo/files"
141
+
142
+ "${e.module_path}/internal/apierr"
143
+ "${e.module_path}/internal/config"
144
+ "${e.module_path}/internal/handlers"
145
+ "${e.module_path}/internal/middleware"
146
+ )
147
+
148
+ // NewRouter assembles the Gin engine with all middleware and routes.
149
+ // Call this from main \u2014 or from tests via server.NewRouter(cfg).
150
+ func NewRouter(cfg *config.Config) *gin.Engine {
151
+ if cfg.GinMode == "release" {
152
+ gin.SetMode(gin.ReleaseMode)
153
+ }
154
+
155
+ r := gin.New()
156
+ r.Use(gin.Recovery())
157
+ r.Use(middleware.CORS())
158
+ r.Use(middleware.RequestID())
159
+ r.Use(middleware.RateLimit())
160
+ r.Use(middleware.Logger())
161
+
162
+ // Swagger UI \u2014 /docs redirects to /docs/index.html
163
+ r.GET("/docs", func(c *gin.Context) { c.Redirect(http.StatusFound, "/docs/index.html") })
164
+ r.GET("/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
165
+
166
+ v1 := r.Group("/api/v1")
167
+ {
168
+ v1.GET("/health/live", handlers.Liveness)
169
+ v1.GET("/health/ready", handlers.Readiness)
170
+ v1.GET("/echo/:name", handlers.EchoParams)
171
+ }
172
+
173
+ // Return JSON for unknown routes/methods instead of Gin's default text responses.
174
+ // HandleMethodNotAllowed must be true so NoMethod handler fires for 405 cases.
175
+ r.HandleMethodNotAllowed = true
176
+ r.NoRoute(func(c *gin.Context) {
177
+ apierr.NotFound(c, "route not found")
178
+ })
179
+ r.NoMethod(func(c *gin.Context) {
180
+ apierr.MethodNotAllowed(c)
181
+ })
182
+
183
+ return r
184
+ }
185
+ `}function G(){return `package handlers
186
+
187
+ import (
188
+ "net/http"
189
+ "time"
190
+
191
+ "github.com/gin-gonic/gin"
192
+ )
193
+
194
+ // Liveness signals the process is alive (Kubernetes livenessProbe).
195
+ //
196
+ // @Summary Liveness probe
197
+ // @Description Returns 200 when the process is alive.
198
+ // @Tags health
199
+ // @Produce json
200
+ // @Success 200 {object} map[string]string
201
+ // @Router /api/v1/health/live [get]
202
+ func Liveness(c *gin.Context) {
203
+ c.JSON(http.StatusOK, gin.H{
204
+ "status": "ok",
205
+ "time": time.Now().UTC().Format(time.RFC3339),
206
+ })
207
+ }
208
+
209
+ // Readiness signals the service can accept traffic (Kubernetes readinessProbe).
210
+ // Extend this function to check database connectivity, caches, etc.
211
+ //
212
+ // @Summary Readiness probe
213
+ // @Description Returns 200 when the service is ready to accept traffic.
214
+ // @Tags health
215
+ // @Produce json
216
+ // @Success 200 {object} map[string]string
217
+ // @Router /api/v1/health/ready [get]
218
+ func Readiness(c *gin.Context) {
219
+ c.JSON(http.StatusOK, gin.H{
220
+ "status": "ready",
221
+ "time": time.Now().UTC().Format(time.RFC3339),
222
+ })
223
+ }
224
+ `}function q(e){return `package handlers_test
225
+
226
+ import (
227
+ "encoding/json"
228
+ "net/http"
229
+ "net/http/httptest"
230
+ "testing"
231
+
232
+ "github.com/gin-gonic/gin"
233
+
234
+ "${e.module_path}/internal/config"
235
+ "${e.module_path}/internal/server"
236
+ )
237
+
238
+ func init() { gin.SetMode(gin.TestMode) }
239
+
240
+ func newRouter() *gin.Engine { return server.NewRouter(config.Load()) }
241
+
242
+ func TestLiveness(t *testing.T) {
243
+ w := httptest.NewRecorder()
244
+ req, _ := http.NewRequest(http.MethodGet, "/api/v1/health/live", nil)
245
+ newRouter().ServeHTTP(w, req)
246
+
247
+ if w.Code != http.StatusOK {
248
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
249
+ }
250
+
251
+ var body map[string]any
252
+ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
253
+ t.Fatalf("invalid JSON: %v", err)
254
+ }
255
+ if body["status"] != "ok" {
256
+ t.Fatalf("expected ok, got %v", body["status"])
257
+ }
258
+ }
259
+
260
+ func TestReadiness(t *testing.T) {
261
+ w := httptest.NewRecorder()
262
+ req, _ := http.NewRequest(http.MethodGet, "/api/v1/health/ready", nil)
263
+ newRouter().ServeHTTP(w, req)
264
+
265
+ if w.Code != http.StatusOK {
266
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
267
+ }
268
+ }
269
+ `}function E(){return `# \u2500\u2500 Build stage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
270
+ FROM golang:1.24-alpine AS builder
271
+
272
+ # Build-time version injection
273
+ ARG VERSION=dev
274
+ ARG COMMIT=none
275
+ ARG DATE=unknown
276
+
277
+ WORKDIR /app
278
+ COPY go.mod go.sum ./
279
+ RUN go mod download
280
+
281
+ COPY . .
282
+ RUN CGO_ENABLED=0 GOOS=linux go build \\
283
+ -ldflags="-s -w -X main.version=$\${VERSION} -X main.commit=$\${COMMIT} -X main.date=$\${DATE}" \\
284
+ -o server ./cmd/server
285
+
286
+ # \u2500\u2500 Runtime stage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
287
+ # alpine includes busybox wget required for the HEALTHCHECK below.
288
+ FROM alpine:3.21
289
+
290
+ RUN addgroup -S app && adduser -S -G app app
291
+ COPY --from=builder /app/server /server
292
+ USER app
293
+
294
+ EXPOSE 8080
295
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
296
+ CMD wget -qO- http://localhost:8080/api/v1/health/live || exit 1
297
+ ENTRYPOINT ["/server"]
298
+ `}function L(e){return `version: "3.9"
299
+
300
+ services:
301
+ api:
302
+ build: .
303
+ container_name: ${e.project_name}
304
+ ports:
305
+ - "${e.port}:${e.port}"
306
+ environment:
307
+ PORT: "${e.port}"
308
+ APP_ENV: development
309
+ GIN_MODE: debug
310
+ LOG_LEVEL: info
311
+ CORS_ALLOW_ORIGINS: "*"
312
+ RATE_LIMIT_RPS: "100"
313
+ restart: unless-stopped
314
+ `}function C(e){return d$1({projectName:e.project_name,devCommand:"GIN_MODE=debug $(GOBIN)/air",runCommand:"GIN_MODE=debug go run $(LDFLAGS) ./cmd/server",testCommand:"GIN_MODE=test go test ./... -v -race",includeLintAndFmt:true})}function y(e){return `# Application
315
+ PORT=${e.port}
316
+ APP_ENV=development
317
+ GIN_MODE=debug
318
+ LOG_LEVEL=debug
319
+
320
+ # CORS \u2014 comma-separated list of allowed origins, or * to allow all
321
+ CORS_ALLOW_ORIGINS=*
322
+
323
+ # Rate limiting \u2014 max requests per IP per second
324
+ RATE_LIMIT_RPS=100
325
+ `}function I(){return `# Binaries
326
+ bin/
327
+ *.exe
328
+ *.exe~
329
+ *.dll
330
+ *.so
331
+ *.dylib
332
+
333
+ # Test binary
334
+ *.test
335
+
336
+ # Output of go coverage tool
337
+ *.out
338
+ coverage.html
339
+
340
+ # Go workspace
341
+ go.work
342
+ go.work.sum
343
+
344
+ # Environment
345
+ .env
346
+ .env.local
347
+
348
+ # Hot reload (air)
349
+ tmp/
350
+
351
+ # Swagger \u2014 generated files (committed stub docs/doc.go; run \`make docs\` to regenerate)
352
+ docs/swagger.json
353
+ docs/swagger.yaml
354
+ docs/docs.go
355
+
356
+ # Editor
357
+ .idea/
358
+ .vscode/
359
+ *.swp
360
+ *.swo
361
+
362
+ # OS
363
+ .DS_Store
364
+ Thumbs.db
365
+ `}function x(e){return `name: CI
366
+
367
+ on:
368
+ push:
369
+ branches: [main, develop]
370
+ pull_request:
371
+ branches: [main]
372
+
373
+ jobs:
374
+ test:
375
+ name: Test
376
+ runs-on: ubuntu-latest
377
+
378
+ steps:
379
+ - uses: actions/checkout@v4
380
+
381
+ - name: Set up Go
382
+ uses: actions/setup-go@v5
383
+ with:
384
+ go-version: "${e.go_version}"
385
+ cache: true
386
+
387
+ - name: Tidy
388
+ run: go mod tidy
389
+
390
+ - name: Build
391
+ run: go build ./...
392
+
393
+ - name: Test
394
+ run: GIN_MODE=test go test ./... -race -coverprofile=coverage.out
395
+
396
+ - name: Upload coverage
397
+ uses: actions/upload-artifact@v4
398
+ with:
399
+ name: coverage
400
+ path: coverage.out
401
+
402
+ lint:
403
+ name: Lint
404
+ runs-on: ubuntu-latest
405
+
406
+ steps:
407
+ - uses: actions/checkout@v4
408
+
409
+ - name: Set up Go
410
+ uses: actions/setup-go@v5
411
+ with:
412
+ go-version: "${e.go_version}"
413
+ cache: true
414
+
415
+ - name: golangci-lint
416
+ uses: golangci/golangci-lint-action@v6
417
+ with:
418
+ version: latest
419
+ `}function M(e){return `# ${b$2(e.project_name)}
420
+
421
+ > ${e.description}
422
+
423
+ Built with [Go](https://go.dev/) + [Gin](https://gin-gonic.com/) \xB7 Scaffolded by [RapidKit](https://getrapidkit.com)
424
+
425
+ ## Quick start
426
+
427
+ \`\`\`bash
428
+ # Run locally (hot reload)
429
+ make dev
430
+
431
+ # Run tests
432
+ make test
433
+
434
+ # Build binary
435
+ make build
436
+
437
+ # Generate / refresh Swagger docs
438
+ make docs
439
+
440
+ # Docker
441
+ make docker-up
442
+ \`\`\`
443
+
444
+ ## Swagger / OpenAPI
445
+
446
+ After running \`make docs\`, the interactive UI is available at:
447
+
448
+ \`\`\`
449
+ http://localhost:${e.port}/docs
450
+ \`\`\`
451
+
452
+ The raw OpenAPI spec is served at \`/docs/doc.json\`.
453
+
454
+ ## Endpoints
455
+
456
+ | Method | Path | Description |
457
+ |--------|------|--------------|
458
+ | GET | /api/v1/health/live | Kubernetes livenessProbe |
459
+ | GET | /api/v1/health/ready | Kubernetes readinessProbe |
460
+ | GET | /api/v1/echo/:name | Example handler \u2014 remove in production |
461
+ | GET | /docs/* | Swagger UI (OpenAPI docs) |
462
+
463
+ ## Configuration
464
+
465
+ All configuration is done through environment variables (see \`.env.example\`):
466
+
467
+ | Variable | Default | Description |
468
+ |----------|---------|-------------|
469
+ | \`PORT\` | \`${e.port}\` | HTTP listen port |
470
+ | \`APP_ENV\` | \`development\` | Application environment |
471
+ | \`GIN_MODE\` | \`debug\` | \`debug\` \\| \`release\` \\| \`test\` |
472
+ | \`LOG_LEVEL\` | \`debug\` / \`info\` | \`debug\` \\| \`info\` \\| \`warn\` \\| \`error\` |
473
+ | \`CORS_ALLOW_ORIGINS\` | \`*\` | Comma-separated list of allowed origins, or \`*\` |
474
+ | \`RATE_LIMIT_RPS\` | \`100\` | Max requests per IP per second |
475
+
476
+ ## Project structure
477
+
478
+ \`\`\`
479
+ ${e.project_name}/
480
+ \u251C\u2500\u2500 cmd/
481
+ \u2502 \u2514\u2500\u2500 server/
482
+ \u2502 \u2514\u2500\u2500 main.go # Graceful shutdown + version ldflags
483
+ \u251C\u2500\u2500 docs/ # Swagger generated files (\`make docs\`)
484
+ \u2502 \u2514\u2500\u2500 doc.go # Package-level OpenAPI annotations
485
+ \u251C\u2500\u2500 internal/
486
+ \u2502 \u251C\u2500\u2500 apierr/ # Consistent JSON error envelope
487
+ \u2502 \u2502 \u251C\u2500\u2500 apierr.go
488
+ \u2502 \u2502 \u2514\u2500\u2500 apierr_test.go
489
+ \u2502 \u251C\u2500\u2500 config/ # 12-factor configuration
490
+ \u2502 \u2502 \u251C\u2500\u2500 config.go
491
+ \u2502 \u2502 \u2514\u2500\u2500 config_test.go
492
+ \u2502 \u251C\u2500\u2500 handlers/ # HTTP handlers + tests
493
+ \u2502 \u2502 \u251C\u2500\u2500 health.go
494
+ \u2502 \u2502 \u251C\u2500\u2500 health_test.go
495
+ \u2502 \u2502 \u251C\u2500\u2500 example.go # EchoParams \u2014 replace with your own handlers
496
+ \u2502 \u2502 \u2514\u2500\u2500 example_test.go
497
+ \u2502 \u251C\u2500\u2500 middleware/
498
+ \u2502 \u2502 \u251C\u2500\u2500 requestid.go # X-Request-ID + structured logger
499
+ \u2502 \u2502 \u251C\u2500\u2500 requestid_test.go
500
+ \u2502 \u2502 \u251C\u2500\u2500 cors.go # CORS (CORS_ALLOW_ORIGINS)
501
+ \u2502 \u2502 \u251C\u2500\u2500 cors_test.go
502
+ \u2502 \u2502 \u251C\u2500\u2500 ratelimit.go # Per-IP limiter (RATE_LIMIT_RPS)
503
+ \u2502 \u2502 \u2514\u2500\u2500 ratelimit_test.go
504
+ \u2502 \u2514\u2500\u2500 server/
505
+ \u2502 \u251C\u2500\u2500 server.go
506
+ \u2502 \u2514\u2500\u2500 server_test.go
507
+ \u251C\u2500\u2500 .air.toml # Hot reload
508
+ \u251C\u2500\u2500 .github/workflows/ci.yml # CI: test + lint
509
+ \u251C\u2500\u2500 .golangci.yml
510
+ \u251C\u2500\u2500 Dockerfile # Multi-stage, alpine HEALTHCHECK
511
+ \u251C\u2500\u2500 docker-compose.yml
512
+ \u251C\u2500\u2500 Makefile
513
+ \u2514\u2500\u2500 README.md
514
+ \`\`\`
515
+
516
+ ## Available commands
517
+
518
+ | Command | Description |
519
+ |---------|-------------|
520
+ | \`make dev\` | Hot reload via [air](https://github.com/air-verse/air) |
521
+ | \`make run\` | Run without hot reload |
522
+ | \`make build\` | Binary with version ldflags |
523
+ | \`make test\` | Run tests with race detector |
524
+ | \`make cover\` | HTML coverage report |
525
+ | \`make docs\` | Re-generate Swagger JSON (needs \`swag\`) |
526
+ | \`make lint\` | golangci-lint |
527
+ | \`make fmt\` | gofmt |
528
+ | \`make tidy\` | go mod tidy |
529
+ | \`make docker-up\` | Build & run via Docker Compose |
530
+ | \`make docker-down\` | Stop |
531
+
532
+ ## License
533
+
534
+ ${e.app_version} \xB7 ${e.author}
535
+ `}function k(){return `package middleware
536
+
537
+ import (
538
+ "crypto/rand"
539
+ "encoding/hex"
540
+ "log/slog"
541
+ "time"
542
+
543
+ "github.com/gin-gonic/gin"
544
+ )
545
+
546
+ const headerRequestID = "X-Request-ID"
547
+
548
+ // RequestID injects a unique identifier into every request.
549
+ // If the caller sends an X-Request-ID header it is reused; otherwise a new one
550
+ // is generated and written back in the response.
551
+ func RequestID() gin.HandlerFunc {
552
+ return func(c *gin.Context) {
553
+ id := c.GetHeader(headerRequestID)
554
+ if id == "" {
555
+ id = newID()
556
+ }
557
+ c.Set(headerRequestID, id)
558
+ c.Header(headerRequestID, id)
559
+ c.Next()
560
+ }
561
+ }
562
+
563
+ // Logger emits a structured JSON log line after each request.
564
+ func Logger() gin.HandlerFunc {
565
+ return func(c *gin.Context) {
566
+ start := time.Now()
567
+ c.Next()
568
+ slog.Info("http",
569
+ "method", c.Request.Method,
570
+ "path", c.Request.URL.Path,
571
+ "status", c.Writer.Status(),
572
+ "bytes", c.Writer.Size(),
573
+ "latency_ms", time.Since(start).Milliseconds(),
574
+ "ip", c.ClientIP(),
575
+ "request_id", c.GetString(headerRequestID),
576
+ )
577
+ }
578
+ }
579
+
580
+ func newID() string {
581
+ b := make([]byte, 8)
582
+ if _, err := rand.Read(b); err != nil {
583
+ return "unknown"
584
+ }
585
+ return hex.EncodeToString(b)
586
+ }
587
+ `}function A(e){return `package middleware_test
588
+
589
+ import (
590
+ "net/http"
591
+ "net/http/httptest"
592
+ "testing"
593
+
594
+ "github.com/gin-gonic/gin"
595
+
596
+ "${e.module_path}/internal/middleware"
597
+ )
598
+
599
+ func init() { gin.SetMode(gin.TestMode) }
600
+
601
+ func newTestRouter() *gin.Engine {
602
+ r := gin.New()
603
+ r.Use(middleware.RequestID())
604
+ r.Use(middleware.Logger())
605
+ r.GET("/ping", func(c *gin.Context) {
606
+ c.String(http.StatusOK, "pong")
607
+ })
608
+ return r
609
+ }
610
+
611
+ func TestRequestID_IsGenerated(t *testing.T) {
612
+ w := httptest.NewRecorder()
613
+ req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
614
+ newTestRouter().ServeHTTP(w, req)
615
+
616
+ id := w.Header().Get("X-Request-ID")
617
+ if id == "" {
618
+ t.Fatal("expected X-Request-ID header to be set")
619
+ }
620
+ if len(id) != 16 { // 8 random bytes \u2192 16 hex chars
621
+ t.Fatalf("unexpected request ID length %d, want 16", len(id))
622
+ }
623
+ }
624
+
625
+ func TestRequestID_IsReused(t *testing.T) {
626
+ w := httptest.NewRecorder()
627
+ req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
628
+ req.Header.Set("X-Request-ID", "my-trace-id")
629
+ newTestRouter().ServeHTTP(w, req)
630
+
631
+ id := w.Header().Get("X-Request-ID")
632
+ if id != "my-trace-id" {
633
+ t.Fatalf("expected X-Request-ID to be reused, got %q", id)
634
+ }
635
+ }
636
+ `}function P(){return `// Package apierr provides a consistent JSON error envelope for all API responses.
637
+ //
638
+ // Every error response looks like:
639
+ //
640
+ // {"error": "user not found", "code": "NOT_FOUND", "request_id": "a1b2c3d4..."}
641
+ package apierr
642
+
643
+ import (
644
+ "net/http"
645
+
646
+ "github.com/gin-gonic/gin"
647
+ )
648
+
649
+ // Response is the standard error envelope returned by all API endpoints.
650
+ type Response struct {
651
+ Error string \`json:"error"\`
652
+ Code string \`json:"code"\`
653
+ RequestID string \`json:"request_id,omitempty"\`
654
+ }
655
+
656
+ func reply(c *gin.Context, status int, msg, code string) {
657
+ c.AbortWithStatusJSON(status, Response{
658
+ Error: msg,
659
+ Code: code,
660
+ RequestID: c.GetString("X-Request-ID"),
661
+ })
662
+ }
663
+
664
+ // BadRequest responds with 400 and code "BAD_REQUEST".
665
+ func BadRequest(c *gin.Context, msg string) {
666
+ reply(c, http.StatusBadRequest, msg, "BAD_REQUEST")
667
+ }
668
+
669
+ // NotFound responds with 404 and code "NOT_FOUND".
670
+ func NotFound(c *gin.Context, msg string) {
671
+ reply(c, http.StatusNotFound, msg, "NOT_FOUND")
672
+ }
673
+
674
+ // Unauthorized responds with 401 and code "UNAUTHORIZED".
675
+ func Unauthorized(c *gin.Context) {
676
+ reply(c, http.StatusUnauthorized, "authentication required", "UNAUTHORIZED")
677
+ }
678
+
679
+ // Forbidden responds with 403 and code "FORBIDDEN".
680
+ func Forbidden(c *gin.Context) {
681
+ reply(c, http.StatusForbidden, "access denied", "FORBIDDEN")
682
+ }
683
+
684
+ // MethodNotAllowed responds with 405 and code "METHOD_NOT_ALLOWED".
685
+ func MethodNotAllowed(c *gin.Context) {
686
+ reply(c, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
687
+ }
688
+
689
+ // InternalError responds with 500 and code "INTERNAL_ERROR".
690
+ // The original error is intentionally not exposed to the client.
691
+ func InternalError(c *gin.Context, _ error) {
692
+ reply(c, http.StatusInternalServerError, "an internal error occurred", "INTERNAL_ERROR")
693
+ }
694
+
695
+ // TooManyRequests responds with 429 and code "TOO_MANY_REQUESTS".
696
+ func TooManyRequests(c *gin.Context) {
697
+ reply(c, http.StatusTooManyRequests, "rate limit exceeded", "TOO_MANY_REQUESTS")
698
+ }
699
+ `}function D(e){return `package apierr_test
700
+
701
+ import (
702
+ "encoding/json"
703
+ "net/http"
704
+ "net/http/httptest"
705
+ "testing"
706
+
707
+ "github.com/gin-gonic/gin"
708
+
709
+ "${e.module_path}/internal/apierr"
710
+ )
711
+
712
+ func init() { gin.SetMode(gin.TestMode) }
713
+
714
+ func makeRouter(fn gin.HandlerFunc) *gin.Engine {
715
+ r := gin.New()
716
+ r.GET("/test", fn)
717
+ return r
718
+ }
719
+
720
+ func readJSON(t *testing.T, w *httptest.ResponseRecorder) apierr.Response {
721
+ t.Helper()
722
+ var out apierr.Response
723
+ if err := json.NewDecoder(w.Body).Decode(&out); err != nil {
724
+ t.Fatalf("invalid JSON: %v", err)
725
+ }
726
+ return out
727
+ }
728
+
729
+ func TestBadRequest(t *testing.T) {
730
+ w := httptest.NewRecorder()
731
+ req, _ := http.NewRequest(http.MethodGet, "/test", nil)
732
+ makeRouter(func(c *gin.Context) { apierr.BadRequest(c, "invalid email") }).ServeHTTP(w, req)
733
+
734
+ if w.Code != http.StatusBadRequest {
735
+ t.Fatalf("expected 400, got %d", w.Code)
736
+ }
737
+ body := readJSON(t, w)
738
+ if body.Code != "BAD_REQUEST" {
739
+ t.Fatalf("expected BAD_REQUEST, got %q", body.Code)
740
+ }
741
+ if body.Error != "invalid email" {
742
+ t.Fatalf("unexpected error message: %q", body.Error)
743
+ }
744
+ }
745
+
746
+ func TestNotFound(t *testing.T) {
747
+ w := httptest.NewRecorder()
748
+ req, _ := http.NewRequest(http.MethodGet, "/test", nil)
749
+ makeRouter(func(c *gin.Context) { apierr.NotFound(c, "user not found") }).ServeHTTP(w, req)
750
+
751
+ if w.Code != http.StatusNotFound {
752
+ t.Fatalf("expected 404, got %d", w.Code)
753
+ }
754
+ body := readJSON(t, w)
755
+ if body.Code != "NOT_FOUND" {
756
+ t.Fatalf("expected NOT_FOUND, got %q", body.Code)
757
+ }
758
+ }
759
+
760
+ func TestUnauthorized(t *testing.T) {
761
+ w := httptest.NewRecorder()
762
+ req, _ := http.NewRequest(http.MethodGet, "/test", nil)
763
+ makeRouter(func(c *gin.Context) { apierr.Unauthorized(c) }).ServeHTTP(w, req)
764
+
765
+ if w.Code != http.StatusUnauthorized {
766
+ t.Fatalf("expected 401, got %d", w.Code)
767
+ }
768
+ }
769
+
770
+ func TestForbidden(t *testing.T) {
771
+ w := httptest.NewRecorder()
772
+ req, _ := http.NewRequest(http.MethodGet, "/test", nil)
773
+ makeRouter(func(c *gin.Context) { apierr.Forbidden(c) }).ServeHTTP(w, req)
774
+
775
+ if w.Code != http.StatusForbidden {
776
+ t.Fatalf("expected 403, got %d", w.Code)
777
+ }
778
+ body := readJSON(t, w)
779
+ if body.Code != "FORBIDDEN" {
780
+ t.Fatalf("expected FORBIDDEN, got %q", body.Code)
781
+ }
782
+ }
783
+
784
+ func TestMethodNotAllowed(t *testing.T) {
785
+ w := httptest.NewRecorder()
786
+ req, _ := http.NewRequest(http.MethodGet, "/test", nil)
787
+ makeRouter(func(c *gin.Context) { apierr.MethodNotAllowed(c) }).ServeHTTP(w, req)
788
+
789
+ if w.Code != http.StatusMethodNotAllowed {
790
+ t.Fatalf("expected 405, got %d", w.Code)
791
+ }
792
+ body := readJSON(t, w)
793
+ if body.Code != "METHOD_NOT_ALLOWED" {
794
+ t.Fatalf("expected METHOD_NOT_ALLOWED, got %q", body.Code)
795
+ }
796
+ }
797
+
798
+ func TestInternalError(t *testing.T) {
799
+ w := httptest.NewRecorder()
800
+ req, _ := http.NewRequest(http.MethodGet, "/test", nil)
801
+ makeRouter(func(c *gin.Context) { apierr.InternalError(c, nil) }).ServeHTTP(w, req)
802
+
803
+ if w.Code != http.StatusInternalServerError {
804
+ t.Fatalf("expected 500, got %d", w.Code)
805
+ }
806
+ body := readJSON(t, w)
807
+ if body.Code != "INTERNAL_ERROR" {
808
+ t.Fatalf("expected INTERNAL_ERROR, got %q", body.Code)
809
+ }
810
+ }
811
+
812
+ func TestTooManyRequests(t *testing.T) {
813
+ w := httptest.NewRecorder()
814
+ req, _ := http.NewRequest(http.MethodGet, "/test", nil)
815
+ makeRouter(func(c *gin.Context) { apierr.TooManyRequests(c) }).ServeHTTP(w, req)
816
+
817
+ if w.Code != http.StatusTooManyRequests {
818
+ t.Fatalf("expected 429, got %d", w.Code)
819
+ }
820
+ body := readJSON(t, w)
821
+ if body.Code != "TOO_MANY_REQUESTS" {
822
+ t.Fatalf("expected TOO_MANY_REQUESTS, got %q", body.Code)
823
+ }
824
+ }
825
+ `}function H(e){return `// Package docs provides the swaggo-generated OpenAPI specification.
826
+ //
827
+ // Run \`make docs\` to regenerate after changing handler annotations.
828
+ //
829
+ // @title ${b$2(e.project_name)} API
830
+ // @version ${e.app_version}
831
+ // @description ${e.description}
832
+ // @host localhost:${e.port}
833
+ // @BasePath /
834
+ // @schemes http https
835
+ //
836
+ // @contact.name ${e.author}
837
+ // @license.name MIT
838
+ package docs
839
+ `}function F(e){return `package handlers
840
+
841
+ import (
842
+ "net/http"
843
+
844
+ "github.com/gin-gonic/gin"
845
+
846
+ "${e.module_path}/internal/apierr"
847
+ )
848
+
849
+ // EchoResponse is the JSON body returned by EchoParams.
850
+ type EchoResponse struct {
851
+ Name string \`json:"name"\`
852
+ RequestID string \`json:"request_id"\`
853
+ }
854
+
855
+ // EchoParams is an example handler demonstrating how to:
856
+ // - read URL path parameters
857
+ // - use apierr for consistent JSON error responses
858
+ // - access the request ID injected by RequestID middleware
859
+ //
860
+ // Replace or remove this file once you add your own business logic.
861
+ //
862
+ // @Summary Echo path parameter
863
+ // @Description Returns the :name path parameter together with the request ID.
864
+ // @Tags example
865
+ // @Produce json
866
+ // @Param name path string true "Name to echo"
867
+ // @Success 200 {object} handlers.EchoResponse
868
+ // @Failure 400 {object} apierr.Response
869
+ // @Router /api/v1/echo/{name} [get]
870
+ func EchoParams(c *gin.Context) {
871
+ name := c.Param("name")
872
+ if name == "" {
873
+ apierr.BadRequest(c, "name parameter is required")
874
+ return
875
+ }
876
+ c.JSON(http.StatusOK, EchoResponse{
877
+ Name: name,
878
+ RequestID: c.GetString("X-Request-ID"),
879
+ })
880
+ }
881
+ `}function $(e){return `package handlers_test
882
+
883
+ import (
884
+ "encoding/json"
885
+ "net/http"
886
+ "net/http/httptest"
887
+ "testing"
888
+
889
+ "github.com/gin-gonic/gin"
890
+
891
+ "${e.module_path}/internal/handlers"
892
+ "${e.module_path}/internal/middleware"
893
+ )
894
+
895
+ func newEchoRouter() *gin.Engine {
896
+ r := gin.New()
897
+ r.Use(middleware.RequestID())
898
+ r.GET("/echo/:name", handlers.EchoParams)
899
+ return r
900
+ }
901
+
902
+ func TestEchoParams_Success(t *testing.T) {
903
+ w := httptest.NewRecorder()
904
+ req, _ := http.NewRequest(http.MethodGet, "/echo/alice", nil)
905
+ newEchoRouter().ServeHTTP(w, req)
906
+
907
+ if w.Code != http.StatusOK {
908
+ t.Fatalf("expected 200, got %d", w.Code)
909
+ }
910
+
911
+ var body map[string]any
912
+ if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
913
+ t.Fatalf("invalid JSON: %v", err)
914
+ }
915
+ if body["name"] != "alice" {
916
+ t.Fatalf("expected name=alice, got %v", body["name"])
917
+ }
918
+ if body["request_id"] == nil || body["request_id"] == "" {
919
+ t.Fatal("expected request_id to be set by RequestID middleware")
920
+ }
921
+ }
922
+
923
+ // TestEchoParams_EmptyName registers EchoParams on a param-free route so that
924
+ // c.Param("name") returns "" and the 400 guard executes.
925
+ func TestEchoParams_EmptyName(t *testing.T) {
926
+ gin.SetMode(gin.TestMode)
927
+ r := gin.New()
928
+ r.Use(middleware.RequestID())
929
+ r.GET("/echo-bare", handlers.EchoParams)
930
+
931
+ w := httptest.NewRecorder()
932
+ req, _ := http.NewRequest(http.MethodGet, "/echo-bare", nil)
933
+ r.ServeHTTP(w, req)
934
+
935
+ if w.Code != http.StatusBadRequest {
936
+ t.Fatalf("expected 400, got %d", w.Code)
937
+ }
938
+ var body map[string]any
939
+ if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
940
+ t.Fatalf("invalid JSON: %v", err)
941
+ }
942
+ if body["code"] != "BAD_REQUEST" {
943
+ t.Fatalf("expected code=BAD_REQUEST, got %v", body["code"])
944
+ }
945
+ }
946
+ `}function j(e){return `package config_test
947
+
948
+ import (
949
+ "log/slog"
950
+ "testing"
951
+
952
+ "${e.module_path}/internal/config"
953
+ )
954
+
955
+ func TestParseLogLevel(t *testing.T) {
956
+ tests := []struct {
957
+ input string
958
+ want slog.Level
959
+ }{
960
+ {"debug", slog.LevelDebug},
961
+ {"DEBUG", slog.LevelDebug},
962
+ {"warn", slog.LevelWarn},
963
+ {"warning", slog.LevelWarn},
964
+ {"error", slog.LevelError},
965
+ {"info", slog.LevelInfo},
966
+ {"", slog.LevelInfo},
967
+ {"unknown", slog.LevelInfo},
968
+ }
969
+ for _, tc := range tests {
970
+ got := config.ParseLogLevel(tc.input)
971
+ if got != tc.want {
972
+ t.Errorf("ParseLogLevel(%q) = %v, want %v", tc.input, got, tc.want)
973
+ }
974
+ }
975
+ }
976
+
977
+ func TestLoad_EnvOverride(t *testing.T) {
978
+ t.Setenv("PORT", "9090")
979
+ t.Setenv("APP_ENV", "production")
980
+ t.Setenv("LOG_LEVEL", "warn")
981
+ t.Setenv("GIN_MODE", "release")
982
+
983
+ cfg := config.Load()
984
+
985
+ if cfg.Port != "9090" {
986
+ t.Errorf("expected Port=9090, got %q", cfg.Port)
987
+ }
988
+ if cfg.Env != "production" {
989
+ t.Errorf("expected Env=production, got %q", cfg.Env)
990
+ }
991
+ if cfg.LogLevel != "warn" {
992
+ t.Errorf("expected LogLevel=warn, got %q", cfg.LogLevel)
993
+ }
994
+ if cfg.GinMode != "release" {
995
+ t.Errorf("expected GinMode=release, got %q", cfg.GinMode)
996
+ }
997
+ }
998
+
999
+ func TestLoad_Defaults(t *testing.T) {
1000
+ // Empty string forces getEnv() to return the built-in fallback value.
1001
+ t.Setenv("PORT", "")
1002
+ t.Setenv("APP_ENV", "")
1003
+ t.Setenv("LOG_LEVEL", "")
1004
+ t.Setenv("GIN_MODE", "")
1005
+
1006
+ cfg := config.Load()
1007
+
1008
+ if cfg.Port != "${e.port}" {
1009
+ t.Errorf("expected default Port=${e.port}, got %q", cfg.Port)
1010
+ }
1011
+ if cfg.Env != "development" {
1012
+ t.Errorf("expected default Env=development, got %q", cfg.Env)
1013
+ }
1014
+ // APP_ENV="" \u2192 fallback "development" \u2192 defaultLogLevel \u2192 "debug"
1015
+ if cfg.LogLevel != "debug" {
1016
+ t.Errorf("expected default LogLevel=debug (development env), got %q", cfg.LogLevel)
1017
+ }
1018
+ if cfg.GinMode != "debug" {
1019
+ t.Errorf("expected default GinMode=debug, got %q", cfg.GinMode)
1020
+ }
1021
+ }
1022
+ `}function U(){return `package middleware
1023
+
1024
+ import (
1025
+ "net/http"
1026
+ "os"
1027
+ "strings"
1028
+
1029
+ "github.com/gin-gonic/gin"
1030
+ )
1031
+
1032
+ // CORS returns a Gin middleware configured via CORS_ALLOW_ORIGINS env var.
1033
+ //
1034
+ // Set CORS_ALLOW_ORIGINS="*" for development (the default when unset).
1035
+ // In production supply a comma-separated list of allowed origins:
1036
+ //
1037
+ // CORS_ALLOW_ORIGINS=https://app.example.com,https://admin.example.com
1038
+ func CORS() gin.HandlerFunc {
1039
+ allowed := os.Getenv("CORS_ALLOW_ORIGINS")
1040
+ if allowed == "" {
1041
+ allowed = "*"
1042
+ }
1043
+ allowAll := allowed == "*"
1044
+
1045
+ return func(c *gin.Context) {
1046
+ origin := c.Request.Header.Get("Origin")
1047
+ if allowAll {
1048
+ c.Header("Access-Control-Allow-Origin", "*")
1049
+ } else if origin != "" {
1050
+ for _, o := range strings.Split(allowed, ",") {
1051
+ if strings.TrimSpace(o) == origin {
1052
+ c.Header("Access-Control-Allow-Origin", origin)
1053
+ c.Header("Vary", "Origin")
1054
+ break
1055
+ }
1056
+ }
1057
+ }
1058
+ c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
1059
+ c.Header("Access-Control-Allow-Headers", "Origin,Content-Type,Authorization,X-Request-ID")
1060
+ c.Header("Access-Control-Expose-Headers", "X-Request-ID")
1061
+ c.Header("Access-Control-Max-Age", "600")
1062
+
1063
+ if c.Request.Method == http.MethodOptions {
1064
+ c.AbortWithStatus(http.StatusNoContent)
1065
+ return
1066
+ }
1067
+ c.Next()
1068
+ }
1069
+ }
1070
+ `}function B(e){return `package middleware_test
1071
+
1072
+ import (
1073
+ "net/http"
1074
+ "net/http/httptest"
1075
+ "testing"
1076
+
1077
+ "github.com/gin-gonic/gin"
1078
+
1079
+ "${e.module_path}/internal/middleware"
1080
+ )
1081
+
1082
+ func newCORSRouter(t *testing.T) *gin.Engine {
1083
+ t.Helper()
1084
+ gin.SetMode(gin.TestMode)
1085
+ r := gin.New()
1086
+ r.Use(middleware.CORS())
1087
+ r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
1088
+ return r
1089
+ }
1090
+
1091
+ func TestCORS_Wildcard(t *testing.T) {
1092
+ t.Setenv("CORS_ALLOW_ORIGINS", "*")
1093
+ w := httptest.NewRecorder()
1094
+ req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
1095
+ req.Header.Set("Origin", "https://example.com")
1096
+ newCORSRouter(t).ServeHTTP(w, req)
1097
+
1098
+ if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
1099
+ t.Fatalf("expected ACAO=*, got %q", got)
1100
+ }
1101
+ }
1102
+
1103
+ func TestCORS_Preflight(t *testing.T) {
1104
+ t.Setenv("CORS_ALLOW_ORIGINS", "*")
1105
+ gin.SetMode(gin.TestMode)
1106
+ r := gin.New()
1107
+ r.Use(middleware.CORS())
1108
+
1109
+ w := httptest.NewRecorder()
1110
+ req, _ := http.NewRequest(http.MethodOptions, "/ping", nil)
1111
+ req.Header.Set("Origin", "https://example.com")
1112
+ req.Header.Set("Access-Control-Request-Method", "POST")
1113
+ r.ServeHTTP(w, req)
1114
+
1115
+ if w.Code != http.StatusNoContent {
1116
+ t.Fatalf("expected 204 preflight, got %d", w.Code)
1117
+ }
1118
+ }
1119
+
1120
+ func TestCORS_SpecificOrigin_Allowed(t *testing.T) {
1121
+ t.Setenv("CORS_ALLOW_ORIGINS", "https://app.example.com")
1122
+ w := httptest.NewRecorder()
1123
+ req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
1124
+ req.Header.Set("Origin", "https://app.example.com")
1125
+ newCORSRouter(t).ServeHTTP(w, req)
1126
+
1127
+ if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
1128
+ t.Fatalf("expected ACAO=https://app.example.com, got %q", got)
1129
+ }
1130
+ }
1131
+
1132
+ func TestCORS_SpecificOrigin_Denied(t *testing.T) {
1133
+ t.Setenv("CORS_ALLOW_ORIGINS", "https://app.example.com")
1134
+ w := httptest.NewRecorder()
1135
+ req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
1136
+ req.Header.Set("Origin", "https://evil.com")
1137
+ newCORSRouter(t).ServeHTTP(w, req)
1138
+
1139
+ if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
1140
+ t.Fatalf("expected no ACAO header for denied origin, got %q", got)
1141
+ }
1142
+ }
1143
+
1144
+ func TestCORS_Default_Origin(t *testing.T) {
1145
+ // When CORS_ALLOW_ORIGINS is unset the middleware must default to "*".
1146
+ t.Setenv("CORS_ALLOW_ORIGINS", "")
1147
+ w := httptest.NewRecorder()
1148
+ req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
1149
+ req.Header.Set("Origin", "https://anywhere.com")
1150
+ newCORSRouter(t).ServeHTTP(w, req)
1151
+
1152
+ if got := w.Header().Get("Access-Control-Allow-Origin"); got == "" {
1153
+ t.Fatal("expected CORS header when CORS_ALLOW_ORIGINS defaults to *")
1154
+ }
1155
+ }
1156
+ `}function V(e){return `package server_test
1157
+
1158
+ import (
1159
+ "encoding/json"
1160
+ "net/http"
1161
+ "net/http/httptest"
1162
+ "testing"
1163
+
1164
+ "github.com/gin-gonic/gin"
1165
+
1166
+ "${e.module_path}/internal/config"
1167
+ "${e.module_path}/internal/server"
1168
+ )
1169
+
1170
+ func init() { gin.SetMode(gin.TestMode) }
1171
+
1172
+ type serverAPIError struct {
1173
+ Code string \`json:"code"\`
1174
+ Message string \`json:"message"\`
1175
+ }
1176
+
1177
+ func TestServer_NotFound_JSON(t *testing.T) {
1178
+ w := httptest.NewRecorder()
1179
+ req, _ := http.NewRequest(http.MethodGet, "/no-such-route", nil)
1180
+ server.NewRouter(config.Load()).ServeHTTP(w, req)
1181
+
1182
+ if w.Code != http.StatusNotFound {
1183
+ t.Fatalf("expected 404, got %d", w.Code)
1184
+ }
1185
+ var body serverAPIError
1186
+ if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
1187
+ t.Fatalf("expected JSON error body: %v", err)
1188
+ }
1189
+ if body.Code != "NOT_FOUND" {
1190
+ t.Fatalf("expected code=NOT_FOUND, got %q", body.Code)
1191
+ }
1192
+ }
1193
+
1194
+ func TestServer_MethodNotAllowed_JSON(t *testing.T) {
1195
+ w := httptest.NewRecorder()
1196
+ req, _ := http.NewRequest(http.MethodPost, "/api/v1/health/live", nil)
1197
+ server.NewRouter(config.Load()).ServeHTTP(w, req)
1198
+
1199
+ if w.Code != http.StatusMethodNotAllowed {
1200
+ t.Fatalf("expected 405, got %d", w.Code)
1201
+ }
1202
+ var body serverAPIError
1203
+ if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
1204
+ t.Fatalf("expected JSON error body: %v", err)
1205
+ }
1206
+ if body.Code != "METHOD_NOT_ALLOWED" {
1207
+ t.Fatalf("expected code=METHOD_NOT_ALLOWED, got %q", body.Code)
1208
+ }
1209
+ }
1210
+
1211
+ func TestServer_CORS_Header(t *testing.T) {
1212
+ t.Setenv("CORS_ALLOW_ORIGINS", "*")
1213
+ w := httptest.NewRecorder()
1214
+ req, _ := http.NewRequest(http.MethodGet, "/api/v1/health/live", nil)
1215
+ req.Header.Set("Origin", "https://example.com")
1216
+ server.NewRouter(config.Load()).ServeHTTP(w, req)
1217
+
1218
+ if got := w.Header().Get("Access-Control-Allow-Origin"); got == "" {
1219
+ t.Fatal("expected Access-Control-Allow-Origin header to be set")
1220
+ }
1221
+ }
1222
+
1223
+ func TestServer_HealthLive(t *testing.T) {
1224
+ w := httptest.NewRecorder()
1225
+ req, _ := http.NewRequest(http.MethodGet, "/api/v1/health/live", nil)
1226
+ server.NewRouter(config.Load()).ServeHTTP(w, req)
1227
+
1228
+ if w.Code != http.StatusOK {
1229
+ t.Fatalf("expected 200, got %d", w.Code)
1230
+ }
1231
+ var body map[string]any
1232
+ if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
1233
+ t.Fatalf("invalid JSON: %v", err)
1234
+ }
1235
+ if body["status"] != "ok" {
1236
+ t.Fatalf("expected status=ok, got %v", body["status"])
1237
+ }
1238
+ }
1239
+
1240
+ func TestServer_Docs_Redirect(t *testing.T) {
1241
+ w := httptest.NewRecorder()
1242
+ req, _ := http.NewRequest(http.MethodGet, "/docs", nil)
1243
+ server.NewRouter(config.Load()).ServeHTTP(w, req)
1244
+
1245
+ if w.Code != http.StatusFound {
1246
+ t.Fatalf("expected 302 redirect from /docs, got %d", w.Code)
1247
+ }
1248
+ if loc := w.Header().Get("Location"); loc != "/docs/index.html" {
1249
+ t.Fatalf("expected Location=/docs/index.html, got %q", loc)
1250
+ }
1251
+ }
1252
+
1253
+ func TestServer_ReleaseMode(t *testing.T) {
1254
+ // Covers the gin.SetMode(gin.ReleaseMode) branch in NewRouter.
1255
+ // Restore test mode after so other tests are not affected.
1256
+ t.Cleanup(func() { gin.SetMode(gin.TestMode) })
1257
+ t.Setenv("GIN_MODE", "release")
1258
+ w := httptest.NewRecorder()
1259
+ req, _ := http.NewRequest(http.MethodGet, "/api/v1/health/live", nil)
1260
+ server.NewRouter(config.Load()).ServeHTTP(w, req)
1261
+
1262
+ if w.Code != http.StatusOK {
1263
+ t.Fatalf("expected 200 in release mode, got %d", w.Code)
1264
+ }
1265
+ }
1266
+ `}function W(e){return `package middleware
1267
+
1268
+ import (
1269
+ "os"
1270
+ "strconv"
1271
+ "sync"
1272
+ "time"
1273
+
1274
+ "github.com/gin-gonic/gin"
1275
+
1276
+ "${e.module_path}/internal/apierr"
1277
+ )
1278
+
1279
+ // ipBucket tracks the per-IP fixed-window request counter.
1280
+ type ipBucket struct {
1281
+ mu sync.Mutex
1282
+ count int
1283
+ windowStart time.Time
1284
+ }
1285
+
1286
+ // RateLimit returns a per-IP fixed-window rate limiter.
1287
+ // Configure the limit via RATE_LIMIT_RPS env var (requests per second, default 100).
1288
+ func RateLimit() gin.HandlerFunc {
1289
+ rps := 100
1290
+ if raw := os.Getenv("RATE_LIMIT_RPS"); raw != "" {
1291
+ if n, err := strconv.Atoi(raw); err == nil && n > 0 {
1292
+ rps = n
1293
+ }
1294
+ }
1295
+ var buckets sync.Map
1296
+ return func(c *gin.Context) {
1297
+ ip := c.ClientIP()
1298
+ now := time.Now()
1299
+ v, _ := buckets.LoadOrStore(ip, &ipBucket{windowStart: now})
1300
+ b := v.(*ipBucket)
1301
+ b.mu.Lock()
1302
+ if now.Sub(b.windowStart) >= time.Second {
1303
+ b.count = 0
1304
+ b.windowStart = now
1305
+ }
1306
+ b.count++
1307
+ count := b.count
1308
+ b.mu.Unlock()
1309
+ if count > rps {
1310
+ apierr.TooManyRequests(c)
1311
+ c.Header("Retry-After", "1")
1312
+ return
1313
+ }
1314
+ c.Next()
1315
+ }
1316
+ }
1317
+ `}function J(e){return `package middleware_test
1318
+
1319
+ import (
1320
+ "net/http"
1321
+ "net/http/httptest"
1322
+ "testing"
1323
+
1324
+ "github.com/gin-gonic/gin"
1325
+
1326
+ "${e.module_path}/internal/middleware"
1327
+ )
1328
+
1329
+ func newRateLimitRouter(t *testing.T) *gin.Engine {
1330
+ t.Helper()
1331
+ gin.SetMode(gin.TestMode)
1332
+ r := gin.New()
1333
+ r.Use(middleware.RateLimit())
1334
+ r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
1335
+ return r
1336
+ }
1337
+
1338
+ func TestRateLimit_AllowsUnderLimit(t *testing.T) {
1339
+ t.Setenv("RATE_LIMIT_RPS", "3")
1340
+ r := newRateLimitRouter(t)
1341
+
1342
+ for i := 0; i < 3; i++ {
1343
+ w := httptest.NewRecorder()
1344
+ req, _ := http.NewRequest(http.MethodGet, "/", nil)
1345
+ r.ServeHTTP(w, req)
1346
+ if w.Code != http.StatusOK {
1347
+ t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
1348
+ }
1349
+ }
1350
+ }
1351
+
1352
+ func TestRateLimit_Blocks_After_Limit(t *testing.T) {
1353
+ t.Setenv("RATE_LIMIT_RPS", "2")
1354
+ r := newRateLimitRouter(t)
1355
+
1356
+ // Exhaust the limit.
1357
+ for i := 0; i < 2; i++ {
1358
+ w := httptest.NewRecorder()
1359
+ req, _ := http.NewRequest(http.MethodGet, "/", nil)
1360
+ r.ServeHTTP(w, req)
1361
+ }
1362
+
1363
+ // Next request must be rejected.
1364
+ w := httptest.NewRecorder()
1365
+ req, _ := http.NewRequest(http.MethodGet, "/", nil)
1366
+ r.ServeHTTP(w, req)
1367
+
1368
+ if w.Code != http.StatusTooManyRequests {
1369
+ t.Fatalf("expected 429 after limit, got %d", w.Code)
1370
+ }
1371
+ }
1372
+
1373
+ func TestRateLimit_InvalidRPS(t *testing.T) {
1374
+ // When RATE_LIMIT_RPS is not a valid positive integer, the middleware
1375
+ // must fall back to the default limit (100 rps) and allow normal requests.
1376
+ t.Setenv("RATE_LIMIT_RPS", "not-a-number")
1377
+ r := newRateLimitRouter(t)
1378
+ w := httptest.NewRecorder()
1379
+ req, _ := http.NewRequest(http.MethodGet, "/", nil)
1380
+ r.ServeHTTP(w, req)
1381
+
1382
+ if w.Code != http.StatusOK {
1383
+ t.Fatalf("expected 200 with invalid RPS env, got %d", w.Code)
1384
+ }
1385
+ }
1386
+ `}function K(e){return `# Air \u2014 live reload for Go projects
1387
+ # https://github.com/air-verse/air
1388
+ root = "."
1389
+ tmp_dir = "tmp"
1390
+
1391
+ [build]
1392
+ pre_cmd = ["$(go env GOPATH)/bin/swag init -g main.go -d cmd/server,internal/handlers,internal/apierr -o docs --parseDependency 2>/dev/null || true"]
1393
+ cmd = "go build -o ./tmp/server ./cmd/server"
1394
+ bin = "./tmp/server"
1395
+ include_ext = ["go", "yaml", "yml", "env"]
1396
+ exclude_dir = ["tmp", "vendor", ".git", "testdata", "docs"]
1397
+ delay = 500
1398
+ rerun_delay = 500
1399
+ send_interrupt = true
1400
+ kill_delay = "200ms"
1401
+
1402
+ [env]
1403
+ GIN_MODE = "debug"
1404
+ PORT = "${e.port}"
1405
+
1406
+ [misc]
1407
+ clean_on_exit = true
1408
+
1409
+ [log]
1410
+ time = false
1411
+ `}function X(e){return `run:
1412
+ timeout: 5m
1413
+
1414
+ linters:
1415
+ enable:
1416
+ - bodyclose
1417
+ - durationcheck
1418
+ - errcheck
1419
+ - errname
1420
+ - errorlint
1421
+ - gci
1422
+ - goimports
1423
+ - gosimple
1424
+ - govet
1425
+ - ineffassign
1426
+ - misspell
1427
+ - noctx
1428
+ - nolintlint
1429
+ - prealloc
1430
+ - staticcheck
1431
+ - unconvert
1432
+ - unused
1433
+ - wrapcheck
1434
+
1435
+ linters-settings:
1436
+ gci:
1437
+ sections:
1438
+ - standard
1439
+ - default
1440
+ - prefix(${e})
1441
+ goimports:
1442
+ local-prefixes: "${e}"
1443
+ govet:
1444
+ enable:
1445
+ - shadow
1446
+ wrapcheck:
1447
+ ignorePackageGlobs:
1448
+ - "${e}/*"
1449
+
1450
+ issues:
1451
+ max-same-issues: 5
1452
+ exclude-rules:
1453
+ - path: _test.go
1454
+ linters:
1455
+ - errcheck
1456
+ - wrapcheck
1457
+ `}function Q(){return JSON.stringify({engine:"npm",runtime:"go"},null,2)}function z(e,n){return JSON.stringify({kit_name:"gogin.standard",runtime:"go",module_support:false,project_name:e.project_name,module_path:e.module_path,app_version:e.app_version,created_by:"rapidkit-npm",rapidkit_version:n,created_at:new Date().toISOString()},null,2)}function Y(e$1){return e({runtimeLabel:"Go/Gin",projectName:e$1.project_name,fallbackDevCommand:'cd "$SCRIPT_DIR" && GIN_MODE=debug exec go run ./cmd/server "$@"'})}function Z(e){return f({runtimeLabel:"Go/Gin",projectName:e.project_name})}async function ce(e,n){let r={project_name:n.project_name,module_path:n.module_path||n.project_name,author:n.author||"RapidKit User",description:n.description||`Go/Gin REST API \u2014 ${n.project_name}`,go_version:n.go_version||a,app_version:n.app_version||"0.1.0",port:n.port||"8080",skipGit:n.skipGit??false},f=b$1();try{await execa("go",["version"],{timeout:3e3});}catch{console.log(o.yellow("\n\u26A0 Go not found in PATH \u2014 project will be scaffolded, but `go mod tidy` requires Go 1.21+")),console.log(o.gray(` Install: https://go.dev/dl/
1458
+ `));}let i=_(`Generating Go/Gin project: ${r.project_name}\u2026`).start();try{let t=(v,S)=>c(d.join(e,v),S),w=d.join(e,"rapidkit"),R=d.join(e,"rapidkit.cmd");await Promise.all([t("cmd/server/main.go",T(r)),t("go.mod",O(r)),t("internal/config/config.go",N(r)),t("internal/server/server.go",b(r)),t("internal/middleware/requestid.go",k()),t("internal/middleware/requestid_test.go",A(r)),t("internal/apierr/apierr.go",P()),t("internal/apierr/apierr_test.go",D(r)),t("internal/handlers/health.go",G()),t("internal/handlers/health_test.go",q(r)),t("internal/handlers/example.go",F(r)),t("internal/handlers/example_test.go",$(r)),t("internal/config/config_test.go",j(r)),t("internal/middleware/cors.go",U()),t("internal/middleware/cors_test.go",B(r)),t("internal/middleware/ratelimit.go",W(r)),t("internal/middleware/ratelimit_test.go",J(r)),t("internal/server/server_test.go",V(r)),t("docs/doc.go",H(r)),t(".air.toml",K(r)),t("Dockerfile",E()),t("docker-compose.yml",L(r)),t("Makefile",C(r)),t(".golangci.yml",X(r.module_path)),t(".env.example",y(r)),t(".gitignore",I()),t(".github/workflows/ci.yml",x(r)),t("README.md",M(r)),t(".rapidkit/project.json",z(r,f)),t(".rapidkit/context.json",Q()),t("rapidkit",Y(r)),t("rapidkit.cmd",Z(r))]),await promises.chmod(w,493),await promises.chmod(R,493),i.succeed(o.green(`Project created at ${e}`));try{i.start("Fetching Go dependencies\u2026"),await execa("go",["mod","tidy"],{cwd:e,timeout:12e4}),i.succeed(o.gray("\u2713 go mod tidy completed"));}catch{i.warn(o.yellow("\u26A0 go mod tidy failed \u2014 run manually: go mod tidy"));}if(!r.skipGit)try{await execa("git",["init"],{cwd:e}),await execa("git",["add","-A"],{cwd:e}),await execa("git",["commit","-m","chore: initial scaffold (rapidkit gogin.standard)"],{cwd:e}),console.log(o.gray("\u2713 git repository initialized"));}catch{console.log(o.gray("\u26A0 git init skipped (git not found or error)"));}console.log(""),console.log(o.bold("\u2705 Go/Gin project ready!")),console.log(""),console.log(o.cyan("Next steps:")),console.log(o.white(` cd ${r.project_name}`)),console.log(o.white(" make run # start dev server")),console.log(o.white(" make test # run tests")),console.log(""),console.log(o.gray("Server will listen on port "+r.port)),console.log(o.gray(" http://localhost:"+r.port+"/api/v1/health/live")),console.log(o.gray(" http://localhost:"+r.port+"/api/v1/health/ready")),console.log(""),console.log(o.yellow("\u2139 RapidKit modules are not available for Go projects (module system uses Python/pip).")),console.log("");}catch(t){throw i.fail(o.red("Failed to generate Go/Gin project")),t}}
1459
+ export{ce as generateGoGinKit};