diff --git a/CLAUDE.md b/CLAUDE.md index abaf57f..d45e3ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,15 +22,25 @@ go vet ./... # static analysis - **Sentinel errors**: canonical values live in sub-packages, root package re-exports as aliases - **balancer.Transport** returns `(Middleware, *Closer)` — Closer must be tracked for health checker shutdown - **Client.Close()** stops the health checker goroutine +- **Client.Patch()** — PATCH method, same pattern as Put/Post +- **NewFormRequest** — form-encoded request builder (`application/x-www-form-urlencoded`) with `GetBody` for retry +- **WithMaxResponseBody** — wraps `resp.Body` with `io.LimitedReader` to prevent OOM +- **middleware.RequestID()** — propagates request ID from context to outgoing `X-Request-Id` header +- **`internal/requestid`** — shared context key used by both `server` and `middleware` packages to avoid circular imports ### Server (`server/`) - **Core pattern**: middleware is `func(http.Handler) http.Handler` - **Server** wraps `http.Server` with `net.Listener`, graceful shutdown via signal handling, lifecycle hooks -- **Router** wraps `http.ServeMux` — supports groups with prefix + middleware inheritance, `Mount` for sub-handlers +- **Router** wraps `http.ServeMux` — supports groups with prefix + middleware inheritance, `Mount` for sub-handlers, `WithNotFoundHandler` for custom 404 - **Middleware chain** via `Chain(A, B, C)` — A outermost, C innermost (same as client side) - **statusWriter** wraps `http.ResponseWriter` to capture status; implements `Unwrap()` for `http.ResponseController` - **Defaults()** preset: RequestID → Recovery → Logging + production timeouts - **HealthHandler** exposes `GET /healthz` (liveness) and `GET /readyz` (readiness with pluggable checkers) +- **CORS** middleware — preflight OPTIONS handling, `AllowOrigins`, `AllowMethods`, `AllowHeaders`, `ExposeHeaders`, `AllowCredentials`, `MaxAge` +- **RateLimit** middleware — per-key token bucket (`sync.Map`), IP from `X-Forwarded-For`, `WithRate`/`WithBurst`/`WithKeyFunc`, uses `internal/clock` +- **MaxBodySize** middleware — wraps `r.Body` via `http.MaxBytesReader` +- **Timeout** middleware — wraps `http.TimeoutHandler`, returns 503 +- **WriteJSON** / **WriteError** — JSON response helpers in `server/respond.go` ## Conventions diff --git a/README.md b/README.md index 6a22c5e..2a97caa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # httpx -HTTP client and server toolkit for Go microservices. Client side: retry, load balancing, circuit breaking — all as `http.RoundTripper` middleware. Server side: routing, middleware (request ID, recovery, logging), health checks, graceful shutdown. stdlib only, zero external deps. +HTTP client and server toolkit for Go microservices. Client side: retry, load balancing, circuit breaking, request ID propagation, response size limits — all as `http.RoundTripper` middleware. Server side: routing, middleware (request ID, recovery, logging, CORS, rate limiting, body limits, timeouts), health checks, JSON helpers, graceful shutdown. stdlib only, zero external deps. ``` go get git.codelab.vc/pkg/httpx @@ -29,6 +29,16 @@ if err != nil { var user User resp.JSON(&user) + +// PATCH request +resp, err = client.Patch(ctx, "/users/123", strings.NewReader(`{"name":"updated"}`)) + +// Form-encoded request (OAuth, webhooks, etc.) +req, _ := httpx.NewFormRequest(ctx, http.MethodPost, "/oauth/token", url.Values{ + "grant_type": {"client_credentials"}, + "scope": {"read write"}, +}) +resp, err = client.Do(ctx, req) ``` ## Packages @@ -42,7 +52,7 @@ Client middleware is `func(http.RoundTripper) http.RoundTripper`. Use them with | `retry` | Exponential/constant backoff, Retry-After support. Idempotent methods only by default. | | `balancer` | Round robin, failover, weighted random. Optional background health checks. | | `circuitbreaker` | Per-host state machine (closed/open/half-open). Stops hammering dead endpoints. | -| `middleware` | Logging (slog), default headers, bearer/basic auth, panic recovery. | +| `middleware` | Logging (slog), default headers, bearer/basic auth, panic recovery, request ID propagation. | ### Server @@ -56,6 +66,12 @@ Server middleware is `func(http.Handler) http.Handler`. The `server` package pro | `server.Recovery` | Recovers panics, returns 500, logs stack trace. | | `server.Logging` | Structured request logging (method, path, status, duration, request ID). | | `server.HealthHandler` | Liveness (`/healthz`) and readiness (`/readyz`) endpoints with pluggable checkers. | +| `server.CORS` | Cross-origin resource sharing with preflight handling and functional options. | +| `server.RateLimit` | Per-key token bucket rate limiting with IP extraction and `Retry-After`. | +| `server.MaxBodySize` | Limits request body size via `http.MaxBytesReader`. | +| `server.Timeout` | Context-based request timeout, returns 503 on expiry. | +| `server.WriteJSON` | JSON response helper, sets Content-Type and status. | +| `server.WriteError` | JSON error response (`{"error": "..."}`) helper. | | `server.Defaults` | Production preset: RequestID → Recovery → Logging + sensible timeouts. | The client assembles them in this order: @@ -111,9 +127,15 @@ httpClient := &http.Client{ ```go logger := slog.Default() -r := server.NewRouter() -r.HandleFunc("GET /hello", func(w http.ResponseWriter, _ *http.Request) { - w.Write([]byte("world")) +r := server.NewRouter( + // Custom JSON 404 instead of plain text + server.WithNotFoundHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + server.WriteError(w, 404, "not found") + })), +) + +r.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) { + server.WriteJSON(w, 200, map[string]string{"message": "world"}) }) // Groups with middleware @@ -125,10 +147,54 @@ r.Mount("/", server.HealthHandler( func() error { return db.Ping() }, )) -srv := server.New(r, server.Defaults(logger)...) +srv := server.New(r, + append(server.Defaults(logger), + // Protection middleware + server.WithMiddleware( + server.CORS( + server.AllowOrigins("https://app.example.com"), + server.AllowMethods("GET", "POST", "PUT", "PATCH", "DELETE"), + server.AllowHeaders("Authorization", "Content-Type"), + server.MaxAge(3600), + ), + server.RateLimit( + server.WithRate(100), + server.WithBurst(200), + ), + server.MaxBodySize(1<<20), // 1 MB + server.Timeout(30*time.Second), + ), + )..., +) log.Fatal(srv.ListenAndServe()) // graceful shutdown on SIGINT/SIGTERM ``` +## Client request ID propagation + +In microservices, forward the incoming request ID to downstream calls: + +```go +client := httpx.New( + httpx.WithMiddleware(middleware.RequestID()), +) + +// In a server handler — the context already has the request ID from server.RequestID(): +func handler(w http.ResponseWriter, r *http.Request) { + // ID is automatically forwarded as X-Request-Id + resp, err := client.Get(r.Context(), "https://downstream/api") +} +``` + +## Response body limit + +Protect against OOM from unexpectedly large upstream responses: + +```go +client := httpx.New( + httpx.WithMaxResponseBody(10 << 20), // 10 MB max +) +``` + ## Requirements Go 1.24+, stdlib only.