rapidkit 0.25.4 → 0.25.6

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