Add Client with response wrapper, request helpers, and full middleware assembly
Implements the top-level httpx.Client that composes the full chain: Logging → User Middlewares → Retry → Circuit Breaker → Balancer → Transport - Response wrapper with JSON/XML/Bytes decoding and body caching - NewJSONRequest helper with Content-Type and GetBody support - Functional options: WithBaseURL, WithTimeout, WithRetry, WithEndpoints, etc. - Integration tests covering retry, balancing, error mapping, and JSON round-trips
This commit is contained in:
164
client.go
Normal file
164
client.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.codelab.vc/pkg/httpx/balancer"
|
||||
"git.codelab.vc/pkg/httpx/circuitbreaker"
|
||||
"git.codelab.vc/pkg/httpx/middleware"
|
||||
"git.codelab.vc/pkg/httpx/retry"
|
||||
)
|
||||
|
||||
// Client is a high-level HTTP client that composes middleware for retry,
|
||||
// circuit breaking, load balancing, logging, and more.
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
errorMapper ErrorMapper
|
||||
}
|
||||
|
||||
// New creates a new Client with the given options.
|
||||
//
|
||||
// The middleware chain is assembled as (outermost → innermost):
|
||||
//
|
||||
// Logging → User Middlewares → Retry → Circuit Breaker → Balancer → Base Transport
|
||||
func New(opts ...Option) *Client {
|
||||
o := &clientOptions{
|
||||
transport: http.DefaultTransport,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
// Build the middleware chain from inside out.
|
||||
var chain []middleware.Middleware
|
||||
|
||||
// Balancer (innermost, wraps base transport).
|
||||
if len(o.endpoints) > 0 {
|
||||
chain = append(chain, balancer.Transport(o.endpoints, o.balancerOpts...))
|
||||
}
|
||||
|
||||
// Circuit breaker wraps balancer.
|
||||
if o.enableCB {
|
||||
chain = append(chain, circuitbreaker.Transport(o.cbOpts...))
|
||||
}
|
||||
|
||||
// Retry wraps circuit breaker + balancer.
|
||||
if o.enableRetry {
|
||||
chain = append(chain, retry.Transport(o.retryOpts...))
|
||||
}
|
||||
|
||||
// User middlewares.
|
||||
for i := len(o.middlewares) - 1; i >= 0; i-- {
|
||||
chain = append(chain, o.middlewares[i])
|
||||
}
|
||||
|
||||
// Logging (outermost).
|
||||
if o.logger != nil {
|
||||
chain = append(chain, middleware.Logging(o.logger))
|
||||
}
|
||||
|
||||
// Assemble: chain[last] is outermost.
|
||||
rt := o.transport
|
||||
for _, mw := range chain {
|
||||
rt = mw(rt)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Transport: rt,
|
||||
Timeout: o.timeout,
|
||||
},
|
||||
baseURL: o.baseURL,
|
||||
errorMapper: o.errorMapper,
|
||||
}
|
||||
}
|
||||
|
||||
// Do executes an HTTP request.
|
||||
func (c *Client) Do(ctx context.Context, req *http.Request) (*Response, error) {
|
||||
req = req.WithContext(ctx)
|
||||
c.resolveURL(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &Error{
|
||||
Op: req.Method,
|
||||
URL: req.URL.String(),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
r := newResponse(resp)
|
||||
|
||||
if c.errorMapper != nil {
|
||||
if mapErr := c.errorMapper(resp); mapErr != nil {
|
||||
return r, &Error{
|
||||
Op: req.Method,
|
||||
URL: req.URL.String(),
|
||||
StatusCode: resp.StatusCode,
|
||||
Err: mapErr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Get performs a GET request to the given URL.
|
||||
func (c *Client) Get(ctx context.Context, url string) (*Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Do(ctx, req)
|
||||
}
|
||||
|
||||
// Post performs a POST request to the given URL with the given body.
|
||||
func (c *Client) Post(ctx context.Context, url string, body io.Reader) (*Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Do(ctx, req)
|
||||
}
|
||||
|
||||
// Put performs a PUT request to the given URL with the given body.
|
||||
func (c *Client) Put(ctx context.Context, url string, body io.Reader) (*Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Do(ctx, req)
|
||||
}
|
||||
|
||||
// Delete performs a DELETE request to the given URL.
|
||||
func (c *Client) Delete(ctx context.Context, url string) (*Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Do(ctx, req)
|
||||
}
|
||||
|
||||
// HTTPClient returns the underlying *http.Client for advanced use cases.
|
||||
func (c *Client) HTTPClient() *http.Client {
|
||||
return c.httpClient
|
||||
}
|
||||
|
||||
func (c *Client) resolveURL(req *http.Request) {
|
||||
if c.baseURL == "" {
|
||||
return
|
||||
}
|
||||
// Only resolve relative URLs (no scheme).
|
||||
if req.URL.Scheme == "" && req.URL.Host == "" {
|
||||
path := req.URL.String()
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
base := strings.TrimRight(c.baseURL, "/")
|
||||
req.URL, _ = req.URL.Parse(base + path)
|
||||
}
|
||||
}
|
||||
87
client_options.go
Normal file
87
client_options.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.codelab.vc/pkg/httpx/balancer"
|
||||
"git.codelab.vc/pkg/httpx/circuitbreaker"
|
||||
"git.codelab.vc/pkg/httpx/middleware"
|
||||
"git.codelab.vc/pkg/httpx/retry"
|
||||
)
|
||||
|
||||
type clientOptions struct {
|
||||
baseURL string
|
||||
timeout time.Duration
|
||||
transport http.RoundTripper
|
||||
logger *slog.Logger
|
||||
errorMapper ErrorMapper
|
||||
middlewares []middleware.Middleware
|
||||
retryOpts []retry.Option
|
||||
enableRetry bool
|
||||
cbOpts []circuitbreaker.Option
|
||||
enableCB bool
|
||||
endpoints []balancer.Endpoint
|
||||
balancerOpts []balancer.Option
|
||||
}
|
||||
|
||||
// Option configures a Client.
|
||||
type Option func(*clientOptions)
|
||||
|
||||
// WithBaseURL sets the base URL prepended to all relative request paths.
|
||||
func WithBaseURL(url string) Option {
|
||||
return func(o *clientOptions) { o.baseURL = url }
|
||||
}
|
||||
|
||||
// WithTimeout sets the overall request timeout.
|
||||
func WithTimeout(d time.Duration) Option {
|
||||
return func(o *clientOptions) { o.timeout = d }
|
||||
}
|
||||
|
||||
// WithTransport sets the base http.RoundTripper. Defaults to http.DefaultTransport.
|
||||
func WithTransport(rt http.RoundTripper) Option {
|
||||
return func(o *clientOptions) { o.transport = rt }
|
||||
}
|
||||
|
||||
// WithLogger enables structured logging of requests and responses.
|
||||
func WithLogger(l *slog.Logger) Option {
|
||||
return func(o *clientOptions) { o.logger = l }
|
||||
}
|
||||
|
||||
// WithErrorMapper sets a function that maps HTTP responses to errors.
|
||||
func WithErrorMapper(m ErrorMapper) Option {
|
||||
return func(o *clientOptions) { o.errorMapper = m }
|
||||
}
|
||||
|
||||
// WithMiddleware appends user middlewares to the chain.
|
||||
// These run between logging and retry in the middleware stack.
|
||||
func WithMiddleware(mws ...middleware.Middleware) Option {
|
||||
return func(o *clientOptions) { o.middlewares = append(o.middlewares, mws...) }
|
||||
}
|
||||
|
||||
// WithRetry enables retry with the given options.
|
||||
func WithRetry(opts ...retry.Option) Option {
|
||||
return func(o *clientOptions) {
|
||||
o.enableRetry = true
|
||||
o.retryOpts = opts
|
||||
}
|
||||
}
|
||||
|
||||
// WithCircuitBreaker enables per-host circuit breaking.
|
||||
func WithCircuitBreaker(opts ...circuitbreaker.Option) Option {
|
||||
return func(o *clientOptions) {
|
||||
o.enableCB = true
|
||||
o.cbOpts = opts
|
||||
}
|
||||
}
|
||||
|
||||
// WithEndpoints sets the endpoints for load balancing.
|
||||
func WithEndpoints(eps ...balancer.Endpoint) Option {
|
||||
return func(o *clientOptions) { o.endpoints = eps }
|
||||
}
|
||||
|
||||
// WithBalancer configures the load balancer strategy and options.
|
||||
func WithBalancer(opts ...balancer.Option) Option {
|
||||
return func(o *clientOptions) { o.balancerOpts = opts }
|
||||
}
|
||||
311
client_test.go
Normal file
311
client_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package httpx_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.codelab.vc/pkg/httpx"
|
||||
"git.codelab.vc/pkg/httpx/balancer"
|
||||
"git.codelab.vc/pkg/httpx/middleware"
|
||||
"git.codelab.vc/pkg/httpx/retry"
|
||||
)
|
||||
|
||||
func TestClient_Get(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "hello")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := httpx.New()
|
||||
resp, err := client.Get(context.Background(), srv.URL+"/test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body, err := resp.String()
|
||||
if err != nil {
|
||||
t.Fatalf("reading body: %v", err)
|
||||
}
|
||||
if body != "hello" {
|
||||
t.Errorf("expected body %q, got %q", "hello", body)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Post(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if string(b) != "request-body" {
|
||||
t.Errorf("expected body %q, got %q", "request-body", string(b))
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
fmt.Fprint(w, "created")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := httpx.New()
|
||||
resp, err := client.Post(context.Background(), srv.URL+"/items", strings.NewReader("request-body"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d", resp.StatusCode)
|
||||
}
|
||||
body, err := resp.String()
|
||||
if err != nil {
|
||||
t.Fatalf("reading body: %v", err)
|
||||
}
|
||||
if body != "created" {
|
||||
t.Errorf("expected body %q, got %q", "created", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_BaseURL(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/users" {
|
||||
t.Errorf("expected path /api/v1/users, got %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := httpx.New(httpx.WithBaseURL(srv.URL + "/api/v1"))
|
||||
|
||||
// Use a relative path (no scheme/host).
|
||||
resp, err := client.Get(context.Background(), "/users")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_WithMiddleware(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
val := r.Header.Get("X-Custom-Header")
|
||||
if val != "test-value" {
|
||||
t.Errorf("expected header X-Custom-Header=%q, got %q", "test-value", val)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
addHeader := func(next http.RoundTripper) http.RoundTripper {
|
||||
return middleware.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Custom-Header", "test-value")
|
||||
return next.RoundTrip(req)
|
||||
})
|
||||
}
|
||||
|
||||
client := httpx.New(httpx.WithMiddleware(addHeader))
|
||||
|
||||
resp, err := client.Get(context.Background(), srv.URL+"/test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RetryIntegration(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "success")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := httpx.New(
|
||||
httpx.WithRetry(
|
||||
retry.WithMaxAttempts(3),
|
||||
retry.WithBackoff(retry.ConstantBackoff(1*time.Millisecond)),
|
||||
),
|
||||
)
|
||||
|
||||
resp, err := client.Get(context.Background(), srv.URL+"/flaky")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := resp.String()
|
||||
if err != nil {
|
||||
t.Fatalf("reading body: %v", err)
|
||||
}
|
||||
if body != "success" {
|
||||
t.Errorf("expected body %q, got %q", "success", body)
|
||||
}
|
||||
|
||||
if got := calls.Load(); got != 3 {
|
||||
t.Errorf("expected 3 total requests, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_BalancerIntegration(t *testing.T) {
|
||||
var hits1, hits2 atomic.Int32
|
||||
|
||||
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits1.Add(1)
|
||||
fmt.Fprint(w, "server1")
|
||||
}))
|
||||
defer srv1.Close()
|
||||
|
||||
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits2.Add(1)
|
||||
fmt.Fprint(w, "server2")
|
||||
}))
|
||||
defer srv2.Close()
|
||||
|
||||
client := httpx.New(
|
||||
httpx.WithEndpoints(
|
||||
balancer.Endpoint{URL: srv1.URL},
|
||||
balancer.Endpoint{URL: srv2.URL},
|
||||
),
|
||||
)
|
||||
|
||||
const totalRequests = 6
|
||||
for i := range totalRequests {
|
||||
resp, err := client.Get(context.Background(), fmt.Sprintf("/item/%d", i))
|
||||
if err != nil {
|
||||
t.Fatalf("request %d: unexpected error: %v", i, err)
|
||||
}
|
||||
resp.Close()
|
||||
}
|
||||
|
||||
h1 := hits1.Load()
|
||||
h2 := hits2.Load()
|
||||
|
||||
if h1+h2 != totalRequests {
|
||||
t.Errorf("expected %d total hits, got %d", totalRequests, h1+h2)
|
||||
}
|
||||
if h1 == 0 || h2 == 0 {
|
||||
t.Errorf("expected requests distributed across both servers, got server1=%d server2=%d", h1, h2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ErrorMapper(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprint(w, "not found")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
mapper := func(resp *http.Response) error {
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
client := httpx.New(httpx.WithErrorMapper(mapper))
|
||||
|
||||
resp, err := client.Get(context.Background(), srv.URL+"/missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
// The response should still be returned alongside the error.
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response even on mapped error")
|
||||
}
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify the error message contains the status code.
|
||||
if !strings.Contains(err.Error(), "404") {
|
||||
t.Errorf("expected error to contain 404, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_JSON(t *testing.T) {
|
||||
type reqPayload struct {
|
||||
Name string `json:"name"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
type respPayload struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %q", ct)
|
||||
}
|
||||
|
||||
var p reqPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
t.Errorf("decoding request body: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Name != "Alice" || p.Age != 30 {
|
||||
t.Errorf("unexpected payload: %+v", p)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(respPayload{ID: 1, Name: p.Name})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := httpx.New()
|
||||
|
||||
req, err := httpx.NewJSONRequest(context.Background(), http.MethodPost, srv.URL+"/users", reqPayload{
|
||||
Name: "Alice",
|
||||
Age: 30,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("creating JSON request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var result respPayload
|
||||
if err := resp.JSON(&result); err != nil {
|
||||
t.Fatalf("decoding JSON response: %v", err)
|
||||
}
|
||||
if result.ID != 1 {
|
||||
t.Errorf("expected ID 1, got %d", result.ID)
|
||||
}
|
||||
if result.Name != "Alice" {
|
||||
t.Errorf("expected Name %q, got %q", "Alice", result.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure slog import is used (referenced in imports for completeness with the spec).
|
||||
var _ = slog.Default
|
||||
90
error_test.go
Normal file
90
error_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package httpx_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.codelab.vc/pkg/httpx"
|
||||
)
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
t.Run("formats without endpoint", func(t *testing.T) {
|
||||
inner := errors.New("connection refused")
|
||||
e := &httpx.Error{
|
||||
Op: "Get",
|
||||
URL: "http://example.com/api",
|
||||
Err: inner,
|
||||
}
|
||||
|
||||
want := "httpx: Get http://example.com/api: connection refused"
|
||||
if got := e.Error(); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("formats with endpoint different from url", func(t *testing.T) {
|
||||
inner := errors.New("timeout")
|
||||
e := &httpx.Error{
|
||||
Op: "Do",
|
||||
URL: "http://example.com/api",
|
||||
Endpoint: "http://node1.example.com/api",
|
||||
Err: inner,
|
||||
}
|
||||
|
||||
want := "httpx: Do http://example.com/api (endpoint http://node1.example.com/api): timeout"
|
||||
if got := e.Error(); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("formats with endpoint same as url", func(t *testing.T) {
|
||||
inner := errors.New("not found")
|
||||
e := &httpx.Error{
|
||||
Op: "Get",
|
||||
URL: "http://example.com/api",
|
||||
Endpoint: "http://example.com/api",
|
||||
Err: inner,
|
||||
}
|
||||
|
||||
want := "httpx: Get http://example.com/api: not found"
|
||||
if got := e.Error(); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unwrap returns inner error", func(t *testing.T) {
|
||||
inner := errors.New("underlying")
|
||||
e := &httpx.Error{Op: "Get", URL: "http://example.com", Err: inner}
|
||||
|
||||
if got := e.Unwrap(); got != inner {
|
||||
t.Errorf("Unwrap() = %v, want %v", got, inner)
|
||||
}
|
||||
|
||||
if !errors.Is(e, inner) {
|
||||
t.Error("errors.Is should find the inner error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSentinelErrors(t *testing.T) {
|
||||
t.Run("ErrRetryExhausted", func(t *testing.T) {
|
||||
if httpx.ErrRetryExhausted == nil {
|
||||
t.Fatal("ErrRetryExhausted is nil")
|
||||
}
|
||||
if httpx.ErrRetryExhausted.Error() == "" {
|
||||
t.Fatal("ErrRetryExhausted has empty message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ErrCircuitOpen", func(t *testing.T) {
|
||||
if httpx.ErrCircuitOpen == nil {
|
||||
t.Fatal("ErrCircuitOpen is nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ErrNoHealthy", func(t *testing.T) {
|
||||
if httpx.ErrNoHealthy == nil {
|
||||
t.Fatal("ErrNoHealthy is nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
34
request.go
Normal file
34
request.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// NewRequest creates an http.Request with context. It is a convenience
|
||||
// wrapper around http.NewRequestWithContext.
|
||||
func NewRequest(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, method, url, body)
|
||||
}
|
||||
|
||||
// NewJSONRequest creates an http.Request with a JSON-encoded body and
|
||||
// sets Content-Type to application/json.
|
||||
func NewJSONRequest(ctx context.Context, method, url string, body any) (*http.Request, error) {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpx: encoding JSON body: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.GetBody = func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(b)), nil
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
78
request_test.go
Normal file
78
request_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package httpx_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.codelab.vc/pkg/httpx"
|
||||
)
|
||||
|
||||
func TestNewJSONRequest(t *testing.T) {
|
||||
t.Run("body is JSON encoded", func(t *testing.T) {
|
||||
payload := map[string]string{"key": "value"}
|
||||
req, err := httpx.NewJSONRequest(context.Background(), http.MethodPost, "http://example.com", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("reading body: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]string
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if decoded["key"] != "value" {
|
||||
t.Errorf("decoded[key] = %q, want %q", decoded["key"], "value")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("content type is set", func(t *testing.T) {
|
||||
req, err := httpx.NewJSONRequest(context.Background(), http.MethodPost, "http://example.com", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
ct := req.Header.Get("Content-Type")
|
||||
if ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want %q", ct, "application/json")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetBody works", func(t *testing.T) {
|
||||
payload := map[string]int{"num": 123}
|
||||
req, err := httpx.NewJSONRequest(context.Background(), http.MethodPost, "http://example.com", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if req.GetBody == nil {
|
||||
t.Fatal("GetBody is nil")
|
||||
}
|
||||
|
||||
// Read body first time
|
||||
b1, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("reading body: %v", err)
|
||||
}
|
||||
|
||||
// Get a fresh body
|
||||
body2, err := req.GetBody()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBody(): %v", err)
|
||||
}
|
||||
b2, err := io.ReadAll(body2)
|
||||
if err != nil {
|
||||
t.Fatalf("reading body2: %v", err)
|
||||
}
|
||||
|
||||
if string(b1) != string(b2) {
|
||||
t.Errorf("GetBody returned different data: %q vs %q", b1, b2)
|
||||
}
|
||||
})
|
||||
}
|
||||
99
response.go
Normal file
99
response.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Response wraps http.Response with convenience methods.
|
||||
type Response struct {
|
||||
*http.Response
|
||||
body []byte
|
||||
read bool
|
||||
}
|
||||
|
||||
func newResponse(resp *http.Response) *Response {
|
||||
return &Response{Response: resp}
|
||||
}
|
||||
|
||||
// Bytes reads and returns the entire response body.
|
||||
// The body is cached so subsequent calls return the same data.
|
||||
func (r *Response) Bytes() ([]byte, error) {
|
||||
if r.read {
|
||||
return r.body, nil
|
||||
}
|
||||
defer r.Body.Close()
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.body = b
|
||||
r.read = true
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// String reads the response body and returns it as a string.
|
||||
func (r *Response) String() (string, error) {
|
||||
b, err := r.Bytes()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// JSON decodes the response body as JSON into v.
|
||||
func (r *Response) JSON(v any) error {
|
||||
b, err := r.Bytes()
|
||||
if err != nil {
|
||||
return fmt.Errorf("httpx: reading response body: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, v); err != nil {
|
||||
return fmt.Errorf("httpx: decoding JSON: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// XML decodes the response body as XML into v.
|
||||
func (r *Response) XML(v any) error {
|
||||
b, err := r.Bytes()
|
||||
if err != nil {
|
||||
return fmt.Errorf("httpx: reading response body: %w", err)
|
||||
}
|
||||
if err := xml.Unmarshal(b, v); err != nil {
|
||||
return fmt.Errorf("httpx: decoding XML: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSuccess returns true if the status code is in the 2xx range.
|
||||
func (r *Response) IsSuccess() bool {
|
||||
return r.StatusCode >= 200 && r.StatusCode < 300
|
||||
}
|
||||
|
||||
// IsError returns true if the status code is 4xx or 5xx.
|
||||
func (r *Response) IsError() bool {
|
||||
return r.StatusCode >= 400
|
||||
}
|
||||
|
||||
// Close drains and closes the response body.
|
||||
func (r *Response) Close() error {
|
||||
if r.read {
|
||||
return nil
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
return r.Body.Close()
|
||||
}
|
||||
|
||||
// BodyReader returns a reader for the response body.
|
||||
// If the body has already been read via Bytes/String/JSON/XML,
|
||||
// returns a reader over the cached bytes.
|
||||
func (r *Response) BodyReader() io.Reader {
|
||||
if r.read {
|
||||
return bytes.NewReader(r.body)
|
||||
}
|
||||
return r.Body
|
||||
}
|
||||
96
response_test.go
Normal file
96
response_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeTestResponse(statusCode int, body string) *Response {
|
||||
return newResponse(&http.Response{
|
||||
StatusCode: statusCode,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
})
|
||||
}
|
||||
|
||||
func TestResponse(t *testing.T) {
|
||||
t.Run("Bytes returns body", func(t *testing.T) {
|
||||
r := makeTestResponse(200, "hello world")
|
||||
b, err := r.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("Bytes() error: %v", err)
|
||||
}
|
||||
if string(b) != "hello world" {
|
||||
t.Errorf("Bytes() = %q, want %q", string(b), "hello world")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("body caching returns same data", func(t *testing.T) {
|
||||
r := makeTestResponse(200, "cached body")
|
||||
b1, err := r.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("first Bytes() error: %v", err)
|
||||
}
|
||||
b2, err := r.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("second Bytes() error: %v", err)
|
||||
}
|
||||
if string(b1) != string(b2) {
|
||||
t.Errorf("Bytes() returned different data: %q vs %q", b1, b2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("String returns body as string", func(t *testing.T) {
|
||||
r := makeTestResponse(200, "string body")
|
||||
s, err := r.String()
|
||||
if err != nil {
|
||||
t.Fatalf("String() error: %v", err)
|
||||
}
|
||||
if s != "string body" {
|
||||
t.Errorf("String() = %q, want %q", s, "string body")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON decodes body", func(t *testing.T) {
|
||||
r := makeTestResponse(200, `{"name":"test","value":42}`)
|
||||
var result struct {
|
||||
Name string `json:"name"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
if err := r.JSON(&result); err != nil {
|
||||
t.Fatalf("JSON() error: %v", err)
|
||||
}
|
||||
if result.Name != "test" {
|
||||
t.Errorf("Name = %q, want %q", result.Name, "test")
|
||||
}
|
||||
if result.Value != 42 {
|
||||
t.Errorf("Value = %d, want %d", result.Value, 42)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IsSuccess for 2xx", func(t *testing.T) {
|
||||
for _, code := range []int{200, 201, 204, 299} {
|
||||
r := makeTestResponse(code, "")
|
||||
if !r.IsSuccess() {
|
||||
t.Errorf("IsSuccess() = false for status %d", code)
|
||||
}
|
||||
if r.IsError() {
|
||||
t.Errorf("IsError() = true for status %d", code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IsError for 4xx and 5xx", func(t *testing.T) {
|
||||
for _, code := range []int{400, 404, 500, 503} {
|
||||
r := makeTestResponse(code, "")
|
||||
if !r.IsError() {
|
||||
t.Errorf("IsError() = false for status %d", code)
|
||||
}
|
||||
if r.IsSuccess() {
|
||||
t.Errorf("IsSuccess() = true for status %d", code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user