HTTP & Middleware

go.putnami.dev/http provides the HTTP server, trie-based routing, and middleware layer built on Go's net/http standard library.

HTTP server

Basic setup

package main

import (
    "context"

    "go.putnami.dev/app"
    fhttp "go.putnami.dev/http"
)

func main() {
    server := fhttp.NewServerPlugin(fhttp.ServerConfig{Port: 3000})
    server.Use(fhttp.Recovery())
    server.Use(fhttp.Logging(fhttp.LoggerOptions{}))
    server.GET("/health", func(ctx *fhttp.Context) *fhttp.Response {
        return fhttp.JSON(map[string]string{"status": "ok"})
    })

    a := app.New("my-service")
    a.Module.Use(server)
    a.ListenAndServe()
}

Configuration

fhttp.NewServerPlugin(fhttp.ServerConfig{
    Port:            8080,           // Server port (env: PORT)
    ReadTimeout:     30 * time.Second,
    WriteTimeout:    30 * time.Second,
    ShutdownTimeout: 10 * time.Second,
    MaxBodySize:     1 << 20,        // 1 MiB
})
Option Type Default Description
Port int 8080 Server port (PORT env var)
ReadTimeout time.Duration 30s Read timeout
WriteTimeout time.Duration 30s Write timeout
ShutdownTimeout time.Duration 10s Graceful shutdown timeout
MaxBodySize int64 1048576 Max request body size in bytes

Routing

Basic routes

server := fhttp.NewServerPlugin(fhttp.ServerConfig{Port: 3000})

// GET
server.GET("/users", func(ctx *fhttp.Context) *fhttp.Response {
    return fhttp.JSON([]User{})
})

// POST
server.POST("/users", func(ctx *fhttp.Context) *fhttp.Response {
    var body CreateUserRequest
    if err := ctx.Body(&body); err != nil {
        return fhttp.JSONStatus(400, map[string]string{"error": "invalid body"})
    }
    return fhttp.JSONStatus(201, user)
})

// PUT
server.PUT("/users/{id}", func(ctx *fhttp.Context) *fhttp.Response {
    id := ctx.Param("id")
    return fhttp.JSON(map[string]string{"updated": id})
})

// DELETE
server.DELETE("/users/{id}", func(ctx *fhttp.Context) *fhttp.Response {
    id := ctx.Param("id")
    return fhttp.NoContent()
})

// PATCH
server.PATCH("/users/{id}", func(ctx *fhttp.Context) *fhttp.Response {
    id := ctx.Param("id")
    return fhttp.JSON(map[string]string{"patched": id})
})

Route with options

server.Route("GET", "/api/data", handler,
    fhttp.WithAccept("application/json"),
    fhttp.WithStatusCode(200),
)

Route patterns

The router uses a trie-based matcher supporting three pattern types:

Pattern Example Description
Exact /users Matches the exact path
Parameter /users/{id} Captures a named segment
Wildcard /files/* Captures the rest of the path
server.GET("/users/{id}", func(ctx *fhttp.Context) *fhttp.Response {
    id := ctx.Param("id")      // "123" for /users/123
    return fhttp.JSON(map[string]string{"id": id})
})

server.GET("/files/*", func(ctx *fhttp.Context) *fhttp.Response {
    path := ctx.Param("*")     // "docs/readme.md" for /files/docs/readme.md
    return fhttp.Text(path)
})

Request context

Properties

func handler(ctx *fhttp.Context) *fhttp.Response {
    ctx.Request     // *http.Request
    ctx.Writer      // http.ResponseWriter
    ctx.Method      // HTTP method string
    ctx.Path        // URL path
    ctx.Route       // Matched route pattern (e.g., "/users/{id}")
    ctx.Params      // map[string]string of path parameters
    ctx.User        // map[string]any of authenticated user claims
    ctx.StatusCode  // Response status code (modifiable)
}

Methods

func handler(ctx *fhttp.Context) *fhttp.Response {
    // Path and query parameters
    id := ctx.Param("id")
    page := ctx.Query("page")
    params := ctx.QueryParams()    // url.Values

    // Headers
    auth := ctx.Header("Authorization")
    ctx.SetHeader("X-Custom", "value")

    // Host and security
    host := ctx.Host()
    secure := ctx.IsSecured()      // true if user claims are set

    // Body parsing
    var body MyStruct
    if err := ctx.Body(&body); err != nil {
        return fhttp.JSONStatus(400, map[string]string{"error": err.Error()})
    }

    // Raw body
    raw, err := ctx.RawBody()

    // Content negotiation
    ct := ctx.ContentType()
    accept := ctx.Accept()

    // Access underlying context.Context
    goCtx := ctx.Context()
}

Response helpers

Factory functions

// JSON responses
fhttp.JSON(data)                     // 200 + JSON
fhttp.JSONStatus(201, data)          // Custom status + JSON

// Text
fhttp.Text("Hello, world!")          // 200 + text/plain

// Redirect
fhttp.Redirect("/new-location", 302)

// No content
fhttp.NoContent()                    // 204

// Error responses
fhttp.NotFound()                     // 404 JSON
fhttp.Unauthorized()                 // 401 JSON
fhttp.Forbidden()                    // 403 JSON
fhttp.InternalError("something failed") // 500 JSON

Response modification

fhttp.JSON(data).
    WithHeader("Cache-Control", "no-store").
    WithHeader("X-Request-Id", requestId).
    WithStatus(201)

Middleware

Middleware signature

type Middleware func(ctx *Context, next func() *Response) *Response

Creating middleware

// Logging middleware
func requestLogger() fhttp.Middleware {
    return func(ctx *fhttp.Context, next func() *fhttp.Response) *fhttp.Response {
        start := time.Now()
        resp := next()
        duration := time.Since(start)
        fmt.Printf("%s %s %dms\n", ctx.Method, ctx.Path, duration.Milliseconds())
        return resp
    }
}

// Authentication middleware
func requireAuth() fhttp.Middleware {
    return func(ctx *fhttp.Context, next func() *fhttp.Response) *fhttp.Response {
        token := ctx.Header("Authorization")
        if token == "" {
            return fhttp.Unauthorized()
        }
        claims, err := verifyToken(token)
        if err != nil {
            return fhttp.Unauthorized()
        }
        ctx.User = claims
        return next()
    }
}

Registering middleware

server := fhttp.NewServerPlugin(fhttp.ServerConfig{Port: 3000})

// Middleware executes in registration order
server.Use(fhttp.Recovery())        // 1st — catch panics
server.Use(fhttp.RequestID())       // 2nd — extract trace ID
server.Use(fhttp.Logging(fhttp.LoggerOptions{})) // 3rd — log requests
server.Use(requireAuth())           // 4th — check authentication

Early return

Middleware can return early without calling next():

func maintenanceMode() fhttp.Middleware {
    return func(ctx *fhttp.Context, next func() *fhttp.Response) *fhttp.Response {
        if isMaintenanceMode() {
            return fhttp.JSONStatus(503, map[string]string{
                "error": "Service temporarily unavailable",
            })
        }
        return next()
    }
}

Modifying responses

func securityHeaders() fhttp.Middleware {
    return func(ctx *fhttp.Context, next func() *fhttp.Response) *fhttp.Response {
        resp := next()
        if resp != nil {
            return resp.
                WithHeader("X-Content-Type-Options", "nosniff").
                WithHeader("X-Frame-Options", "DENY")
        }
        return resp
    }
}

Chaining middleware

// Compose middleware into a single handler wrapper
protected := fhttp.Chain(
    fhttp.Recovery(),
    fhttp.RequestID(),
    requireAuth(),
)

handler := protected(func(ctx *fhttp.Context) *fhttp.Response {
    return fhttp.JSON(map[string]string{"ok": "true"})
})

Built-in middleware

Recovery

Catches panics and returns a 500 response:

server.Use(fhttp.Recovery())

Request ID

Extracts trace ID from incoming headers (X-Request-ID, X-Cloud-Trace-Context) and makes it available in the request context:

server.Use(fhttp.RequestID())

Logging

Logs each request with method, path, status, and duration:

server.Use(fhttp.Logging(fhttp.LoggerOptions{
    Logger:  customLogger,           // nil = default logger
    Exclude: []string{"/health"},    // path prefixes to skip
}))

Rate limiting

Sliding-window rate limiting per client:

server.Use(fhttp.RateLimit(fhttp.RateLimitOptions{
    WindowMs: 60_000,    // Time window (default: 60000ms)
    Max:      100,       // Max requests per window (default: 100)
    Message:  "Too Many Requests",
    Headers:  boolPtr(true),  // Include RateLimit-* headers
    KeyFunc:  func(ctx *fhttp.Context) string {
        return ctx.Header("X-Forwarded-For") // Custom key extraction
    },
}))

Compression

Gzip compression for responses above a size threshold:

server.Use(fhttp.Compression(fhttp.CompressionOptions{
    Threshold: 1024, // Minimum body size in bytes (default: 1024)
}))

Endpoint builder

For endpoints that need validation, DI injection, and OpenAPI metadata, use the fluent endpoint builder:

import (
    fhttp "go.putnami.dev/http"
    "go.putnami.dev/inject"
)

type CreateUserParams struct {
    Name  string `json:"name" validate:"required,minlen=2"`
    Email string `json:"email" validate:"required,email"`
}

endpoint := fhttp.Endpoint("POST", "/users").
    Description("Create a new user").
    Body(reflect.TypeOf(CreateUserParams{})).
    Returns("The created user").
    Throws(409, "Email already exists").
    Inject("users", inject.TokenOf[*UserService]()).
    Handle(func(ctx *fhttp.EndpointContext) *fhttp.Response {
        users := ctx.Injected["users"].(*UserService)
        body := ctx.ValidatedBody
        // ... create user
        return fhttp.JSONStatus(201, user)
    })

endpoint.Register(server)

Health checks

For new code, use go.putnami.dev/platform — it mounts /healthz, /livez, /readyz, /version, and (opt-in) /debug/pprof/*, and auto-discovers app.HealthChecker / app.ReadinessChecker probes from the module tree.

The http package's legacy health plugin remains available for a single liveness endpoint at /_/health:

health := fhttp.NewHealthPlugin()
health.RegisterOn(server)

a := app.New("my-service")
a.Module.Use(server)
a.Module.Use(health)

Returns 200 {"status":"ok"} when ready and 503 {"status":"unavailable"} during startup or shutdown. It also auto-discovers app.HealthChecker implementations and reports their state under a checks map — but it has no readiness, version, or pprof surface. Prefer the platform plugin unless you specifically need to keep the /_/health path.

Content negotiation

import fhttp "go.putnami.dev/http"

// Parse Accept header
types := fhttp.ParseAccept("text/html, application/json;q=0.9")
// types[0].Full == "text/html", types[1].Full == "application/json"

// Negotiate content type
best := fhttp.NegotiateContentType(
    ctx.Accept(),
    []string{"application/json", "text/html"},
)

DI integration

The server plugin creates a DI scope per request when used as an app.Plugin. The container is acquired automatically via owner.Container() in Configure. This enables per-request scoped services:

// Container is acquired automatically when server is added as a plugin.
// a.Use(server)

// In a handler, resolve scoped services from the request context
server.GET("/users", func(ctx *fhttp.Context) *fhttp.Response {
    userService, _ := inject.Resolve[*UserService](ctx.Context(), inject.TokenOf[*UserService]())
    return fhttp.JSON(userService.List(ctx.Context()))
})

See Dependency Injection for scoped provider details.

Request scope and the response

Each request runs in its own dependency scope. The scope is reconciled against the response the client actually receives, not against a returned error:

Outcome Scope
2xx or 3xx response commit
4xx response commit — a client error is a handled outcome, and any audit row or counter written alongside it is kept
5xx response roll back
Canceled or timed-out request roll back
Panic no middleware converted roll back, then the panic is re-raised
Commit itself fails after a success the prepared response is replaced by a sanitized 500

A handler that wants a 4xx to discard its work marks its unit of work rollback-only; the status code alone will not do it.

Graceful shutdown and bounded resources

ServerConfig's default: struct tags are applied by config.Load. A directly constructed config — the shape used throughout these guides — carries Go zero values, so the server resolves every bound itself and both construction paths behave identically:

Field Unset Opt out
ShutdownTimeout 10s drain for in-flight requests negative: drain under the caller's context alone
WebSocketIdleTimeout 60s idle deadline on hijacked connections negative disables
StreamWriteTimeout 30s per server-sent-event write negative disables
ReadTimeout / WriteTimeout 30s
MaxBodySize 1 MiB
MaxHeaderBytes 64 KiB
server := fhttp.NewServerPlugin(fhttp.ServerConfig{Port: 8080})
// Stop() drains in-flight requests for 10s before closing.

Support and contract

go.putnami.dev/http is stable in the workspace support catalog. Its behavior is defined by the HTTP services specification and two accepted decision records next to the package source (go/framework/http/specs/ and go/framework/http/doc/adr/). Before v1.0.0 a minor 0.x release may still contain a documented breaking change; strict compatibility between every pre-1.0 minor is not promised.