7 min read

How to create a generic JSON request function, over HTTP, in Go?

How to create a generic 
JSON request function, over HTTP, in Go?
Photo by Thomas Jensen / Unsplash

Introduction

If you've been writing Go HTTP clients for a while, you've probably noticed a pattern: you're constantly writing the same boilerplate code to marshal requests, handle responses, and unmarshal JSON. Each API integration becomes a repetitive dance of error handling and type assertions.

Since Go 1.18 introduced generics, we finally have the tools to eliminate this repetition while maintaining type safety. Let me show you how.

The Traditional Approach (And Why It's Painful)

Let's start with a typical HTTP client implementation you've probably written dozens of times:

type Header struct {
    Key   string
    Value string
}

func doJSONRequest(method string, url string, requestData interface{}, headers ...Header) (*http.Response, error) {
    // Marshal request body
    jsonData, err := json.Marshal(requestData)
    if err != nil {
        return nil, fmt.Errorf("failed to marshal request: %w", err)
    }

    // Create request
    req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
    if err != nil {
        return nil, fmt.Errorf("failed to create request: %w", err)
    }
    
    req.Header.Set("Content-Type", "application/json")
    for _, header := range headers {
        req.Header.Set(header.Key, header.Value)
    }

    // Execute request
    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("request failed: %w", err)
    }

    return resp, nil
}

An attempt to create a generic HTTP client function

And here's how you'd use it:

func main() {
    type RequestData struct {
        Key   string `json:"key"`
        Value string `json:"value"`
    }
    
    type ResponseData struct {
        Result string `json:"result"`
        Status string `json:"status"`
    }

    resp, err := doJSONRequest(
        http.MethodPost,
        "https://api.example.com/data",
        RequestData{
            Key:   "foo",
            Value: "bar",
        },
        Header{"User-Agent", "xxx-service-api-client"},
        Header{"Authorization", "Bearer xxxx"},
    )
    if err != nil {
        log.Fatal("Error:", err)
    }
    defer resp.Body.Close()

    // Here's where the pain begins...
    responseBody, err := io.ReadAll(resp.Body)
    if err != nil {
        log.Fatal("Failed to read response:", err)
    }

    var data ResponseData
    if err := json.Unmarshal(responseBody, &data); err != nil {
        log.Fatal("Failed to unmarshal response:", err)
    }

    fmt.Println("Response:", data)
}

But the complexity of handling this function did not walked away

The problems are clear:

  1. Boilerplate everywhere: Every call requires manual response body handling
  2. No type safety: The function returns *http.Response, forcing you to unmarshal manually
  3. Repetitive error handling: The same unmarshal logic is duplicated across your codebase
  4. Poor reusability: Wrapping this in a type-specific function couples it to one response type

The Generic Solution

Go generics give us the power to abstract this pattern while preserving type safety. Here's the improved version:

func doJSONRequest[R any](method string, url string, requestData interface{}, headers ...Header) (R, error) {
	var resp R

	// Serialize the request data to JSON
	jsonData, err := json.Marshal(requestData)
	if err != nil {
		return resp, err
	}

	// Create an HTTP request
	req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
	if err != nil {
		return resp, err
	}
	req.Header.Set("Content-Type", "application/json")
    for header, headerValue := range

	// Send the request using the default HTTP client
	client := &http.Client{}
	resp, err := client.Do(requestData)
	if err != nil {
		return resp, err
	}
    defer resp.Body.Close()

	_, err = rs.Body.Read(body)
	if err != nil {
		return resp, err
	}

	err = json.Unmarshal(body, &resp)
	return resp, err
}

Now look how clean the calling code becomes:

func main() {
	resp, err := doJSONRequest[ResponseData](
    	http.MethodPost,
        "https://api.example.com/data", 
        RequestData{
            Key:   "foo",
            Value: "bar",
        },
        Header{"User-Agent", "xxx-service-api-client"},
        Header{"Authorization", "Bearer xxxx"},
     )
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
    fmt.Println("Response: ", resp)
}

Beautiful! The response is already typed, deserialized, and ready to use. But we can do even better.

Making It Production-Ready

The generic version is great, but it's still rigid. What if you need:

  • Custom serialization (like Protocol Buffers or MessagePack)?
  • Configurable timeouts?
  • Request/response middleware?
  • Different HTTP clients for different services?

Let's build a flexible, production-ready solution:

package fetcher

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
)

var (
	ErrMissingURL        = errors.New("no URL specified")
	ErrMissingHttpMethod = errors.New("no HTTP verb/method specified")
)

type HTTPClient interface {
	Do(req *http.Request) (*http.Response, error)
}

type SerializationFunc func(any) ([]byte, error)
type DeserializationFunc func(data []byte, v any) error
type RequestOption func(*Request)
type statusHandlerFunc func(*http.Response) (any, error)

type Request struct {
	method               string
	url                  string
	contentType          string
	headers              map[string]string
	client               HTTPClient
	serialize            SerializationFunc
	deserialize          DeserializationFunc
	statusHandlers       map[int]statusHandlerFunc
	defaultStatusHandler statusHandlerFunc
	body                 any
}

// Fetch sends requests, serializes/deserializes bodies, and sets http clients
func Fetch[RT any](options ...RequestOption) (RT, error) {
	var resp RT
	req := &Request{
		contentType: "application/json",
		client:      http.DefaultClient,
		method:      http.MethodGet,
		serialize:   func(body any) ([]byte, error) { return json.Marshal(body) },
		deserialize: func(data []byte, v any) error { return json.Unmarshal(data, v) },
		defaultStatusHandler: func(response *http.Response) (any, error) {
			var reqBody []byte
			if response.Request != nil && response.Request.Body != nil {
				reqBody, _ = io.ReadAll(response.Request.Body)
			}
			respBody, _ := io.ReadAll(response.Body)
			log.Printf("[WARN] HANDLING UNKNOWN STATUS: %q, URL: %q,\n\tREQ_BODY: %q\n\tRESP_BODY: %q", response.Status, response.Request.URL, string(reqBody), string(respBody))
			return resp, nil
		},
	}

	for _, setter := range options {
		setter(req)
	}

	if req.url == "" {
		return resp, ErrMissingURL
	}

	if req.method == "" {
		return resp, ErrMissingHttpMethod
	}

	var reqBody []byte
	var err error

	if req.body != nil {
		reqBody, err = req.serialize(req.body)
		if err != nil {
			return resp, err
		}
	}

	var reqReader io.Reader

	if reqBody != nil {
		reqReader = bytes.NewBuffer(reqBody)
	}

	httpReq, err := http.NewRequest(req.method, req.url, reqReader)
	if err != nil {
		return resp, err
	}
	httpReq.Header.Set("Content-Type", "application/json")
	for header, headerVal := range req.headers {
		httpReq.Header.Set(header, headerVal)
	}

	response, err := req.client.Do(httpReq)
	if err != nil {
		return resp, err
	}
	defer response.Body.Close()

	if !(response.StatusCode == 200 || response.StatusCode == 201) {
		handle, found := req.statusHandlers[response.StatusCode]
		if !found {
			handle = req.defaultStatusHandler
		}

		b, err := handle(response)
		if err != nil {
			return resp, err
		}
		resp, sameType := b.(RT)
		if !sameType && found {
			return resp, fmt.Errorf("%s HTTP Status handler does not return %T type value", response.Status, resp)
		} else if !sameType && !found {
			return resp, fmt.Errorf("default HTTP Status handler does not return %T type value", resp)
		} else {
			return resp, nil
		}
	}

	respBody, err := io.ReadAll(response.Body)
	if err != nil {
		return resp, err
	}

	err = req.deserialize(respBody, &resp)

	return resp, err
}

// RequestOptions functions for all Request struct fields

// WithMethod sets the HTTP method for the request
func WithMethod(method string) RequestOption {
	return func(r *Request) {
		r.method = method
	}
}

// WithURL sets the URL for the request
func WithURL(url string) RequestOption {
	return func(r *Request) {
		r.url = url
	}
}

// WithContentType sets the content type for the request
func WithContentType(contentType string) RequestOption {
	return func(r *Request) {
		r.contentType = contentType
	}
}

// WithHeaders sets the headers for the request
func WithHeaders(headers map[string]string) RequestOption {
	return func(r *Request) {
		r.headers = headers
	}
}

// WithHeader adds a single header to the request
func WithHeader(key, value string) RequestOption {
	return func(r *Request) {
		if r.headers == nil {
			r.headers = make(map[string]string)
		}
		r.headers[key] = value
	}
}

// WithClient sets a custom HTTP client for the request
func WithClient(client HTTPClient) RequestOption {
	return func(r *Request) {
		r.client = client
	}
}

// WithSerialize sets a custom serialization function for the request body
func WithSerialize(serialize SerializationFunc) RequestOption {
	return func(r *Request) {
		r.serialize = serialize
	}
}

// WithDeserialize sets a custom deserialization function for the response body
func WithDeserialize(deserialize DeserializationFunc) RequestOption {
	return func(r *Request) {
		r.deserialize = deserialize
	}
}

// WithStatusHandlers sets the status handlers map for the request
func WithStatusHandlers(handlers map[int]statusHandlerFunc) RequestOption {
	return func(r *Request) {
		r.statusHandlers = handlers
	}
}

// WithStatusHandler adds a single status handler for a specific status code
func WithStatusHandler(statusCode int, handler statusHandlerFunc) RequestOption {
	return func(r *Request) {
		if r.statusHandlers == nil {
			r.statusHandlers = make(map[int]statusHandlerFunc)
		}
		r.statusHandlers[statusCode] = handler
	}
}

// WithDefaultStatusHandler sets the default status handler for unhandled status codes
func WithDefaultStatusHandler(handler statusHandlerFunc) RequestOption {
	return func(r *Request) {
		r.defaultStatusHandler = handler
	}
}

// WithBody sets the request body
func WithBody(body any) RequestOption {
	return func(r *Request) {
		r.body = body
	}
}

Now you have complete flexibility (let's consider the HTTP client for GitHub API):

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

	"github.com/CristiCurteanu/abaxus-api/internal/fetcher"
)

const (
	githubAPIUrl            = "https://api.github.com"
	githubClientId          = "<your_github_client_id>"
	githubClientSecret      = "<your_github_client_secret>"
	githubOauthRedirectCode = "<gh_oauth_redirect_code>"
)

type AccessTokenResponse struct {
	AccessToken string `json:"access_token"`
	TokenType   string `json:"token_type"`
	Scope       string `json:"scope"`
}

type ProfileData struct {
	Id        int    `json:"id"`
	Username  string `json:"login"`
	AvatarURL string `json:"avatar_url"`
	Company   string `json:"company"`
	Repos     int    `json:"public_repos"`
	Gists     int    `json:"public_gists"`
	Followers int    `json:"followers"`
	Following int    `json:"following"`
}

func main() {
	httpClient := &http.Client{Timeout: 10 * time.Second}

	tokenResponse, err := fetcher.Fetch[AccessTokenResponse](
		fetcher.WithURL("https://github.com/login/oauth/access_token"),
		fetcher.WithMethod(http.MethodPost),
		fetcher.WithHeader("Accept", "application/json"),
		fetcher.WithBody(
			map[string]any{
				"client_id":     githubClientId,
				"client_secret": githubClientSecret,
				"code":          githubOauthRedirectCode,
			},
		),
		fetcher.WithClient(httpClient),
	)
	if err != nil {
		log.Fatal(err.Error())
	}

	profile, err := fetcher.Fetch[ProfileData](
		fetcher.WithURL(githubAPIUrl+"/user"),
		fetcher.WithHeader("Authorization", fmt.Sprintf("token %s", tokenResponse.AccessToken)),
	)
	if err != nil {
		log.Fatal(err.Error())
	}

	log.Printf("profile: %+v\n", profile)
}

With request options variadic functions parameter, it is possible to (re-)configure the `Request` itself, which provides a much cleaner API for the `Fetch` function, and we just specify the type of response object that should be deserialized as generic type.

There is also possibility to handle different status codes, by registering status handlers functions with `fetcher.WithStatusHandler` option function.

When to Use This Pattern

This approach shines when you:

  • Have multiple API integrations with similar patterns
  • Want type safety without code generation
  • Need flexibility in serialization/deserialization
  • Want to centralize HTTP client configuration

However, for very simple one-off requests, the standard library might be sufficient. Choose the right tool for the job.

What's Next?

This foundation can be extended further:

  • Add retry logic with exponential backoff
  • Add structured logging
  • Support streaming responses
  • Add metrics and tracing

Wrapping Up

Go generics aren't just syntactic sugar—they're a powerful tool for eliminating boilerplate while maintaining type safety. By building reusable, flexible abstractions like this HTTP client, you can write cleaner code and ship features faster.

The key is following the Open/Closed Principle: our function is open for extension (through custom serializers, clients, and headers) but closed for modification (the core logic remains stable).

Give it a try in your next project. Your future self will thank you when you're not copy-pasting HTTP client code for the hundredth time.


PS. You can find the package in this GitHub repository.