Putnami
DocsGitHub

Licensed under FSL-1.1-MIT

Getting Started
Concepts
How To
Build A Web App
Build An Api Service
Share Code Between Projects
Configure Your App
Add Persistence
Add Authentication
Add Background Jobs
Principles
Tooling & Workspace
Workspace Overview
Cli
Jobs & Commands
SDK
Error Handling
Extensions
Typescript
Go
Python
Docker
Ci
Frameworks
Typescript
OverviewWebReact RoutingForms And ActionsStatic FilesApiErrors And ResponsesConfigurationLoggingHttp And MiddlewareDependency InjectionPlugins And LifecycleSessionsAuthPersistenceEventsStorageCachingWebsocketsTestingHealth ChecksTelemetryProto GrpcSmart Client
Go
OverviewHttpDependency InjectionPlugins And LifecycleConfigurationSecurityPersistenceErrorsEventsStorageCachingLoggingTelemetryGrpcService ClientsValidationOpenapiTesting
Platform
  1. DocsSeparator
  2. FrameworksSeparator
  3. GoSeparator
  4. Http

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

The built-in health plugin registers a GET /_/health endpoint:

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

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

The health endpoint returns 200 {"status":"ok"} when the application is ready and 503 {"status":"unavailable"} during startup or shutdown.

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 a ContainerContext is set. This enables per-request scoped services:

server.SetContainerContext(app.Context())

// 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.

Related guides

  • Security — authorization middleware
  • Plugins & Lifecycle — server lifecycle
  • Dependency Injection — per-request scoping
  • OpenAPI — spec generation from endpoints

On this page

  • HTTP & Middleware
  • HTTP server
  • Basic setup
  • Configuration
  • Routing
  • Basic routes
  • Route with options
  • Route patterns
  • Request context
  • Properties
  • Methods
  • Response helpers
  • Factory functions
  • Response modification
  • Middleware
  • Middleware signature
  • Creating middleware
  • Registering middleware
  • Early return
  • Modifying responses
  • Chaining middleware
  • Built-in middleware
  • Recovery
  • Request ID
  • Logging
  • Rate limiting
  • Compression
  • Endpoint builder
  • Health checks
  • Content negotiation
  • DI integration
  • Related guides