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