Core

Middleware

Use the built-in middleware, add your own, and understand the order everything runs in.

Middleware is the standard shape, so anything written for net/http works unchanged:

type Middleware func(http.Handler) http.Handler

Start from the defaults

ezz.New() installs three, in this order:

MiddlewareWhat it does
Recovery(logger)Catches panics, logs them, replies 500
RequestID()Reuses an incoming X-Request-ID or generates one, and echoes it on the response
Logging(logger)Logs method, path, status, duration, remote address, and user agent

Add more with Use:

import "github.com/sulv-io/ezz/middleware"

app := ezz.New().
    Use(
        ezz.CORS(ezz.DefaultCORSConfig()),
        middleware.Gzip(),
        middleware.IPRateLimit(100),
    )
Logger(l) rebuilds the default three with the new logger and discards anything added before it. Call Logger first, then Use.

Understand the order

The chain is compiled when a route is registered, not per request. Middleware runs outside-in, in the order it was added, and app-level middleware wraps group-level middleware:

auth check
  └─ Recovery
      └─ RequestID
          └─ Logging
              └─ your app middleware, in Use order
                  └─ group middleware, in Use order
                      └─ handler (binding, validation, your function)

Two consequences are worth knowing before you debug something surprising.

Middleware must be registered before routes. Each route captures the chain that exists at registration time, so Use after a route is registered has no effect on it. Configure the app fully, then register routes.
Auth rejections run before the middleware chain. A 401 or 403 produced by a route's auth rule is written before Recovery, Logging, or any middleware you added, so those responses are not logged and carry no middleware-added headers. That includes CORS headers, so a browser calling a protected route without a token sees a CORS failure rather than a clean 401. If that matters, handle authentication in middleware you control (see JWT providers) or terminate CORS at your proxy.

Add CORS

app.Use(ezz.CORS(ezz.CORSConfig{
    AllowedOrigins:   []string{"https://app.example.com", "https://admin.example.com"},
    AllowedMethods:   []string{"GET", "POST", "PATCH", "DELETE"},
    AllowedHeaders:   []string{"Content-Type", "Authorization"},
    AllowCredentials: true,
    MaxAge:           3600,
}))

DefaultCORSConfig() allows every origin, the common methods, and Content-Type plus Authorization, with a one-hour preflight cache. Origins are looked up in a map built once at construction, and an OPTIONS request is answered with 200 without reaching your handler. An empty AllowedOrigins means "allow all", so set it explicitly in production.

Compress responses

app.Use(middleware.Gzip())

// or tune it
app.Use(middleware.Gzip(middleware.CompressionConfig{
    Level:     6,
    MinLength: 2048,
    MIMETypes: map[string]bool{"application/json": true},
}))

Defaults are level 6, a 1 KB minimum, and JSON, JavaScript, XML, CSS, HTML, and plain text. Responses are only compressed when the client sends Accept-Encoding: gzip. middleware.DecompressionMiddleware() handles the reverse case, transparently decoding gzipped request bodies.

Rate limit requests

The in-memory limiters are token buckets kept in a map keyed by whatever you choose, with a background sweep for idle keys.

app.Use(middleware.IPRateLimit(100))                      // per client IP, per minute
app.Use(middleware.APIKeyRateLimitMiddleware(1000))       // per X-API-Key, else Authorization, else IP
app.Use(middleware.UserRateLimitMiddleware(60, userID))   // per value from your own function

For full control, build a limiter and a key function:

limiter := middleware.NewMemoryLimiter(100, time.Second, 5*time.Minute)

app.Use(middleware.RateLimitMiddleware(limiter, func(r *http.Request) string {
    return r.Header.Get("X-Tenant-ID")
}))

middleware.CustomRateLimitMiddleware(capacity, refill, keyFunc) is the same thing in one call. Exceeding the limit returns 429 with a plain-text body. Anything implementing Allow(key string) bool and Reset(key string) can be passed as a limiter, so a shared store is easy to slot in.

These limiters are per process. Several replicas each enforce the limit separately. For a shared counter, use the Redis limiter in auth/ratelimit or your own implementation of the interface.

Add middleware to a group

Group middleware only wraps the routes registered on that group, which is the natural place for per-area concerns:

api := app.Group("/api/v1").Use(middleware.Gzip())
internal := app.Group("/internal").Use(requireInternalNetwork())

Write your own

func Timeout(d time.Duration) ezz.Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), d)
            defer cancel()
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

app.Use(Timeout(5 * time.Second))

To pass values down to handlers, put them on the request context and read them with ctx.Value:

type tenantKey struct{}

func Tenant(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tenant := lookupTenant(r.Host)
        next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), tenantKey{}, tenant)))
    })
}

// in a handler
tenant, _ := ctx.Value(tenantKey{}).(*Tenant)

If your middleware needs to inspect the status or body, wrap the http.ResponseWriter as usual. The writer ezz passes down implements Unwrap() http.ResponseWriter, so http.ResponseController works for flushing and deadlines.

Next steps

Copyright © 2026