Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ jobs:

- name: Test
run: go test -v ./...
- name: Lint
run: go vet ./...
- name: Format
run: go fmt ./...
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}
77 changes: 77 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Makefile for managing the Go project

# Variables
APP_NAME := httpclient
GO_FILES := $(shell find . -name '*.go' -not -path "./vendor/*")

# Default target
.PHONY: all
all: build lint test

# Build the application
.PHONY: build
build:
@echo "Building the application..."
@go build -o $(APP_NAME) ./...

# Run unit tests
.PHONY: test
test:
@echo "Running unit tests..."
@go test -v ./...

# Run linting
.PHONY: lint
lint:
@echo "Running linting..."
@golangci-lint run

# Format the code
.PHONY: fmt
fmt:
@echo "Formatting code..."
@go fmt ./...

# Run vet
.PHONY: vet
vet:
@echo "Running go vet..."
@go vet ./...

# Tidy up the module
.PHONY: tidy
tidy:
@echo "Tidying up the module..."
@go mod tidy

# Install dependencies
.PHONY: deps
deps:
@echo "Installing dependencies..."
@go mod download

# Clean up build artifacts
.PHONY: clean
clean:
@echo "Cleaning up..."
@rm -f $(APP_NAME)

# Run all tools
.PHONY: tools
tools: fmt vet lint
@echo "All tools executed."

# Help
.PHONY: help
help:
@echo "Available targets:"
@echo " build - Build the application"
@echo " test - Run unit tests"
@echo " lint - Run linting"
@echo " fmt - Format the code"
@echo " vet - Run go vet"
@echo " tidy - Tidy up the module"
@echo " deps - Install dependencies"
@echo " clean - Clean up build artifacts"
@echo " tools - Run all tools (fmt, vet, lint)"
@echo " help - Show this help message"
90 changes: 71 additions & 19 deletions httpclient/httpclient.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
package httpclient

import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"time"
)

// HTTPClient is a wrapper around the standard http.Client with additional features.
type HTTPClient struct {
client *http.Client
retryCount int
retryDelay time.Duration
logger *log.Logger
backoff func(attempt int) time.Duration
}

// Option is a functional option for configuring the HTTPClient.
type Option func(*HTTPClient)

// WithTimeout sets a custom timeout for the HTTP client.
func WithTimeout(timeout time.Duration) Option {
return func(hc *HTTPClient) {
hc.client.Timeout = timeout
}
}

// WithRetry configures retry count and delay.
func WithRetry(retryCount int, retryDelay time.Duration) Option {
return func(hc *HTTPClient) {
Expand Down Expand Up @@ -71,6 +55,27 @@ func WithTLSConfig(tlsConfig *tls.Config) Option {
}
}

// Add default headers to the HTTPClient.
func WithDefaultHeaders(headers map[string]string) Option {
return func(hc *HTTPClient) {
if hc.client.Transport == nil {
hc.client.Transport = &http.Transport{}
}
originalTransport := hc.client.Transport
hc.client.Transport = &headerTransport{
base: originalTransport,
headers: headers,
}
}
}

// WithTimeout configures the timeout for the HTTP client.
func WithTimeout(timeout time.Duration) Option {
return func(hc *HTTPClient) {
hc.client.Timeout = timeout
}
}

// NewHTTPClient creates a new instance of HTTPClient with the provided options.
func NewHTTPClient(options ...Option) *HTTPClient {
hc := &HTTPClient{
Expand Down Expand Up @@ -140,6 +145,44 @@ func (hc *HTTPClient) Post(url string, body io.Reader, headers map[string]string
return hc.Do(req)
}

// Put is a helper method for making PUT requests.
func (hc *HTTPClient) Put(url string, body io.Reader, headers map[string]string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodPut, url, body)
if err != nil {
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
return hc.Do(req)
}

// Delete is a helper method for making DELETE requests.
func (hc *HTTPClient) Delete(url string, headers map[string]string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
return hc.Do(req)
}

// PostJSON is a helper method for making POST requests with JSON body.
func (hc *HTTPClient) PostJSON(url string, jsonBody interface{}, headers map[string]string) (*http.Response, error) {
// Ensure headers map is initialized before adding Content-Type.
if headers == nil {
headers = make(map[string]string)
}
body, err := json.Marshal(jsonBody)
if err != nil {
return nil, err
}
headers["Content-Type"] = "application/json"
return hc.Post(url, bytes.NewReader(body), headers)
}

// ReadResponseBody reads and returns the response body as a string.
func ReadResponseBody(resp *http.Response) (string, error) {
defer resp.Body.Close()
Expand All @@ -149,3 +192,12 @@ func ReadResponseBody(resp *http.Response) (string, error) {
}
return string(body), nil
}

// ReadJSONResponseBody reads and unmarshals the response body into the target interface.
func ReadJSONResponseBody(resp *http.Response, target interface{}) error {
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(target)
}
93 changes: 93 additions & 0 deletions httpclient/httpclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,99 @@ func TestWithTLSConfig(t *testing.T) {
}
}

func TestHTTPClient_Get(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("GET response"))
}
ts := httptest.NewServer(http.HandlerFunc(handler))
defer ts.Close()

hc := NewHTTPClient()
resp, err := hc.Get(ts.URL, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode)
}
}

func TestHTTPClient_PostJSON(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
}
w.WriteHeader(http.StatusCreated)
w.Write([]byte("POST response"))
}
ts := httptest.NewServer(http.HandlerFunc(handler))
defer ts.Close()

hc := NewHTTPClient()
resp, err := hc.PostJSON(ts.URL, map[string]string{"key": "value"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
t.Errorf("expected status %d, got %d", http.StatusCreated, resp.StatusCode)
}
}

func TestHTTPClient_Retry(t *testing.T) {
attempts := 0
handler := func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 3 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
ts := httptest.NewServer(http.HandlerFunc(handler))
defer ts.Close()

hc := NewHTTPClient(WithRetry(3, time.Millisecond))
resp, err := hc.Get(ts.URL, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode)
}
if attempts != 3 {
t.Errorf("expected 3 attempts, got %d", attempts)
}
}

func TestHTTPClient_DefaultHeaders(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Custom-Header") != "value" {
t.Errorf("expected X-Custom-Header value, got %s", r.Header.Get("X-Custom-Header"))
}
w.WriteHeader(http.StatusOK)
}
ts := httptest.NewServer(http.HandlerFunc(handler))
defer ts.Close()

hc := NewHTTPClient(WithDefaultHeaders(map[string]string{"X-Custom-Header": "value"}))
resp, err := hc.Get(ts.URL, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode)
}
}

type errorReader struct{}

func (e *errorReader) Read(p []byte) (n int, err error) {
Expand Down
32 changes: 32 additions & 0 deletions httpclient/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package httpclient

import (
"log"
"net/http"
"time"
)

// HTTPClient is a wrapper around the standard http.Client with additional features.
type HTTPClient struct {
client *http.Client
retryCount int
retryDelay time.Duration
logger *log.Logger
backoff func(attempt int) time.Duration
}

// Option is a functional option for configuring the HTTPClient.
type Option func(*HTTPClient)

// headerTransport is a custom RoundTripper to add default headers.
type headerTransport struct {
base http.RoundTripper
headers map[string]string
}

func (ht *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for key, value := range ht.headers {
req.Header.Set(key, value)
}
return ht.base.RoundTrip(req)
}
Loading