httpclient

package module
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 7, 2024 License: MIT Imports: 10 Imported by: 0

README

go-httpclient

Go Reference

Overview

go-httpclient aims to reduce the boilerplate of HTTP request/response setup for Go, along with providing out-of-the-box testing in a standardized way.

The Go standard library net/http has already an excellent & powerful API. However, the complexity of reliably & securely composing an HTTP request or reading back the HTTP response cannot be easily avoided without a higher-level abstraction layer.

go-httpclient also tries to enforce best practices such as:

  • Non-zero request timeout
  • Passing context.Context to net/http
  • URL-encoded Query Parameters
  • Safe URL Path Joining
  • Always closing the response body

Furthermore, testing is facilitated by the httptesting & httpassert libraries. httptesting provides a 100% compatible API with httpclient.Client and exposes a httpclient.Client instance that can be injected directly as a drop-in replacement of the regular production code Client. The testing abstraction layer is using a httpmock Mock Transport under the hood which allows registration of custom matcher/responders when required.

Key Features

  • Offers an intuitive and ergonomic API based on HTTP verb names Get, Post, Patch, Delete and functional option parameters.
  • All request emitter methods accept context.Context as their first parameter.
  • Uses plain map[string]string structures for passing Query Parameters and Headers which should cover the majority of cases.
  • Always URL-encodes query parameters.
  • Ensures Response body is read when streaming is not required.
  • Separate testing Client that implements the exact same API.
  • Utilizes the powerful httpmock under the hood in order to allow fine-grained and scoped request mocking and assertions.

Examples

Example implementation of a GitHub REST API SDK using httpclient.Client:

package githubsdk

import (
	"context"
	"net/url"
	"time"

	"github.com/georgepsarakis/go-httpclient"
)

type GitHubSDK struct {
	Client *httpclient.Client
}

func New() GitHubSDK {
	client := httpclient.New()
	return NewWithClient(client)
}

func NewWithClient(c *httpclient.Client) GitHubSDK {
	c.WithDefaultHeaders(map[string]string{
		"X-GitHub-Api-Version": "2022-11-28",
		"Accept":               "application/vnd.github+json",
	})
	c, _ = c.WithBaseURL("https://api.github.com")
	return GitHubSDK{Client: c}
}

type User struct {
	ID        int       `json:"id"`
	Bio       string    `json:"bio"`
	Blog      string    `json:"blog"`
	CreatedAt time.Time `json:"created_at"`
	Login     string    `json:"login"`
	Name      string    `json:"name"`
}

// GetUserByUsername retrieves a user based on their public username.
// See https://docs.github.com/en/rest/users/users
func (g GitHubSDK) GetUserByUsername(ctx context.Context, username string) (User, error) {
	path, err := url.JoinPath("/users", username)
	if err != nil {
		return User{}, err
	}
	// Note: `httpclient.Client.Get` allows header parameterization, for example changing an API version:
	// resp, err := g.Client.Get(ctx, path, httpclient.WithHeaders(map[string]string{"x-github-api-version": "2023-11-22"}))
	resp, err := g.Client.Get(ctx, path)
	if err != nil {
		return User{}, err
	}
	u := User{}
	if err := httpclient.DeserializeJSON(resp, &u); err != nil {
		return u, err
	}
	return u, nil
}

Here is how using our SDK looks like:

package main

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/georgepsarakis/go-httpclient/examples/githubsdk"
)

func main() {
	sdk := githubsdk.New()

	user, err := sdk.GetUserByUsername(context.Background(), "georgepsarakis")
	panicOnError(err)

	m, err := json.MarshalIndent(user, "", "  ")
	panicOnError(err)

	fmt.Println(string(m))
	// Output:
	//{
	//   "id": 963304,
	//   "bio": "Software Engineer",
	//   "blog": "https://controlflow.substack.com/",
	//   "created_at": "2011-08-06T16:57:12Z",
	//   "login": "georgepsarakis",
	//   "name": "George Psarakis"
	//}
}

func panicOnError(err error) {
	if err != nil {
		panic(err)
	}
}

Testing our GitHub SDK as well as code that depends on it is straightforward thanks to the httptesting package:

package githubsdk_test

import (
	"context"
	"net/http"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/georgepsarakis/go-httpclient"
	"github.com/georgepsarakis/go-httpclient/examples/githubsdk"
	"github.com/georgepsarakis/go-httpclient/httpassert"
	"github.com/georgepsarakis/go-httpclient/httptesting"
)

func TestGitHubSDK_GetUserByUsername(t *testing.T) {
	var err error

	testClient := httptesting.NewClient(t)
	sdk := githubsdk.NewWithClient(testClient.Client)
	testClient, err = testClient.WithBaseURL("https://test-api-github-com")
	require.NoError(t, err)

	testClient.
		NewMockRequest(
			http.MethodGet,
			"https://test-api-github-com/users/georgepsarakis",
			httpclient.WithHeaders(map[string]string{
				"x-github-api-version": "2022-11-28",
				"accept":               "application/vnd.github+json",
			})).
		RespondWithJSON(http.StatusOK, `
	{
	   "id": 963304,
	   "bio": "Test 123",
	   "blog": "https://test.blog/",
	   "created_at": "2025-09-16T16:57:12Z",
       "login": "georgepsarakis",
	   "name": "Test Name"
	}`).Register()

	user, err := sdk.GetUserByUsername(context.Background(), "georgepsarakis")
	require.NoError(t, err)

	httpassert.PrintJSON(t, user)
	require.Equal(t, githubsdk.User{
		ID:        963304,
		Bio:       "Test 123",
		Blog:      "https://test.blog/",
		CreatedAt: time.Date(2025, time.September, 16, 16, 57, 12, 0, time.UTC),
		Login:     "georgepsarakis",
		Name:      "Test Name",
	}, user)
}

For comparison, below is an alternative implementation using the net/http & httpmock packages:

package examples_test

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"testing"
	"time"

	"github.com/jarcoal/httpmock"
	"github.com/stretchr/testify/require"

	"github.com/georgepsarakis/go-httpclient/httpassert"
)

type GitHubSDK struct {
	Client         *http.Client
	DefaultHeaders http.Header
	BaseURL        string
}

func New() GitHubSDK {
	client := http.DefaultClient
	return NewWithClient(client)
}

func NewWithClient(c *http.Client) GitHubSDK {
	headers := http.Header{}
	headers.Set("X-GitHub-Api-Version", "2022-11-28")
	headers.Set("Accept", "application/vnd.github+json")
	return GitHubSDK{Client: c, DefaultHeaders: headers, BaseURL: "https://api.github.com"}
}

type User struct {
	ID        int       `json:"id"`
	Bio       string    `json:"bio"`
	Blog      string    `json:"blog"`
	CreatedAt time.Time `json:"created_at"`
	Login     string    `json:"login"`
	Name      string    `json:"name"`
}

// GetUserByUsername retrieves a user based on their public username.
// See https://docs.github.com/en/rest/users/users
func (g GitHubSDK) GetUserByUsername(ctx context.Context, username string) (User, error) {
	path, err := url.JoinPath("/users", username)
	if err != nil {
		return User{}, err
	}
	fullURL := fmt.Sprintf("%s%s", g.BaseURL, path)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
	if err != nil {
		return User{}, err
	}
	req.Header = g.DefaultHeaders.Clone()
	resp, err := g.Client.Do(req)
	if err != nil {
		return User{}, err
	}
	defer resp.Body.Close()
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		return User{}, err
	}
	u := User{}
	if err := json.Unmarshal(b, &u); err != nil {
		return User{}, err
	}
	return u, nil
}

func TestGitHubSDK_NetHTTP_GetUserByUsername(t *testing.T) {
	mt := httpmock.NewMockTransport()
	testClient := &http.Client{
		Transport: mt,
	}
	sdk := NewWithClient(testClient)
	sdk.BaseURL = "https://test-api-github-com"

	responder, err := httpmock.NewJsonResponder(http.StatusOK, json.RawMessage(`
	{
	   "id": 963304,
	   "bio": "Test 123",
	   "blog": "https://test.blog/",
	   "created_at": "2025-09-16T16:57:12Z",
       "login": "georgepsarakis",
	   "name": "Test Name"
	}`))
	require.NoError(t, err)
	
	defaultHeaderMatcher := func(req *http.Request) bool {
		return req.Header.Get("Accept") == "application/vnd.github+json" &&
			req.Header.Get("X-GitHub-Api-Version") == "2022-11-28"
	}
	mt.RegisterMatcherResponder(http.MethodGet,
		"https://test-api-github-com/users/georgepsarakis",
		httpmock.NewMatcher("GetUserByUsername", func(req *http.Request) bool {
			if !defaultHeaderMatcher(req) {
				return false
			}
			return true
		}),
		responder)

	user, err := sdk.GetUserByUsername(context.Background(), "georgepsarakis")
	require.NoError(t, err)

	httpassert.PrintJSON(t, user)
	require.Equal(t, User{
		ID:        963304,
		Bio:       "Test 123",
		Blog:      "https://test.blog/",
		CreatedAt: time.Date(2025, time.September, 16, 16, 57, 12, 0, time.UTC),
		Login:     "georgepsarakis",
		Name:      "Test Name",
	}, user)
}

Documentation

Index

Examples

Constants

View Source
const DefaultTimeout = 30 * time.Second

Variables

This section is empty.

Functions

func DeserializeJSON

func DeserializeJSON(resp *http.Response, target any) error

DeserializeJSON unmarshals the response body payload to the object referenced by the `target` pointer. If `target` is not a pointer, an error is returned. The body stream is restored as a NopCloser, so subsequent calls to `Body.Close()` will never fail. Note that the above behavior may have impact on memory requirements since memory must be reserved for the full lifecycle of the http.Response object.

func InterceptRequestBody

func InterceptRequestBody(r *http.Request) ([]byte, error)

func InterceptResponseBody

func InterceptResponseBody(r *http.Response) ([]byte, error)

InterceptResponseBody will read the full contents of the http.Response.Body stream and release any resources associated with the Response object while allowing the Body stream to be accessed again. The Body stream is restored as a NopCloser, so subsequent calls to `Body.Close()` will never fail. Note that the above behavior may have impact on memory requirements since memory must be reserved for the full lifecycle of the http.Response object.

func MustInterceptRequestBody

func MustInterceptRequestBody(r *http.Request) []byte

func MustInterceptResponseBody

func MustInterceptResponseBody(r *http.Response) []byte

func NewRequest

func NewRequest(ctx context.Context, method string, rawURL string, body io.Reader, parameters ...RequestParameter) (*http.Request, error)

NewRequest builds a new request based on the given Method, full URL, body and optional functional option parameters.

Types

type BaseError

type BaseError struct {
	// contains filtered or unexported fields
}

func (*BaseError) Error

func (e *BaseError) Error() string

func (*BaseError) Unwrap

func (e *BaseError) Unwrap() error

type Client

type Client struct {
	// contains filtered or unexported fields
}

func New

func New() *Client

func NewWithTransport

func NewWithTransport(transport http.RoundTripper) *Client

NewWithTransport creates a new Client object that uses the given http.Roundtripper as a transport in the underlying net/http Client.

func (*Client) BaseURL

func (c *Client) BaseURL() string

func (*Client) Delete

func (c *Client) Delete(ctx context.Context, url string, parameters ...RequestParameter) (*http.Response, error)

func (*Client) Get

func (c *Client) Get(ctx context.Context, url string, parameters ...RequestParameter) (*http.Response, error)
Example
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/url"
	"time"
)

func main() {
	sdk := NewSDK()
	user, err := sdk.GetUserByUsername(context.Background(), "georgepsarakis")
	panicOnError(err)

	m, err := json.MarshalIndent(user, "", "  ")
	panicOnError(err)

	fmt.Println(string(m))
}

func panicOnError(err error) {
	if err != nil {
		panic(err)
	}
}

type GitHubSDK struct {
	Client *Client
}

func NewSDK() GitHubSDK {
	return NewSDKWithClient(New())
}

func NewSDKWithClient(c *Client) GitHubSDK {
	c.WithDefaultHeaders(map[string]string{
		"X-GitHub-Api-Version": "2022-11-28",
		"Accept":               "application/vnd.github+json",
	})
	c, _ = c.WithBaseURL("https://api.github.com")
	return GitHubSDK{Client: c}
}

type User struct {
	ID        int       `json:"id"`
	Bio       string    `json:"bio"`
	Blog      string    `json:"blog"`
	CreatedAt time.Time `json:"created_at"`
	Login     string    `json:"login"`
	Name      string    `json:"name"`
}

// GetUserByUsername retrieves a user based on their public username.
// See https://docs.github.com/en/rest/users/users
func (g GitHubSDK) GetUserByUsername(ctx context.Context, username string) (User, error) {
	path, err := url.JoinPath("/users", username)
	if err != nil {
		return User{}, err
	}
	resp, err := g.Client.Get(ctx, path)
	if err != nil {
		return User{}, err
	}
	u := User{}
	if err := DeserializeJSON(resp, &u); err != nil {
		return u, err
	}
	return u, nil
}
Output:

{
  "id": 963304,
  "bio": "Software Engineer",
  "blog": "https://controlflow.substack.com/",
  "created_at": "2011-08-06T16:57:12Z",
  "login": "georgepsarakis",
  "name": "George Psarakis"
}

func (*Client) Head

func (c *Client) Head(ctx context.Context, url string, parameters ...RequestParameter) (*http.Response, error)

Head sends a HEAD Request.

func (*Client) Patch

func (c *Client) Patch(ctx context.Context, url string, body io.Reader, parameters ...RequestParameter) (*http.Response, error)

func (*Client) Post

func (c *Client) Post(ctx context.Context, url string, body io.Reader, parameters ...RequestParameter) (*http.Response, error)

func (*Client) WithBaseTransport

func (c *Client) WithBaseTransport(base http.RoundTripper) *Client

func (*Client) WithBaseURL

func (c *Client) WithBaseURL(baseURL string) (*Client, error)

func (*Client) WithDefaultHeaders

func (c *Client) WithDefaultHeaders(headers map[string]string) *Client

WithDefaultHeaders adds the given name-value pairs as request headers on every Request. Headers can be added or overridden using the WithHeaders functional option parameter on a per-request basis.

func (*Client) WithJSONContentType

func (c *Client) WithJSONContentType() *Client

WithJSONContentType sets the Content-Type default header to `application/json; charset=utf-8`.

func (*Client) WithTimeout

func (c *Client) WithTimeout(timeout time.Duration) *Client

type ErrorTag

type ErrorTag string

type ErrorTagCollection

type ErrorTagCollection []ErrorTag

func (ErrorTagCollection) String

func (c ErrorTagCollection) String(delimiter string) string

type RequestParameter

type RequestParameter func(opts *RequestParameters)

func WithHeaders

func WithHeaders(headers map[string]string) RequestParameter

WithHeaders allows headers to be set on the request. Multiple calls using the same header name will overwrite existing header values.

func WithQueryParameters

func WithQueryParameters(params map[string]string) RequestParameter

WithQueryParameters configures the given name-value pairs as Query String parameters for the request. Multiple calls will override values for existing keys.

type RequestParameters

type RequestParameters struct {
	// contains filtered or unexported fields
}

func NewRequestParameters

func NewRequestParameters(opts ...RequestParameter) *RequestParameters

func (*RequestParameters) ErrorCodes

func (rp *RequestParameters) ErrorCodes() []int

func (*RequestParameters) Headers

func (rp *RequestParameters) Headers() http.Header

func (*RequestParameters) QueryParameters

func (rp *RequestParameters) QueryParameters() url.Values

QueryParameters returns a clone of the currently configured query parameters. Multiple calls will override already existing keys.

Directories

Path Synopsis
examples

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL