diff --git a/README.md b/README.md index 2a97caa..fdfe400 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,19 @@ client := httpx.New( ) ``` +## Examples + +See the [`examples/`](examples/) directory for runnable programs: + +| Example | Description | +|---------|-------------| +| [`basic-client`](examples/basic-client/) | HTTP client with retry, timeout, logging, and response size limit | +| [`form-request`](examples/form-request/) | Form-encoded POST requests (OAuth, webhooks) | +| [`load-balancing`](examples/load-balancing/) | Multi-endpoint client with weighted balancing, circuit breaker, and health checks | +| [`server-basic`](examples/server-basic/) | Server with routing, groups, JSON helpers, health checks, and custom 404 | +| [`server-protected`](examples/server-protected/) | Production server with CORS, rate limiting, body limits, and timeouts | +| [`request-id-propagation`](examples/request-id-propagation/) | Request ID forwarding between server and client for distributed tracing | + ## Requirements Go 1.24+, stdlib only. diff --git a/examples/basic-client/main.go b/examples/basic-client/main.go new file mode 100644 index 0000000..2439e27 --- /dev/null +++ b/examples/basic-client/main.go @@ -0,0 +1,67 @@ +// Basic HTTP client with retry, timeout, and structured logging. +package main + +import ( + "context" + "fmt" + "log" + "log/slog" + "time" + + "git.codelab.vc/pkg/httpx" + "git.codelab.vc/pkg/httpx/middleware" + "git.codelab.vc/pkg/httpx/retry" +) + +func main() { + client := httpx.New( + httpx.WithBaseURL("https://httpbin.org"), + httpx.WithTimeout(10*time.Second), + httpx.WithRetry( + retry.WithMaxAttempts(3), + retry.WithBackoff(retry.ExponentialBackoff(100*time.Millisecond, 2*time.Second, true)), + ), + httpx.WithMiddleware( + middleware.UserAgent("httpx-example/1.0"), + ), + httpx.WithMaxResponseBody(1<<20), // 1 MB limit + httpx.WithLogger(slog.Default()), + ) + defer client.Close() + + ctx := context.Background() + + // GET request. + resp, err := client.Get(ctx, "/get") + if err != nil { + log.Fatal(err) + } + + body, err := resp.String() + if err != nil { + log.Fatal(err) + } + fmt.Printf("GET /get → %d (%d bytes)\n", resp.StatusCode, len(body)) + + // POST with JSON body. + type payload struct { + Name string `json:"name"` + Email string `json:"email"` + } + + req, err := httpx.NewJSONRequest(ctx, "POST", "/post", payload{ + Name: "Alice", + Email: "alice@example.com", + }) + if err != nil { + log.Fatal(err) + } + + resp, err = client.Do(ctx, req) + if err != nil { + log.Fatal(err) + } + defer resp.Close() + + fmt.Printf("POST /post → %d\n", resp.StatusCode) +} diff --git a/examples/form-request/main.go b/examples/form-request/main.go new file mode 100644 index 0000000..aaade8e --- /dev/null +++ b/examples/form-request/main.go @@ -0,0 +1,41 @@ +// Demonstrates form-encoded requests for OAuth token endpoints and similar APIs. +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + + "git.codelab.vc/pkg/httpx" +) + +func main() { + client := httpx.New( + httpx.WithBaseURL("https://httpbin.org"), + ) + defer client.Close() + + ctx := context.Background() + + // Form-encoded POST, common for OAuth token endpoints. + req, err := httpx.NewFormRequest(ctx, http.MethodPost, "/post", url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"my-app"}, + "client_secret": {"secret"}, + "scope": {"read write"}, + }) + if err != nil { + log.Fatal(err) + } + + resp, err := client.Do(ctx, req) + if err != nil { + log.Fatal(err) + } + defer resp.Close() + + body, _ := resp.String() + fmt.Printf("Status: %d\nBody: %s\n", resp.StatusCode, body) +} diff --git a/examples/load-balancing/main.go b/examples/load-balancing/main.go new file mode 100644 index 0000000..8157d5f --- /dev/null +++ b/examples/load-balancing/main.go @@ -0,0 +1,54 @@ +// Demonstrates load balancing across multiple backend endpoints with +// circuit breaking and health-checked failover. +package main + +import ( + "context" + "fmt" + "log" + "log/slog" + "time" + + "git.codelab.vc/pkg/httpx" + "git.codelab.vc/pkg/httpx/balancer" + "git.codelab.vc/pkg/httpx/circuitbreaker" + "git.codelab.vc/pkg/httpx/retry" +) + +func main() { + client := httpx.New( + httpx.WithEndpoints( + balancer.Endpoint{URL: "http://localhost:8081", Weight: 3}, + balancer.Endpoint{URL: "http://localhost:8082", Weight: 1}, + ), + httpx.WithBalancer( + balancer.WithStrategy(balancer.WeightedRandom()), + balancer.WithHealthCheck( + balancer.WithHealthInterval(5*time.Second), + balancer.WithHealthPath("/healthz"), + ), + ), + httpx.WithCircuitBreaker( + circuitbreaker.WithFailureThreshold(5), + circuitbreaker.WithOpenDuration(30*time.Second), + ), + httpx.WithRetry( + retry.WithMaxAttempts(3), + ), + httpx.WithLogger(slog.Default()), + ) + defer client.Close() + + ctx := context.Background() + + for i := range 5 { + resp, err := client.Get(ctx, fmt.Sprintf("/api/item/%d", i)) + if err != nil { + log.Printf("request %d failed: %v", i, err) + continue + } + + body, _ := resp.String() + fmt.Printf("request %d → %d: %s\n", i, resp.StatusCode, body) + } +} diff --git a/examples/request-id-propagation/main.go b/examples/request-id-propagation/main.go new file mode 100644 index 0000000..af72cf1 --- /dev/null +++ b/examples/request-id-propagation/main.go @@ -0,0 +1,54 @@ +// Demonstrates request ID propagation between server and client. +// The server assigns a request ID to incoming requests, and the client +// middleware forwards it to downstream services via X-Request-Id header. +package main + +import ( + "fmt" + "log" + "log/slog" + "net/http" + + "git.codelab.vc/pkg/httpx" + "git.codelab.vc/pkg/httpx/middleware" + "git.codelab.vc/pkg/httpx/server" +) + +func main() { + logger := slog.Default() + + // Client that propagates request IDs from context. + client := httpx.New( + httpx.WithBaseURL("http://localhost:9090"), + httpx.WithMiddleware( + middleware.RequestID(), // Picks up ID from context, sets X-Request-Id. + ), + ) + defer client.Close() + + r := server.NewRouter() + + r.HandleFunc("GET /proxy", func(w http.ResponseWriter, r *http.Request) { + // The request ID is in r.Context() thanks to server.RequestID(). + id := server.RequestIDFromContext(r.Context()) + logger.Info("handling request", "request_id", id) + + // Client automatically forwards the request ID to downstream. + resp, err := client.Get(r.Context(), "/downstream") + if err != nil { + server.WriteError(w, http.StatusBadGateway, fmt.Sprintf("downstream error: %v", err)) + return + } + defer resp.Close() + + body, _ := resp.String() + server.WriteJSON(w, http.StatusOK, map[string]string{ + "request_id": id, + "downstream_response": body, + }) + }) + + // Server with RequestID middleware that assigns IDs to incoming requests. + srv := server.New(r, server.Defaults(logger)...) + log.Fatal(srv.ListenAndServe()) +} diff --git a/examples/server-basic/main.go b/examples/server-basic/main.go new file mode 100644 index 0000000..5144536 --- /dev/null +++ b/examples/server-basic/main.go @@ -0,0 +1,54 @@ +// Basic HTTP server with routing, middleware, health checks, and graceful shutdown. +package main + +import ( + "log" + "log/slog" + "net/http" + + "git.codelab.vc/pkg/httpx/server" +) + +func main() { + logger := slog.Default() + + r := server.NewRouter( + server.WithNotFoundHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + server.WriteError(w, http.StatusNotFound, "resource not found") + })), + ) + + // Public endpoints. + r.HandleFunc("GET /hello", func(w http.ResponseWriter, _ *http.Request) { + server.WriteJSON(w, http.StatusOK, map[string]string{ + "message": "Hello, World!", + }) + }) + + // API group with shared prefix. + api := r.Group("/api/v1") + api.HandleFunc("GET /users/{id}", getUser) + api.HandleFunc("POST /users", createUser) + + // Health checks. + r.Mount("/", server.HealthHandler()) + + // Server with production defaults (RequestID → Recovery → Logging). + srv := server.New(r, server.Defaults(logger)...) + log.Fatal(srv.ListenAndServe()) +} + +func getUser(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + server.WriteJSON(w, http.StatusOK, map[string]string{ + "id": id, + "name": "Alice", + }) +} + +func createUser(w http.ResponseWriter, r *http.Request) { + server.WriteJSON(w, http.StatusCreated, map[string]any{ + "id": 1, + "message": "user created", + }) +} diff --git a/examples/server-protected/main.go b/examples/server-protected/main.go new file mode 100644 index 0000000..94be2aa --- /dev/null +++ b/examples/server-protected/main.go @@ -0,0 +1,61 @@ +// Production server with CORS, rate limiting, body size limits, and timeouts. +package main + +import ( + "log" + "log/slog" + "net/http" + "time" + + "git.codelab.vc/pkg/httpx/server" +) + +func main() { + logger := slog.Default() + + r := server.NewRouter( + server.WithNotFoundHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + server.WriteError(w, http.StatusNotFound, "not found") + })), + ) + + r.HandleFunc("GET /api/data", func(w http.ResponseWriter, _ *http.Request) { + server.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + r.HandleFunc("POST /api/upload", func(w http.ResponseWriter, r *http.Request) { + // Body is already limited by MaxBodySize middleware. + server.WriteJSON(w, http.StatusAccepted, map[string]string{"status": "received"}) + }) + + r.Mount("/", server.HealthHandler()) + + srv := server.New(r, + append( + server.Defaults(logger), + server.WithMiddleware( + // CORS for browser-facing APIs. + server.CORS( + server.AllowOrigins("https://app.example.com", "https://admin.example.com"), + server.AllowMethods("GET", "POST", "PUT", "PATCH", "DELETE"), + server.AllowHeaders("Authorization", "Content-Type"), + server.ExposeHeaders("X-Request-Id"), + server.AllowCredentials(true), + server.MaxAge(3600), + ), + // Rate limit: 100 req/s per IP, burst of 200. + server.RateLimit( + server.WithRate(100), + server.WithBurst(200), + ), + // Limit request body to 1 MB. + server.MaxBodySize(1<<20), + // Per-request timeout of 30 seconds. + server.Timeout(30*time.Second), + ), + server.WithAddr(":8080"), + )..., + ) + + log.Fatal(srv.ListenAndServe()) +}