Building a Go REST client in 2022

Cheikh seck
Dev Genius
Published in
5 min readAug 10, 2022

--

Image by Christina @ wocintechchat.com, from unsplash

It’s 2022, and RESTful APIs are still being consumed by developers. With over 90% of developers implementing or integrating it, REST APIs have lived up to Roy Fielding’s ambitions. The introduction of Generics in Go simplifies the REST API integration. In this post, I’ll walk you through my approach to building a type-safe API client with Go. I’ll be using https://reqres.in’s API to get test data. The types defined in this post represent reqres’ API data.

Got Data?

Since this is meant to be a type-safe client, I’ll start by defining my API types. Here is the type that resembles the JSON my API will return :

type RequestObj struct {
TotalPage int `json:"total"`
Data []Profile `json:"data"`
}
type Profile struct {
Avatar string `json:"avatar"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}

RequestObj is similar to the data structure returned by the API. Next I’ll start defining a generic function to handle GET requests. The function will have the following signature :

func Get[T any](ctx context.Context, url string) (T, error)

The first line of code I’ll add will be var m T , this will define a generic variable. I’ll use it for early returns, as I’ve not processed any JSON yet. The next line will initialize an http.Request object. The following code will do so :

r, err := http.NewRequestWithContext(ctx, "GET", url, nil)

I chose to initialize the request object with a context to be able to set a request timeout. The next step would be to actually perform the request. I’ll be using http package’s default client. The code to launch to request is as follows :

res, err := http.DefaultClient.Do(r)

Next, I’ll use the io package to read all the bytes from the response. Once read, I’ll pass the bytes with the generic type to Go’s JSON unmarshaller. Here is the full Get function :

func Get[T any](ctx context.Context, url string) (T, error) {  var m T
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return m, err
}
res, err := http.DefaultClient.Do(r) if err != nil {
return m, err
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return m, err
}
return parseJSON[T](body)
}
func parseJSON[T any](s []byte) (T, error) {
var r T
if err := json.Unmarshal(s, &r); err != nil {
return r, err
}
return r, nil
}

Now that I have all this neat code, it was time to implement it. Here is an implementation of the client defined above. This will get the data and log the first entry to the console :

package mainimport (
"context"
"fmt"
"log"
"time"
)
func main() { ctx := context.Background()
timeout := 30 * time.Second
reqContext, _ := context.WithTimeout(ctx, timeout)
m, err := Get[RequestObj](reqContext, "https://reqres.in/api/users?page=2")
if err != nil {
log.Fatal(err)
}
fmt.Println(m.Data[0])}

Here is the code in action :

The next step will be to tackle posting data.

Making Data

I’ll start by defining the type I’ll be using for the POST request. Here is the code to define the type :

type User struct {
Name string `json:"name,omitempty"`
Job string `json:"job,omitempty"`
ID string `json:"id,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
}

If you noticed, I’m making heavy use of omitempty , this will prevent sending default field data to the API server. Next, I’ll define the generic function that will post data to the API. The function will have the following signature :

func Post[T any](ctx context.Context, url string, data any) (T, error)

The Post function will be similar to Get , except for the fact that it will initialize an io Reader and set a header for the request. So, I’ll walk through those 2 processes. I’ll also need to convert my type into a byte array. Here is the function that will perform said task :

func toJSON(T any) ([]byte, error) {
return json.Marshal(T)
}

Back to the Post function, I’ll proceed by initializing an io Reader with the bytes generated from my type. I’ll perform this task with the following code :

b,_ := toJSON(data)
...
byteReader := bytes.NewReader(b)

Now that I have an io Reader, I’ll initialize a request with it :

r, err := http.NewRequestWithContext(ctx, "POST", url, byteReader)

Once the request is initialized, I’ll add a header to the request. This will tell the REST server how to make sense of the data I’m sending.

r.Header.Add("Content-Type", "application/json")

Here is the complete Post function :

func Post[T any](ctx context.Context, url string, data any) (T, error) {  var m T  b, err := toJSON(data)  if err != nil {
return m, err
}
byteReader := bytes.NewReader(b) r, err := http.NewRequestWithContext(ctx, "POST", url, byteReader) if err != nil {
return m, err
}
// Important to set
r.Header.Add("Content-Type", "application/json")
res, err := http.DefaultClient.Do(r) if err != nil {
return m, err
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return m, err
}
return parseJSON[T](body)
}

Here is an implementation of function Post :

package mainimport (
"context"
"fmt"
"log"
"time"
)
func main() { ctx := context.Background()
timeout := 30 * time.Second
// Post data
user := User{ Name : "morpheus", Job : "leader"}
addContext, _ := context.WithTimeout(ctx, timeout) newUser, err := Post[User](addContext, "https://reqres.in/api/users", user) if err != nil {
log.Fatal(err)
}
fmt.Println( newUser )}

Here is the code in action, you can also see the additional data generated by the server :

Conclusion

Generics are a great addition to Go. It enabled me to add fluidity to my API clients. Fluid in the sense that I can retrieve and assert data to a type with one line of code ;). It also removes the need for the antiquated process of defining a variable and passing it to simulate generic behavior. An important reminder, that I did not mention is : do not forget to check for HTTP error codes as well!

Additional Information

--

--