Hello! Welcome back to your course on Go.
In our last lesson, we built a resilient HTTP client that handles timeouts and retries with exponential backoff. While powerful, all of our logic was in a single main package. This is fine for a one-off script, but for building reusable DevOps tools, this approach doesn't scale. If you wanted to use that GitHub client logic in another tool, you'd have to copy-paste the code, which is inefficient and error-prone.
Today, we'll fix that. This lesson focuses on a crucial step in software engineering: how to structure a reusable API client package for a DevOps platform like GitHub or GitLab. We will transform our single-file script into a clean, professional, and testable Go package that can be imported and used by any number of applications.
You will learn the idiomatic Go patterns for designing libraries, including:
- Organizing code into a dedicated package.
- Using a central
Clientstruct to manage state and dependencies. - Implementing the functional options pattern for flexible configuration.
- Grouping API endpoints into logical "services."
- Designing your client for testability from the ground up.
1. The Foundation: The Client Struct and Constructor
The first step in creating a reusable package is to move the code out of main and into its own directory. Let's imagine we're creating a github package.
The core of any API client is a struct that holds essential, long-lived information: the API's base URL, an HTTP client to make requests, and any necessary credentials like an API token.
How I Write an API Client in Golang (with testing)
This video provides a great walkthrough of how to structure a basic API client. We'll start by watching the first part, which sets up the fundamental Client struct and its constructor.
Watch the segment from 01:47 to 03:21. Focus on: The definition of the Client struct and its fields (BaseURL, HTTPClient). The purpose of the New function (the constructor) that initializes this struct.
As the video shows, the basic structure looks like this:
// Inside a new directory, e.g., "github/client.go"
package github
import "net/http"
type Client struct {
BaseURL string
apiKey string // Unexported, as it should be handled internally
HTTPClient *http.Client
}
func NewClient(apiKey string) *Client {
return &Client{
BaseURL: "https://api.github.com",
apiKey: apiKey,
HTTPClient: &http.Client{},
}
}
This simple pattern encapsulates the client's dependencies. Any function that needs to make an API call will be a method on a pointer to this Client struct (*Client), giving it access to the base URL and the HTTP client.
2. A Flexible API: The Functional Options Pattern
Our NewClient function is a good start, but it's rigid. What if a user wants to:
- Point the client to a GitHub Enterprise instance with a different URL?
- Provide a custom
http.Client(like theretryablehttpclient from our last lesson)? - Set a custom user agent?
We could add more arguments to NewClient, but this quickly becomes unwieldy. A more idiomatic and scalable solution in Go is the functional options pattern. This pattern allows you to pass a variable number of configuration functions to the constructor.
The Most Efficient Struct Configuration Pattern For Golang
This next video explains the functional options pattern from first principles. It's a powerful technique used in many professional Go libraries, including gRPC and the GitLab client we'll see later.
Watch the following sections to understand the pattern: The Problem (00:53 - 02:39): Understand why a constructor with many arguments is problematic. The Core Pattern (03:59 - 06:43): See how a slice of functions (...OptFunc) is used to apply configurations on top of a default struct. Creating Option Functions (06:29 - 10:11): Learn how helper functions like withTLS and withMaxCon are created to provide a clean configuration API to the user. Focus on the concept of passing functions to modify the client's configuration.
Let's apply this pattern to our GitHub client.
// In github/client.go
package github
import (
"net/http"
"time"
)
// Option is a function that configures a Client.
type Option func(*Client)
// WithBaseURL allows overriding the default API base URL.
func WithBaseURL(baseURL string) Option {
return func(c *Client) {
c.BaseURL = baseURL
}
}
// WithHTTPClient allows providing a custom HTTP client.
func WithHTTPClient(httpClient *http.Client) Option {
return func(c *Client) {
c.HTTPClient = httpClient
}
}
type Client struct {
BaseURL string
apiKey string
HTTPClient *http.Client
}
// NewClient creates a new GitHub API client.
func NewClient(apiKey string, opts ...Option) *Client {
// Default client
client := &Client{
BaseURL: "https://api.github.com",
apiKey: apiKey,
HTTPClient: &http.Client{
Timeout: 10 * time.Second,
},
}
// Apply all provided options
for _, opt := range opts {
opt(client)
}
return client
}
Now, a user can create a client like this:
// Simple client with defaults
client1 := github.NewClient("my-api-key")
// Client for GitHub Enterprise
client2 := github.NewClient("my-api-key", github.WithBaseURL("https://github.my-company.com/api/v3"))
// Client using our retryable client from the last lesson
retryClient := retryablehttp.NewClient()
// ... configure retryClient ...
stdClient := retryClient.StandardClient()
client3 := github.NewClient("my-api-key", github.WithHTTPClient(stdClient))
This approach is clean, extensible, and very common in professional Go libraries.
3. Organizing Endpoints with Services
A large API like GitHub's has hundreds of endpoints. Putting all the corresponding methods directly on the Client struct would create a massive, disorganized file. A better approach is to group related endpoints into services. For example, all user-related endpoints go into a UsersService, repository endpoints into a RepositoriesService, and so on.
Each service is a struct that holds a pointer back to the main client, using it to make the actual HTTP calls.
Internal Working of the GitLab API Go Client
The go-gitlab client is a well-regarded, real-world example of this structure. This article analyzes its design, and we'll focus on how it organizes its package.
Read the section titled "Package Structure". Pay close attention to: How files are grouped by domain (e.g., jobs.go, jobs_test.go). The concept of a JobsService struct that contains a pointer to the main *Client. The diagram showing the delegation pattern: the high-level service (JobsService) delegates the actual HTTP work to the low-level, shared Client.
Here is how we would apply this to our Client:
// In github/client.go
type Client struct {
BaseURL string
apiKey string
HTTPClient *http.Client
// Services for different API resources
Users *UsersService
}
func NewClient(apiKey string, opts ...Option) *Client {
// ... (previous NewClient code) ...
// Wire up the services
client.Users = &UsersService{client: client}
return client
}
// In a new file, github/users.go
package github
import (
"context"
"fmt"
)
// UsersService handles communication with the user related
// methods of the GitHub API.
type UsersService struct {
client *Client
}
type User struct {
Login string `json:"login"`
Name string `json:"name"`
PublicRepos int `json:"public_repos"`
}
// Get fetches a user. Passing an empty string for username will fetch the
// authenticated user.
func (s *UsersService) Get(ctx context.Context, username string) (*User, error) {
var path string
if username != "" {
path = fmt.Sprintf("/users/%s", username)
} else {
path = "/user"
}
// We will implement sendRequest shortly
// req, err := s.client.newRequest(http.MethodGet, path, nil)
// ...
return nil, nil // Placeholder
}
The user of our package can now access user-related methods cleanly: client.Users.Get(...).
4. Abstracting Boilerplate with a Helper Function
Every API call involves similar steps: create a request, set headers (like Authorization and Content-Type), send it, check the status code, and decode the response. Repeating this logic in every service method is tedious. We can abstract this into a private helper method on the main Client.
This article demonstrates a sendRequest helper function that encapsulates the common logic for making requests. This is a crucial pattern for keeping your code DRY (Don't Repeat Yourself).
Read the section that includes the GetFaces function and the sendRequest helper function. Note how sendRequest handles: Setting common headers (Content-Type, Accept, Authorization). Executing the request using c.HTTPClient.Do(req). Checking for non-success status codes. Decoding the JSON response into the provided interface v.
Let's add a similar helper, do, to our Client. This method will become the workhorse of our entire library.
// In github/client.go
func (c *Client) do(ctx context.Context, method, path string, body, result interface{}) (*http.Response, error) {
url := c.BaseURL + path
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(b)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/vnd.github.v3+json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// You would typically decode a specific GitHub error response struct here
return resp, fmt.Errorf("request failed with status: %s", resp.Status)
}
if result != nil {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return resp, fmt.Errorf("failed to decode response: %w", err)
}
}
return resp, nil
}
Now, our UsersService.Get method becomes much simpler:
// In github/users.go
func (s *UsersService) Get(ctx context.Context, username string) (*User, error) {
path := "/user"
if username != "" {
path = fmt.Sprintf("/users/%s", username)
}
var user User
_, err := s.client.do(ctx, http.MethodGet, path, nil, &user)
if err != nil {
return nil, err
}
return &user, nil
}
5. Designing for Testability
A reusable library must be testable. We cannot have our unit tests making real network calls to GitHub. The key to testability is to depend on interfaces, not concrete types.
Notice that our Client holds an *http.Client. To test our logic, we need to be able to replace this with a mock that simulates server responses. The video from the beginning of the lesson explains this perfectly.
How I Write an API Client in Golang (with testing)
Let's return to the first video to see how to make our client testable. This part explains how to use an interface for the HTTP client and then use a mock generator to test the client's logic in isolation.
Watch the final parts of the video: Accepting an Interface (09:31 - 10:45): See how the Client struct is modified to accept an HTTPClient interface instead of a concrete *http.Client. This is the crucial change that enables mocking. Writing a Unit Test with Mocks (10:45 - 18:51): Follow the process of generating a mock implementation and using it in a test to simulate an API response and assert that the client behaves correctly.
The core idea is to define a small interface for just the part of http.Client we use: the Do method.
// In github/client.go
// httpClient is an interface for a client that can make HTTP requests.
// It's satisfied by *http.Client.
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
type Client struct {
// ...
HTTPClient httpClient // Use the interface type
}
By using this interface, our tests can provide a fake httpClient that returns a canned response, allowing us to test our JSON decoding, error handling, and business logic without ever hitting the network.
Test your understanding!
Imagine you are adding a new RepositoriesService to your client. You need to implement a List(ctx, orgName) method to list repositories for an organization.
Based on the patterns we've discussed, what would be the main responsibilities of the RepositoriesService.List method versus the shared client.do helper method?
Show answer
The responsibilities would be clearly separated:
RepositoriesService.List Method:
- Business Logic: Knows how to construct the specific API path for listing organization repositories (e.g.,
/orgs/{orgName}/repos). - Data Shaping: Knows the expected response is a slice of
Repositorystructs ([]Repository). It would declare a variable of this type to pass to the helper. - Delegation: Calls the shared
client.domethod, passing the context, HTTP method (GET), the constructed path, and a pointer to the slice for decoding.
client.do Helper Method:
- Request Boilerplate: Creates the
http.Requestobject. - Authentication & Headers: Adds the
AuthorizationandAcceptheaders to every request. - Execution: Calls
HTTPClient.Do()to send the request. - Generic Error Handling: Checks for non-2xx status codes.
- Generic Decoding: Decodes the JSON response body into the
interface{}variable it was given.
This separation of concerns makes the client easy to maintain and extend. Each service method only contains logic specific to its endpoint.
Conclusion
Congratulations! You've learned the fundamental patterns for building a professional, reusable API client in Go. This is a significant step up from writing simple scripts and is a core skill for creating robust DevOps tooling.
Key Takeaways:
- Package Structure: Isolate your client in its own package (e.g.,
github/). - Client Struct: Use a central
Clientstruct to hold the base URL, HTTP client, and API token. - Functional Options: Use the
Optionfunction pattern for a flexible and extensibleNewClientconstructor. - Service-Oriented Design: Group related API endpoints into services (e.g.,
UsersService,RepositoriesService) that delegate to the main client. - DRY with Helpers: Abstract common request/response logic into a private helper method (e.g.,
doorsendRequest). - Design for Testability: Depend on interfaces (like a
httpClientinterface) instead of concrete types to enable easy mocking in tests.
Next Up
Now that we have a reusable client package, the next logical step is to build a tool that uses it. In our next lesson, we will begin building a command-line interface (CLI) and learn how to parse command-line flags and arguments using Go's built-in flag package. This will be the user-facing part of the automation tools you aim to build.