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:
2026-03-20 14:22:22 +03:00
parent a90c4cd7fa
commit f9a05f5c57
8 changed files with 959 additions and 0 deletions

164
client.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}
})
}