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.
- package/dist/chunk-Q7ULIFQA.js +5 -0
- package/dist/chunk-VM2TOHNX.js +2 -0
- package/dist/chunk-ZAZJEYYT.js +11 -0
- package/dist/create-27NVMJAR.js +776 -0
- package/dist/demo-kit-63CFMCPD.js +141 -0
- package/dist/doctor-P57TTWEP.js +38 -0
- package/dist/gofiber-standard-NQHMZTFK.js +1572 -0
- package/dist/gogin-standard-2G2C3VQL.js +1656 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +167 -3568
- package/dist/package.json +1 -1
- package/dist/{workspace-KRZ3DWL4.js → workspace-VXNLNKCM.js} +5 -5
- package/package.json +1 -1
- package/dist/chunk-EDH5S5JF.js +0 -6
- package/dist/chunk-KAV65WZO.js +0 -786
- package/dist/create-PIVSRLDS.js +0 -1
|
@@ -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};
|