Simple HTTP Client

Exercise: Simple HTTP Client

Difficulty - Beginner

Learning Objectives

  • Master HTTP client operations in Go
  • Make GET, POST, PUT, DELETE requests
  • Handle JSON request/response bodies
  • Set custom headers and timeouts
  • Handle HTTP errors properly

Problem Statement

Create an HTTPClient package that wraps Go's net/http package with convenient methods for common HTTP operations.

Implement these components:

  1. Client: A configurable HTTP client with timeout and headers
  2. GET: Make GET requests and parse JSON responses
  3. POST: Make POST requests with JSON bodies
  4. PUT: Make PUT requests to update resources
  5. DELETE: Make DELETE requests

Data Structures

 1package httpclient
 2
 3import (
 4    "net/http"
 5    "time"
 6)
 7
 8type Client struct {
 9    baseURL    string
10    httpClient *http.Client
11    headers    map[string]string
12}
13
14type Response struct {
15    StatusCode int
16    Body       []byte
17    Headers    http.Header
18}

Function Signatures

 1// NewClient creates a new HTTP client
 2func NewClient(baseURL string, timeout time.Duration) *Client
 3
 4// SetHeader sets a default header for all requests
 5func SetHeader(key, value string)
 6
 7// Get makes a GET request
 8func Get(path string)
 9
10// Post makes a POST request with JSON body
11func Post(path string, body interface{})
12
13// Put makes a PUT request with JSON body
14func Put(path string, body interface{})
15
16// Delete makes a DELETE request
17func Delete(path string)
18
19// DecodeJSON decodes JSON response body into target
20func DecodeJSON(target interface{}) error

Example Usage

 1package main
 2
 3import (
 4    "fmt"
 5    "log"
 6    "time"
 7    "httpclient"
 8)
 9
10type User struct {
11    ID    int    `json:"id"`
12    Name  string `json:"name"`
13    Email string `json:"email"`
14}
15
16func main() {
17    // Create client with 10-second timeout
18    client := httpclient.NewClient("https://jsonplaceholder.typicode.com", 10*time.Second)
19
20    // Set default headers
21    client.SetHeader("Accept", "application/json")
22    client.SetHeader("User-Agent", "MyApp/1.0")
23
24    // GET request
25    resp, err := client.Get("/users/1")
26    if err != nil {
27        log.Fatal(err)
28    }
29
30    var user User
31    if err := resp.DecodeJSON(&user); err != nil {
32        log.Fatal(err)
33    }
34
35    fmt.Printf("User: %+v\n", user)
36
37    // POST request
38    newUser := User{
39        Name:  "John Doe",
40        Email: "john@example.com",
41    }
42
43    resp, err = client.Post("/users", newUser)
44    if err != nil {
45        log.Fatal(err)
46    }
47
48    fmt.Printf("Created user, status: %d\n", resp.StatusCode)
49
50    // PUT request
51    user.Name = "Jane Doe"
52    resp, err = client.Put("/users/1", user)
53    if err != nil {
54        log.Fatal(err)
55    }
56
57    fmt.Printf("Updated user, status: %d\n", resp.StatusCode)
58
59    // DELETE request
60    resp, err = client.Delete("/users/1")
61    if err != nil {
62        log.Fatal(err)
63    }
64
65    fmt.Printf("Deleted user, status: %d\n", resp.StatusCode)
66}

Requirements

  1. Support base URL to avoid repeating it in every request
  2. Allow setting default headers that apply to all requests
  3. Support custom timeout per client
  4. Handle JSON encoding/decoding automatically
  5. Return both successful responses and HTTP error responses

Solution

Click to see the complete solution
  1package httpclient
  2
  3import (
  4    "bytes"
  5    "encoding/json"
  6    "fmt"
  7    "io"
  8    "net/http"
  9    "time"
 10)
 11
 12type Client struct {
 13    baseURL    string
 14    httpClient *http.Client
 15    headers    map[string]string
 16}
 17
 18type Response struct {
 19    StatusCode int
 20    Body       []byte
 21    Headers    http.Header
 22}
 23
 24// NewClient creates a new HTTP client
 25func NewClient(baseURL string, timeout time.Duration) *Client {
 26    return &Client{
 27        baseURL: baseURL,
 28        httpClient: &http.Client{
 29            Timeout: timeout,
 30        },
 31        headers: make(map[string]string),
 32    }
 33}
 34
 35// SetHeader sets a default header for all requests
 36func SetHeader(key, value string) {
 37    c.headers[key] = value
 38}
 39
 40// Get makes a GET request
 41func Get(path string) {
 42    return c.doRequest("GET", path, nil)
 43}
 44
 45// Post makes a POST request with JSON body
 46func Post(path string, body interface{}) {
 47    return c.doRequest("POST", path, body)
 48}
 49
 50// Put makes a PUT request with JSON body
 51func Put(path string, body interface{}) {
 52    return c.doRequest("PUT", path, body)
 53}
 54
 55// Delete makes a DELETE request
 56func Delete(path string) {
 57    return c.doRequest("DELETE", path, nil)
 58}
 59
 60// doRequest performs the actual HTTP request
 61func doRequest(method, path string, body interface{}) {
 62    // Build full URL
 63    url := c.baseURL + path
 64
 65    // Encode body to JSON if provided
 66    var bodyReader io.Reader
 67    if body != nil {
 68        jsonData, err := json.Marshal(body)
 69        if err != nil {
 70            return nil, fmt.Errorf("marshaling body: %w", err)
 71        }
 72        bodyReader = bytes.NewBuffer(jsonData)
 73    }
 74
 75    // Create request
 76    req, err := http.NewRequest(method, url, bodyReader)
 77    if err != nil {
 78        return nil, fmt.Errorf("creating request: %w", err)
 79    }
 80
 81    // Set default headers
 82    for key, value := range c.headers {
 83        req.Header.Set(key, value)
 84    }
 85
 86    // Set Content-Type for requests with body
 87    if body != nil {
 88        req.Header.Set("Content-Type", "application/json")
 89    }
 90
 91    // Execute request
 92    resp, err := c.httpClient.Do(req)
 93    if err != nil {
 94        return nil, fmt.Errorf("executing request: %w", err)
 95    }
 96    defer resp.Body.Close()
 97
 98    // Read response body
 99    respBody, err := io.ReadAll(resp.Body)
100    if err != nil {
101        return nil, fmt.Errorf("reading response: %w", err)
102    }
103
104    // Create response object
105    response := &Response{
106        StatusCode: resp.StatusCode,
107        Body:       respBody,
108        Headers:    resp.Header,
109    }
110
111    // Check for HTTP errors
112    if resp.StatusCode >= 400 {
113        return response, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
114    }
115
116    return response, nil
117}
118
119// DecodeJSON decodes JSON response body into target
120func DecodeJSON(target interface{}) error {
121    if err := json.Unmarshal(r.Body, target); err != nil {
122        return fmt.Errorf("decoding JSON: %w", err)
123    }
124    return nil
125}
126
127// String returns the response body as a string
128func String() string {
129    return string(r.Body)
130}

Explanation

Client Structure:

  • Holds base URL to avoid repetition
  • Contains http.Client with configurable timeout
  • Stores default headers applied to all requests

NewClient:

  • Initializes client with base URL and timeout
  • Creates underlying http.Client with timeout
  • Initializes empty headers map

SetHeader:

  • Stores headers that will be applied to every request
  • Useful for authentication tokens, API keys, etc.
  • Can be called multiple times to set different headers

HTTP Methods:

  • Wrapper methods for common HTTP verbs
  • All delegate to doRequest() for actual work
  • Provide clean API for users

doRequest:

  1. Constructs full URL from base URL and path
  2. Marshals body to JSON if provided
  3. Creates http.Request with method and URL
  4. Applies default headers from client
  5. Sets Content-Type: application/json for body requests
  6. Executes request with httpClient.Do()
  7. Reads response body completely
  8. Returns error for HTTP 4xx/5xx status codes

DecodeJSON:

  • Convenience method to unmarshal JSON response
  • Takes pointer to target struct
  • Returns error if unmarshal fails

Response Structure:

  • Contains all important response information
  • StatusCode for checking success/failure
  • Body as byte slice for flexibility
  • Headers for accessing response headers

HTTP Client Best Practices

1. Always set timeouts:

1client := &http.Client{
2    Timeout: 10 * time.Second,
3}

2. Read and close response body:

1resp, err := http.Get(url)
2if err != nil {
3    return err
4}
5defer resp.Body.Close()
6
7body, err := io.ReadAll(resp.Body)

3. Check status codes:

1if resp.StatusCode != http.StatusOK {
2    return fmt.Errorf("unexpected status: %d", resp.StatusCode)
3}

4. Set appropriate headers:

1req.Header.Set("Content-Type", "application/json")
2req.Header.Set("Authorization", "Bearer "+token)

5. Handle context cancellation:

1ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
2defer cancel()
3
4req = req.WithContext(ctx)

Advanced Features

With Context Support:

1func GetWithContext(ctx context.Context, path string) {
2    req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil)
3    if err != nil {
4        return nil, err
5    }
6    // Apply headers and execute...
7}

With Retry Logic:

 1func doRequestWithRetry(method, path string, body interface{}, maxRetries int) {
 2    var lastErr error
 3
 4    for i := 0; i < maxRetries; i++ {
 5        resp, err := c.doRequest(method, path, body)
 6        if err == nil {
 7            return resp, nil
 8        }
 9
10        lastErr = err
11        time.Sleep(time.Second * time.Duration(i+1))
12    }
13
14    return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
15}

Test Cases

 1package httpclient
 2
 3import (
 4    "encoding/json"
 5    "net/http"
 6    "net/http/httptest"
 7    "testing"
 8    "time"
 9)
10
11func TestGet(t *testing.T) {
12    // Create test server
13    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14        if r.Method != "GET" {
15            t.Errorf("Expected GET request, got %s", r.Method)
16        }
17
18        w.WriteHeader(http.StatusOK)
19        json.NewEncoder(w).Encode(map[string]string{"message": "success"})
20    }))
21    defer server.Close()
22
23    // Test client
24    client := NewClient(server.URL, 5*time.Second)
25    resp, err := client.Get("/test")
26
27    if err != nil {
28        t.Fatalf("Get failed: %v", err)
29    }
30
31    if resp.StatusCode != http.StatusOK {
32        t.Errorf("Expected status 200, got %d", resp.StatusCode)
33    }
34
35    var result map[string]string
36    if err := resp.DecodeJSON(&result); err != nil {
37        t.Fatalf("DecodeJSON failed: %v", err)
38    }
39
40    if result["message"] != "success" {
41        t.Errorf("Expected message 'success', got %s", result["message"])
42    }
43}
44
45func TestPost(t *testing.T) {
46    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47        if r.Method != "POST" {
48            t.Errorf("Expected POST request, got %s", r.Method)
49        }
50
51        if r.Header.Get("Content-Type") != "application/json" {
52            t.Error("Expected Content-Type: application/json")
53        }
54
55        w.WriteHeader(http.StatusCreated)
56        w.Write([]byte(`{"id": 123}`))
57    }))
58    defer server.Close()
59
60    client := NewClient(server.URL, 5*time.Second)
61    body := map[string]string{"name": "test"}
62
63    resp, err := client.Post("/users", body)
64    if err != nil {
65        t.Fatalf("Post failed: %v", err)
66    }
67
68    if resp.StatusCode != http.StatusCreated {
69        t.Errorf("Expected status 201, got %d", resp.StatusCode)
70    }
71}
72
73func TestSetHeader(t *testing.T) {
74    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75        if r.Header.Get("X-Custom-Header") != "test-value" {
76            t.Error("Custom header not set")
77        }
78        w.WriteHeader(http.StatusOK)
79    }))
80    defer server.Close()
81
82    client := NewClient(server.URL, 5*time.Second)
83    client.SetHeader("X-Custom-Header", "test-value")
84
85    _, err := client.Get("/test")
86    if err != nil {
87        t.Fatalf("Get failed: %v", err)
88    }
89}

Bonus Challenges

  1. Authentication: Add authentication support
1func SetBearerToken(token string)
2func SetBasicAuth(username, password string)
  1. File Upload: Support multipart file uploads
1func UploadFile(path, fieldName, filename string, file io.Reader)
  1. Query Parameters: Support URL query parameters
1func GetWithParams(path string, params map[string]string)
  1. Response Caching: Implement basic response caching
1type CachedClient struct {
2    *Client
3    cache map[string]*Response
4}

Key Takeaways

  • http.Client should be reused, not created for each request
  • Always set timeouts to prevent hanging requests
  • Close response bodies with defer to prevent resource leaks
  • Check status codes - not all responses are errors from Go's perspective
  • Use httptest package for testing HTTP clients
  • Context should be used for cancellation and deadlines

HTTP clients are fundamental to modern applications. Understanding proper HTTP client usage, including timeouts, headers, and error handling, is essential for building reliable networked applications.