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 JSONResponse 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) *ResponseCreating 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 authenticationEarly 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