Skip to content

Middleware

Middleware in λ Cosmos wraps handlers to add cross-cutting behavior.

type Handler func(w http.ResponseWriter, r *http.Request) error
type Middleware = func(Handler) Handler

Apply middleware with Use:

app := framework.New()
app.Use(middleware.Recover())
app.Use(middleware.Logger(logger))

Middleware runs in registration order — the first Use call wraps outermost.

Catches panics and converts them into errors that flow through the normal error handling chain:

import "github.com/studiolambda/cosmos/framework/middleware"
app.Use(middleware.Recover())

The default recovery handler converts panic values to errors based on their type:

Panic typeConversion
errorReturned as-is
stringWrapped in errors.New()
fmt.StringerUses String() method
io.ReaderReads all bytes
encoding.TextMarshalerUses MarshalText()
OtherFormatted with fmt.Sprintf and joined with ErrRecoverUnexpected

All recovered panic values are wrapped with ErrRecoverUnexpected via errors.Join to prevent internal details from leaking to HTTP clients. The original value remains accessible via errors.Unwrap for logging. For io.Reader panic values, the content is capped at 1 MB.

For custom panic handling, use RecoverWith:

app.Use(middleware.RecoverWith(func(value any) error {
// custom panic-to-error conversion
sentry.CaptureException(fmt.Errorf("panic: %v", value))
return fmt.Errorf("internal error")
}))

Logs failed requests using structured logging (log/slog):

import "log/slog"
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
app.Use(middleware.Logger(logger))

The logger fires after the response is complete and logs when:

  • The handler returned an error, or
  • The response status code is 5xx.

Logged fields: method, url, status, err.

Passing nil creates a discard logger (no panics, no output).

Protects against Cross-Site Request Forgery using Go’s built-in http.CrossOriginProtection:

app.Use(middleware.CSRF("https://example.com", "https://admin.example.com"))

Pass trusted origins that are allowed to make state-changing requests. Requests from untrusted origins receive a 403 Forbidden Problem Details response.

For full control over the CSRF configuration:

csrf := http.NewCrossOriginProtection()
csrf.AddTrustedOrigin("https://example.com")
app.Use(middleware.CSRFWith(csrf, problem.Problem{
Title: "CSRF Blocked",
Detail: "Your custom error message",
Status: http.StatusForbidden,
}))

Injects values into the request context, making them available to downstream handlers:

app.Use(middleware.Provide(myKey, myValue))

For dynamic values computed per request, use ProvideWith:

app.Use(middleware.ProvideWith(func(w http.ResponseWriter, r *http.Request) (context.Context, error) {
user, err := authenticate(r)
if err != nil {
return nil, err
}
return context.WithValue(r.Context(), userKey, user), nil
}))

If the ProvideFunc returns an error, the request short-circuits and the error flows through normal error handling.

Integrate standard Go func(http.Handler) http.Handler middleware with Cosmos:

import "github.com/rs/cors"
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"https://example.com"},
}).Handler
app.Use(middleware.HTTP(corsMiddleware))

This adapter bridges the gap between Go’s standard middleware pattern and Cosmos’s error-returning handlers. It:

  1. Converts the Cosmos handler chain into a standard http.Handler.
  2. Applies the HTTP middleware.
  3. Captures any errors from downstream Cosmos handlers.
  4. Returns captured errors through the Cosmos middleware chain.

This means you can use any third-party Go HTTP middleware (CORS, rate limiting, etc.) without losing Cosmos error handling.

Configurable Cross-Origin Resource Sharing headers for APIs accessed from browsers on different domains:

app.Use(middleware.CORS(middleware.CORSOptions{
AllowedOrigins: []string{"https://example.com", "https://app.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
ExposedHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAge: 86400,
}))

Use middleware.DefaultCORSOptions as a starting point and customize as needed.

Adds security-related HTTP headers to all responses:

app.Use(middleware.SecureHeaders())

Default headers set:

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • Referrer-Policy: strict-origin-when-cross-origin
  • X-XSS-Protection: 0 (modern browsers use CSP instead)
  • Strict-Transport-Security: max-age=63072000; includeSubDomains

Customize specific headers:

app.Use(middleware.SecureHeadersWith(middleware.SecureHeadersOptions{
FrameOptions: "SAMEORIGIN",
ContentSecurityPolicy: "default-src 'self'",
PermissionsPolicy: "camera=(), microphone=()",
}))

Token bucket rate limiting to prevent abuse:

app.Use(middleware.RateLimit())

Defaults: 15 requests per second per IP, burst of 30. Returns a 429 Too Many Requests Problem Details response when exceeded. Idle entries are automatically evicted after 5 minutes to prevent unbounded memory growth.

Customize the limits:

app.Use(middleware.RateLimitWith(middleware.RateLimitOptions{
RequestsPerSecond: 5,
Burst: 10,
KeyFunc: func(r *http.Request) string {
return r.Header.Get("X-API-Key") // rate limit by API key instead of IP
},
CleanupInterval: 2 * time.Minute, // how often to sweep idle entries
MaxIdleTime: 10 * time.Minute, // evict after 10 min of inactivity
}))

A custom middleware is just a function that takes and returns a framework.Handler:

func Timing() framework.Middleware {
return func(next framework.Handler) framework.Handler {
return func(w http.ResponseWriter, r *http.Request) error {
start := time.Now()
err := next(w, r)
slog.Info("request handled", "duration", time.Since(start))
return err
}
}
}
app.Use(Timing())

Middleware can short-circuit by returning an error without calling next:

func RequireAuth(token string) framework.Middleware {
return func(next framework.Handler) framework.Handler {
return func(w http.ResponseWriter, r *http.Request) error {
if request.Header(r, "Authorization") != "Bearer "+token {
return problem.Problem{
Title: "Unauthorized",
Status: http.StatusUnauthorized,
}
}
return next(w, r)
}
}
}