Built-in Middleware in Go with NestGo
NestGo ships with 14 production-grade middleware in the github.com/ashrafAli23/nestgo/middleware package, covering everything from panic recovery and rate limiting to CSRF protection and gzip compression in Go. Every middleware works identically on the Gin and Fiber adapters, follows the same Xxx(config ...XxxConfig) pattern with sensible defaults, and returns a core.MiddlewareFunc you can apply globally or per route.
How to apply middleware in NestGo
Register middleware globally on the server, or attach them to a single route or group:
import (
core "github.com/ashrafAli23/nestgo/core"
"github.com/ashrafAli23/nestgo/middleware"
)
// Global — runs for every request
server.Use(middleware.Recovery())
server.Use(middleware.Logger())
// Per-route — pass as extra arguments after the handler
r.POST("/upload", handler, middleware.Timeout(middleware.TimeoutConfig{
Timeout: 60 * time.Second,
}))
Every middleware accepts zero or one config struct. Calling it with no arguments uses the defaults documented below. In a custom config, zero-valued string, numeric, and function fields fall back to the same defaults — but boolean fields (ETagConfig.Weak, CSRFConfig.CookieHTTPOnly, RateLimitConfig.Headers, TracingConfig.RecordDuration) and the Helmet/CORS header lists are taken as-is, so restate any you want to keep.
Recommended middleware stack for production
Order matters — Recovery must wrap everything, and correlation IDs should exist before the Logger reads them:
server.Use(middleware.Recovery()) // first: catch panics from everything below
server.Use(middleware.RequestID()) // correlation ID before logging
server.Use(middleware.Tracing()) // trace_id before logging
server.Use(middleware.Logger()) // logs pick up request_id + trace_id
server.Use(middleware.Helmet()) // security headers
server.Use(middleware.CORS(middleware.CORSConfig{
AllowOrigins: []string{"https://app.example.com"},
}))
server.Use(middleware.RateLimit())
server.Use(middleware.Timeout())
Middleware summary table
| Middleware | Purpose | Key default |
|---|---|---|
| Recovery | Recover from handler panics | Generic 500 response; panic details only logged |
| Logger | Log method, path, status, duration | Logs via core.Log() |
| RequestID | Attach a unique ID to every request | X-Request-ID header |
| Tracing | W3C trace context propagation | Reads traceparent, echoes X-Trace-ID |
| Helmet | OWASP security headers | 8 headers on by default |
| CORS | Cross-origin resource sharing | * origins (dev-friendly) |
| RateLimit | Fixed-window rate limiting | 100 req/min per client IP |
| Timeout | Deadline on the request context | 30s, 504 on expiry |
| BodyLimit | Reject oversized request bodies | 4MB, 413 on excess |
| CSRF | Double-submit cookie CSRF protection | _csrf cookie + X-CSRF-Token header |
| Compress | Gzip compression for JSON responses | Default level, 1KB minimum |
| ETag | ETag + If-None-Match → 304 | Weak ETags, SHA-256 based |
| Idempotency | Replay cached responses by key | Idempotency-Key, 24h TTL |
| Upload | Validate multipart file uploads | 10MB max, file field |
Recovery — Panic Recovery Middleware in Go
middleware.Recovery() catches panics in handlers and turns them into HTTP error responses instead of crashing the whole Go server. Register it first so it wraps every other middleware and handler.
If a handler panics with a *core.HTTPError, Recovery preserves its status code and message — those are intentional, client-facing errors. Every other panic value (including plain errors) becomes a generic 500 “Internal Server Error”: the panic message and stack are logged, never sent to the client, unless you opt in with ExposeError.
Defaults (DefaultRecoveryConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
StackSize | int | 8192 | Max stack trace size in bytes captured per panic |
EnableStackTrace | bool | false | Include the stack trace in the error response details |
ExposeError | bool | false | Include the panic value’s message in the client response — dev only; when false, clients get a generic “Internal Server Error” |
LogFunc | func(c core.Context, err interface{}, stack string) | logs via core.Log().Error | Called when a panic is recovered |
ErrorHandler | func(c core.Context, recovered interface{}, stack string) error | nil | If set, takes full control of the error response |
server.Use(middleware.Recovery())
// Custom error response format
server.Use(middleware.Recovery(middleware.RecoveryConfig{
ErrorHandler: func(c core.Context, recovered interface{}, stack string) error {
return c.JSON(500, map[string]any{
"error": "something went wrong",
"code": "INTERNAL",
})
},
}))
Production notes
- Keep
EnableStackTrace: falsein production — stack traces in responses leak file paths and internals. The stack is always available toLogFuncregardless. - Keep
ExposeError: falsein production too — panic messages routinely contain internal details (DSNs, file paths, hosts). The full panic value and stack always reachLogFunceither way, so nothing is lost from your logs. - Stack buffers are pooled with
sync.Pool, so the panic path costs a single string allocation when stack traces are disabled.
Logger — Request Logging Middleware
middleware.Logger() logs every request with method, path, status, duration, and client IP through NestGo’s structured logger. When RequestID or Tracing run before it, the request_id and trace_id fields are included automatically.
Config (LoggerConfig — no defaults constructor; the zero value logs via core.Log()):
| Field | Type | Default | Description |
|---|---|---|---|
SkipFunc | func(c core.Context) bool | nil | Return true to skip logging (e.g. health checks) |
LogFunc | func(c core.Context, status int, duration time.Duration, err error) | nil (uses core.Log()) | Override the log output entirely |
// Skip health checks
server.Use(middleware.Logger(middleware.LoggerConfig{
SkipFunc: func(c core.Context) bool {
return c.Path() == "/health"
},
}))
// Route to slog instead
server.Use(middleware.Logger(middleware.LoggerConfig{
LogFunc: func(c core.Context, status int, d time.Duration, err error) {
slog.Info("request", "method", c.Method(), "path", c.FullURL(),
"status", status, "ms", d.Milliseconds())
},
}))
Production notes
- The status is taken from the written response; for handler errors it uses the
*core.HTTPErrorcode, falling back to 500. - Errors are logged at
Errorlevel, successes atInfo— useful for alerting on log level alone.
RequestID — Request Correlation IDs
middleware.RequestID() guarantees every request carries a unique ID: an incoming X-Request-ID header is reused, otherwise a random 16-byte hex ID is generated. The ID is stored in the context and echoed on the response header, so clients and log pipelines can correlate a single request end to end.
Defaults (DefaultRequestIDConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
Header | string | "X-Request-ID" | Header to read the incoming ID from and write the outgoing ID to |
Generator | func() string | random 16-byte hex (pooled) | Creates a new ID when the header is absent |
ContextKey | string | "request_id" | Context key the ID is stored under |
server.Use(middleware.RequestID())
// In a handler:
rid, _ := c.Get("request_id").(string)
Production notes
- Register before
Logger()so therequest_idfield appears in request logs. - Incoming IDs are trusted as-is; if your edge is untrusted, strip or validate the header at the load balancer.
Tracing — Distributed Tracing in Go
middleware.Tracing() propagates W3C Trace Context: it extracts the trace_id from an incoming traceparent header (or generates a 16-byte one), generates a fresh 8-byte span_id per request, stores both in the context, and echoes the trace ID back on X-Trace-ID for client-side correlation.
Defaults (DefaultTracingConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
TraceHeader | string | "traceparent" | Incoming header parsed for the trace ID (W3C format) |
TraceIDContextKey | string | "trace_id" | Context key for the trace ID |
SpanIDContextKey | string | "span_id" | Context key for the span ID |
ResponseHeader | string | "X-Trace-ID" | Response header carrying the trace ID; "" disables |
RecordDuration | bool | true | Store handler duration (ms) under "trace_duration_ms" |
server.Use(middleware.Tracing())
// In a handler or interceptor:
traceID, _ := c.Get("trace_id").(string)
Production notes
- This middleware handles trace propagation only. For full OpenTelemetry (exporters, sampling, span hierarchies) use the OTel SDK’s middleware alongside it — the trace IDs will match since both read
traceparent. - Register before
Logger()sotrace_idshows up in request logs.
Helmet — Security Headers Middleware
middleware.Helmet() sets OWASP-recommended security headers on every response, mirroring Node’s helmet for Go web apps. Set any string field to "" to disable that header.
Defaults (DefaultHelmetConfig()):
| Field | Header | Default |
|---|---|---|
ContentTypeNoSniff | X-Content-Type-Options | "nosniff" |
XFrameOptions | X-Frame-Options | "SAMEORIGIN" |
HSTS | Strict-Transport-Security | "max-age=63072000; includeSubDomains" |
XXSSProtection | X-XSS-Protection | "0" (disabled — modern browsers use CSP) |
ReferrerPolicy | Referrer-Policy | "strict-origin-when-cross-origin" |
ContentSecurityPolicy | Content-Security-Policy | "" (not set — app-specific) |
XDNSPrefetchControl | X-DNS-Prefetch-Control | "off" |
CrossDomainPolicies | X-Permitted-Cross-Domain-Policies | "none" |
XDownloadOptions | X-Download-Options | "noopen" |
PermissionsPolicy | Permissions-Policy | "" (not set — app-specific) |
server.Use(middleware.Helmet())
// Add a CSP and deny framing entirely
server.Use(middleware.Helmet(middleware.HelmetConfig{
ContentTypeNoSniff: "nosniff",
XFrameOptions: "DENY",
HSTS: "max-age=63072000; includeSubDomains",
ContentSecurityPolicy: "default-src 'self'",
ReferrerPolicy: "strict-origin-when-cross-origin",
}))
Production notes
- Passing a custom config replaces the whole struct — fields you leave as
""are simply not sent, so restate the defaults you want to keep. - Only send HSTS on HTTPS deployments; browsers cache it aggressively.
CORS — Cross-Origin Resource Sharing in Go
middleware.CORS() handles CORS headers and preflight OPTIONS requests in Go. The default config is intentionally permissive for development (* origins); lock it down for production.
Defaults (DefaultCORSConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
AllowOrigins | []string | ["*"] | Allowed origins; exact-match lookup, "*" allows all |
AllowMethods | []string | GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD | Access-Control-Allow-Methods |
AllowHeaders | []string | Origin, Content-Type, Accept, Authorization, X-Request-ID | Access-Control-Allow-Headers |
ExposeHeaders | []string | nil | Headers the browser may read (e.g. X-Total-Count) |
AllowCredentials | bool | false | Sends Access-Control-Allow-Credentials: true |
MaxAge | int | 86400 | Preflight cache time in seconds |
// Production config
server.Use(middleware.CORS(middleware.CORSConfig{
AllowOrigins: []string{"https://app.example.com"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowHeaders: []string{"Content-Type", "Authorization"},
ExposeHeaders: []string{"X-Total-Count"},
AllowCredentials: true,
MaxAge: 3600,
}))
Production notes
- Never ship
AllowOrigins: ["*"]together withAllowCredentials: true— browsers reject that combination, and it defeats the origin check. List explicit origins when credentials are involved. - Preflight
OPTIONSrequests are answered directly with204 No Contentand never reach your handlers. - Header strings are pre-joined at startup, so the per-request cost is near zero.
RateLimit — Rate Limiting in Go
middleware.RateLimit() implements fixed-window rate limiting in Go with an in-memory, sharded store (32 shards, FNV-1a key hashing) to keep lock contention low under high concurrency. By default it limits each client IP to 100 requests per minute and sets X-RateLimit-* headers.
Defaults (DefaultRateLimitConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
Max | int | 100 | Requests allowed per window |
Window | time.Duration | time.Minute | Fixed window length |
KeyFunc | func(c core.Context) string | c.ClientIP() | Key to count against (IP, user ID, API key…) |
Message | string | "too many requests" | Error message when limited |
StatusCode | int | 429 | Status when limited |
SkipFunc | func(c core.Context) bool | nil | Return true to bypass limiting |
Headers | bool | true | Emit X-RateLimit-Limit / -Remaining / -Reset |
MaxKeys | int | 100000 | Cap on distinct keys tracked in memory; when full, expired entries are evicted first, then the closest-to-expiry |
Stop | <-chan struct{} | nil | Close to stop the cleanup goroutine (tests, hot reload) |
// 200 requests per 5 minutes, keyed by API key with IP fallback
server.Use(middleware.RateLimit(middleware.RateLimitConfig{
Max: 200,
Window: 5 * time.Minute,
KeyFunc: func(c core.Context) string {
if key := c.GetHeader("X-API-Key"); key != "" {
return key
}
return c.ClientIP()
},
}))
Production notes
- Memory behavior: counters live in process memory. A background goroutine sweeps expired entries once per
Window, and total tracked keys are hard-capped atMaxKeys(default 100,000) — when a shard fills, expired entries are evicted first, then the entry closest to expiry — so memory stays bounded even under unique-key floods (e.g. spoofed IPs). PassStopin tests to avoid leaking the goroutine. - Multi-replica: the store is per-process — with N replicas behind a load balancer, clients effectively get up to N× the limit. For a shared limit, enforce it at the edge (API gateway) or implement a Redis-backed limiter as custom middleware.
- Fixed windows allow up to 2×
Maxin a burst straddling a window boundary; sizeMaxwith that in mind. - Behind a proxy, make sure your adapter is configured with trusted proxies (e.g. Gin’s
SetTrustedProxies) soClientIP()reflects the real client, not the load balancer — otherwise clients can rotate spoofedX-Forwarded-Forvalues to dodge the limit. Never key on a rawX-Forwarded-Forheader value yourself.
Timeout — Request Timeout Middleware
middleware.Timeout() sets a deadline on the request’s context.Context via context.WithTimeout. Downstream I/O that respects the context — database queries, HTTP clients, gRPC — cancels automatically, and the client receives a 504.
Defaults (DefaultTimeoutConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
Timeout | time.Duration | 30 * time.Second | Maximum handler duration |
Message | string | "request timeout" | Error message on expiry |
StatusCode | int | 504 | Status on expiry |
SkipFunc | func(c core.Context) bool | nil | Return true to skip (WebSocket, SSE) |
// Global 30s default
server.Use(middleware.Timeout())
// Longer timeout for one route
r.POST("/reports", handler, middleware.Timeout(middleware.TimeoutConfig{
Timeout: 2 * time.Minute,
}))
// Skip streaming endpoints
server.Use(middleware.Timeout(middleware.TimeoutConfig{
SkipFunc: func(c core.Context) bool {
return c.IsWebSocket() || c.GetHeader("Accept") == "text/event-stream"
},
}))
Production notes
- The deadline cancels context-aware work; it does not forcibly kill a handler goroutine that ignores its context (a CPU-bound loop, a blocking non-context-aware call). Pass the request context to every DB/HTTP/gRPC call (see
core.RCtx()handler parameters) for the timeout to bite, and keep the server’sWriteTimeoutset as the hard backstop. - The 504 is substituted only when the deadline fired and nothing has been written yet. An already-committed response is never overwritten, and a handler that merely finishes slightly late keeps its real response or error.
BodyLimit — Request Body Size Limit
middleware.BodyLimit() rejects requests whose body exceeds a maximum with 413 Payload Too Large. Enforcement is two-phase: a declared Content-Length above the limit is rejected before the body is read, and the actual body bytes are then measured — so chunked requests and lying Content-Length headers cannot bypass the limit. Use it per route to override the global Config.BodyLimit.
Config (BodyLimitConfig — no defaults constructor; fallbacks applied when zero):
| Field | Type | Default | Description |
|---|---|---|---|
MaxBytes | int64 | 4 * 1024 * 1024 (4MB) | Maximum allowed body size in bytes |
Message | string | "request body too large" | Error message on rejection |
// Allow 100MB on the upload route only
r.POST("/upload", handler, middleware.BodyLimit(middleware.BodyLimitConfig{
MaxBytes: 100 * 1024 * 1024,
}))
// Tight limit for small JSON endpoints
r.POST("/api/data", handler, middleware.BodyLimit(middleware.BodyLimitConfig{
MaxBytes: 1024,
}))
Production notes
- The
Content-Lengthfast path costs nothing for compliant clients; chunked requests (noContent-Length) are caught by the actual-bytes check. Adapters cache the body, so handlers can still callc.Body()/c.Bind()normally afterwards. The globalConfig.BodyLimitremains the transport-level backstop, enforced by both adapters. - The rejected response includes an
X-Body-Limitheader with the configured maximum.
CSRF — CSRF Protection in Go
middleware.CSRF() implements the double-submit cookie pattern for Cross-Site Request Forgery protection in Go: a random token is set as a cookie, and on unsafe methods (POST, PUT, PATCH, DELETE) the client must echo the same token in a header or form field. Safe methods (GET, HEAD, OPTIONS, TRACE) always pass. Comparison is constant-time to prevent timing attacks.
Defaults (DefaultCSRFConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
TokenLength | int | 32 | Token bytes (hex-encoded to 64 chars) |
CookieName | string | "_csrf" | Cookie storing the token |
HeaderName | string | "X-CSRF-Token" | Header the client sends the token in |
FormField | string | "_csrf" | Form-field fallback when the header is missing |
CookiePath | string | "/" | Cookie path |
CookieDomain | string | "" | Cookie domain (current domain) |
CookieSecure | bool | false | Secure flag — set true in production |
SameSite | string | "Lax" | Cookie SameSite attribute ("Lax", "Strict", "None"); applied when the adapter implements core.SameSiteCookieSetter (both official adapters do) |
CookieHTTPOnly | bool | true | HttpOnly flag |
CookieMaxAge | int | 86400 | Cookie lifetime in seconds (24h) |
SkipFunc | func(c core.Context) bool | nil | Return true to skip validation |
ErrorHandler | func(c core.Context) error | 403 "CSRF token mismatch" | Called on validation failure |
TokenGenerator | func() (string, error) | crypto/rand hex | Custom token source |
// Cookie-session apps: protect everything, skip the JWT API
server.Use(middleware.CSRF(middleware.CSRFConfig{
CookieSecure: true,
SkipFunc: func(c core.Context) bool {
return strings.HasPrefix(c.Path(), "/api/")
},
}))
// In a template handler, expose the token to the page:
token, _ := c.Get("csrf_token").(string)
Production notes
- Always set
CookieSecure: truein production — the double-submit defense depends on attackers being unable to plant the cookie, and a non-Securecookie can be set by a plaintext-HTTP MITM. The cookie is written withSameSite=Laxby default (on adapters withoutcore.SameSiteCookieSetter, it is set without a SameSite attribute). For SPA frameworks that read the cookie via JavaScript (Angular-styleXSRF-TOKEN), setCookieHTTPOnly: falseand renameCookieName/HeaderNameaccordingly — the double-submit pattern stays sound because cross-site pages cannot read your cookies. - CSRF matters for cookie/session authentication. Pure bearer-token APIs (JWT in the
Authorizationheader) are not CSRF-able — skip them viaSkipFuncrather than weakening the config. - The current token is always available to handlers under the
"csrf_token"context key for rendering into forms.
Compress — Gzip Compression in Go
middleware.Compress() enables gzip compression in Go for JSON responses. It checks the client’s Accept-Encoding and stashes the compression settings in the context; handlers opt in by responding with the middleware.GzipJSON(c, status, data) helper instead of c.JSON(...). Responses smaller than MinLength are sent uncompressed.
Defaults (DefaultCompressConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
Level | int | gzip.DefaultCompression | Gzip level 1–9 (gzip.BestSpeed … gzip.BestCompression) |
MinLength | int | 1024 | Minimum body size in bytes to compress |
SkipFunc | func(c core.Context) bool | nil | Return true to skip |
server.Use(middleware.Compress())
// Handler opts in with GzipJSON
func (ctrl *ReportController) List(c core.Context) error {
data := ctrl.service.GetLargeDataset()
return middleware.GzipJSON(c, 200, data)
}
Production notes
GzipJSONdegrades gracefully: without the middleware (or a client that doesn’t accept gzip) it behaves exactly likec.JSON. Compressed responses carryContent-Encoding: gzipandVary: Accept-Encoding.- For transparent compression of every response type, use the adapter-native option instead:
github.com/gin-contrib/gzipon Gin, or Fiber’s built-in compress middleware. - Don’t compress tiny payloads — the default 1KB threshold avoids paying gzip overhead for bodies that fit in one packet anyway.
GzipJSONpools gzip writers per compression level (sync.Pool), reusing the flate compressor’s internal state across responses instead of reallocating it per call.
ETag — HTTP Caching with ETags
middleware.ETag() computes an ETag from the response body (SHA-256, truncated to 32 hex chars) on GET/HEAD requests and answers matching If-None-Match requests with 304 Not Modified and no body — a large bandwidth win on read-heavy Go APIs. Serving the 304 requires the adapter to discard the buffered response first (core.ResponseResetter); both official adapters implement it.
Defaults (DefaultETagConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
Weak | bool | true | Emit weak ETags (W/"...") — safest for JSON APIs |
SkipFunc | func(c core.Context) bool | nil | Return true to skip (e.g. streaming) |
server.Use(middleware.ETag())
// Strong ETags when responses are byte-exact
server.Use(middleware.ETag(middleware.ETagConfig{Weak: false}))
// Skip SSE endpoints
server.Use(middleware.ETag(middleware.ETagConfig{
SkipFunc: func(c core.Context) bool {
return c.GetHeader("Accept") == "text/event-stream"
},
}))
Production notes
- ETags are only generated for successful (2xx) GET/HEAD responses with a non-empty body; errors and mutations are untouched.
- The 304 rewrite happens only when the adapter context implements
core.ResponseResetter, so the buffered 200 can be discarded before it reaches the wire. The Gin and Fiber adapters both do; on a third-party adapter without it, only theETagheader is set and the full response is sent — never a status rewrite on top of a committed body. - The handler still runs on a 304 — the saving is response bandwidth, not compute. For compute savings, cache at the service layer.
- If responses are gzip-compressed by an adapter-level compressor after this middleware, prefer weak ETags (the default), since the bytes on the wire differ from the hashed body.
Idempotency — Idempotency Middleware for Safe Retries
middleware.Idempotency() is an idempotency middleware that caches successful responses by a client-supplied Idempotency-Key header. A retry with the same key within the TTL returns the cached response immediately — with an Idempotency-Replayed: true header and the original Content-Type — without re-running the handler. Cache keys are hashed (SHA-256) from the method, path, an optional KeyScopeFunc scope, and the client key, so a key reused against a different endpoint — or, with scoping, by a different user — can never replay the wrong response. Concurrent duplicates get 409 Conflict while the first request is in flight, and failed requests (errors, non-2xx statuses, even panics) release the key so retries can succeed. This is essential for payment APIs and any non-idempotent mutation.
Defaults (DefaultIdempotencyConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
Header | string | "Idempotency-Key" | Header carrying the client’s unique key |
TTL | time.Duration | 24 * time.Hour | How long cached responses are kept |
Methods | []string | POST, PUT, PATCH | Methods idempotency applies to |
Required | bool | false | Return 400 when the header is missing |
KeyScopeFunc | func(c core.Context) string | nil | Extra scope hashed into the cache key (e.g. user/tenant ID) — strongly recommended on multi-tenant APIs |
MaxEntries | int | 10000 | Cap on entries in the default in-memory store; ignored when a custom Store is provided |
Store | IdempotencyStore | in-memory store | Pluggable backing store |
Stop | <-chan struct{} | nil | Close to stop the in-memory store’s cleanup goroutine |
// Require an idempotency key on the payments route, scoped per user
r.POST("/payments", handler, middleware.Idempotency(middleware.IdempotencyConfig{
Required: true,
TTL: 1 * time.Hour,
KeyScopeFunc: func(c core.Context) string {
userID, _ := c.Get("user_id").(string)
return userID // one user's key can never replay another user's response
},
}))
Production notes
- Multi-replica deployments need a shared store. The default store is in-process memory — with several replicas, a retry may land on a replica that never saw the key and re-execute the handler. Implement the
IdempotencyStoreinterface over Redis and pass it viaStore:
// IdempotencyStore — implement over Redis for multi-replica safety
type IdempotencyStore interface {
Get(key string) *IdempotencyEntry
Set(key string, entry *IdempotencyEntry, ttl time.Duration)
SetProcessing(key string, ttl time.Duration) bool // false if already in flight
Remove(key string)
}
server.Use(middleware.Idempotency(middleware.IdempotencyConfig{
Store: myRedisIdempotencyStore,
}))
- In a Redis implementation, back
SetProcessingwithSET key ... NX PX ttlso the in-flight lock is atomic across replicas. - Only 2xx responses are cached (
IdempotencyEntry{Status, Body, ContentType}— the originalContent-Typeis stored and replayed). Handler errors and non-2xx statuses callStore.Removeso the client can retry, and a panicking handler releases the key before re-panicking instead of blocking retries with 409s until the TTL expires. - The default in-memory store is sharded 32 ways (FNV-1a, same scheme as the rate limiter), bounded by
MaxEntries(expired entries evicted first, then the oldest-expiring), and sweeps expired entries everymin(TTL/2, 1 minute). - The client key is never used alone — the store key is
SHA-256(method + path + scope + key). Clients should still send a UUID per logical operation, not per HTTP attempt.
Upload — File Upload Validation
middleware.Upload() validates a multipart file upload before your handler runs: size limit, allowed extensions, and MIME type sniffed from the first 512 bytes of file content (not the spoofable client header). On success it stores an *UploadedFile in the context under "uploaded_file".
Defaults (DefaultUploadConfig()):
| Field | Type | Default | Description |
|---|---|---|---|
FieldName | string | "file" | Multipart form field containing the file |
MaxFileSize | int64 | 10 * 1024 * 1024 (10MB) | Max size; larger files get 413 |
AllowedMIMETypes | []string | nil (all allowed) | e.g. ["image/png", "image/jpeg", "application/pdf"] |
AllowedExtensions | []string | nil (all allowed) | With dot, e.g. [".png", ".jpg", ".pdf"] |
r.POST("/avatar", handler, middleware.Upload(middleware.UploadConfig{
MaxFileSize: 5 * 1024 * 1024,
AllowedMIMETypes: []string{"image/png", "image/jpeg"},
}))
func (ctrl *ProfileController) UploadAvatar(c core.Context) error {
uf := c.Get("uploaded_file").(*middleware.UploadedFile)
if err := middleware.SaveFile(uf.Header, "/uploads/"+uf.Filename); err != nil {
return core.ErrInternalServer("could not save file")
}
return c.JSON(201, map[string]any{"size": uf.Size, "type": uf.MIMEType})
}
Production notes
- Pair with BodyLimit (or the global
Config.BodyLimit) sized aboveMaxFileSize—MaxFileSizechecks the parsed file, while the body limit rejects oversized requests before parsing. - MIME detection uses
http.DetectContentTypeon real file bytes, so a renamed.execannot masquerade as a PNG. Combine MIME and extension checks for defense in depth. - Never write uploads using the client-supplied filename verbatim — generate your own name (the example above is simplified).
middleware.SaveFilecreates missing destination directories for you.
Next steps
- Guards — authentication and authorization checks that run before handlers
- Interceptors — wrap handler execution for logging, caching, and response mapping
- Validation & Error Handling —
HTTPError, exception filters, and how middleware errors become responses
Browse the middleware source on GitHub: github.com/ashrafAli23/nestgo.