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:
- Client: A configurable HTTP client with timeout and headers
- GET: Make GET requests and parse JSON responses
- POST: Make POST requests with JSON bodies
- PUT: Make PUT requests to update resources
- 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
- Support base URL to avoid repeating it in every request
- Allow setting default headers that apply to all requests
- Support custom timeout per client
- Handle JSON encoding/decoding automatically
- 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.Clientwith configurable timeout - Stores default headers applied to all requests
NewClient:
- Initializes client with base URL and timeout
- Creates underlying
http.Clientwith 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:
- Constructs full URL from base URL and path
- Marshals body to JSON if provided
- Creates
http.Requestwith method and URL - Applies default headers from client
- Sets
Content-Type: application/jsonfor body requests - Executes request with
httpClient.Do() - Reads response body completely
- 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
- Authentication: Add authentication support
1func SetBearerToken(token string)
2func SetBasicAuth(username, password string)
- File Upload: Support multipart file uploads
1func UploadFile(path, fieldName, filename string, file io.Reader)
- Query Parameters: Support URL query parameters
1func GetWithParams(path string, params map[string]string)
- 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.