This package offers an easy way of building http request and handling http responses.
The main goal is to simplify the amount of code required to perform simple and complex http request, which reduce development time, ease maintenance and tests.
Say we want to perform a POST request to example.com on /users/ to create a user using its email in the json body. This endpoint would return a 201 Created if the creation succeeded, or an error status, with a special 401 Unauthorized to catch.
A typical go code would look like this:
func performUserCreationRequest(ctx context.Context, userEmail string) (uint64, error) {
// serialization of the request content
body, err := json.Marshal(&UserCreationRequest{Email: userEmail})
if err != nil {
return 0, fmt.Errorf("unable to serialize in json: %v", err)
}
// create the request using the provided context to respect cancellation or deadlines
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://example.com/users", bytes.NewReader(body))
if err != nil {
return 0, fmt.Errorf("unable to create the request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
// the client used is hardcoded to be http.Default client but in real-life scenario it is probably injected somehow to ease tests
client := http.DefaultClient
// perform the request
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("unable to perform request: %v", err)
}
switch resp.StatusCode {
case http.StatusCreated:
// will be handled below
case http.StatusUnauthorized:
return 0, ErrUnauthorizedRequest
default:
return 0, fmt.Errorf("unhandled http status: %d", resp.StatusCode)
}
// deserialize the response
var userCreationResponse UserCreationResponse
if err := json.NewDecoder(resp.Body).Decode(&userCreationResponse); err != nil {
return 0, fmt.Errorf("unable to deserialize json: %v", err)
}
// return the newly generated user id
return userCreationResponse.UserID, nil
}
This is straightforward:
- create the json body
- create the request (don't forget some useful headers, don't forget the context propagation using NewRequestWithContext)
- perform the request on the provided http client
- handle errors (and treat differently authentication related error to return a sentinel error)
- handle success by parsing the json body
- return the parsed user id
but as much as this is a lovely and regular go code, this is a lot of code, and it is not perfect (testing each branches takes some effort, reviewers has to check the request is created using the WithContext method, and check for the right headers, ..., security issues related to not handling huge body by restricting body readers for instance, ...).
Here is the same code using this package:
func performUserCreationRequest(ctx context.Context, userEmail string) (uint64, error) {
var resp CreateUserResponse
if err := httpclient.NewRequest(http.MethodPost, "https://example.com/users/").
SendJSON(&CreateUserRequest{Email: userEmail}).
Do(ctx).
ReceiveJSON(http.StatusCreated, &resp).
ErrorOnStatus(http.StatusUnauthorized, ErrUnauthorizedRequest).
Error(); err != nil {
return 0, err
}
return resp.UserID, nil
}
which is a lot easier to write, read, and test.
To go even further, if we write multiple method for the same API, we can create an API object like:
api := httpclient.
NewAPI(client, url.URL{
Scheme: "https",
Host: "example.com",
}).
WithResponseHandler(http.StatusUnauthorized, func(rw *http.Response) error {
return ErrUnauthorizedRequest
})
this object can be configured using a lot of options to define some default behavior for each request,
it can be used like this:
func (api myAPIMethods) performUserCreationRequest(ctx context.Context, userEmail string) (uint64, error) {
var resp CreateUserResponse
if err := api.
Do(ctx, api.Post("/users/").SendJSON(&CreateUserRequest{Email: userEmail})).
ReceiveJSON(http.StatusCreated, &resp).
Error(); err != nil {
return 0, err
}
return resp.UserID, nil
}
which reduce duplication of error handling by setting common handler for common response handler, or by setting common request attributes. This is also useful to avoid repeating the API address and to reduce number of test cases.
Say we want to perform a PUT request to example.com on /users/$userID/email to update the email of the user identified using its $userID
,
with a json body containing an email attribute. This endpoint would return a 204 No Content if the update was taken into account immediately and in success,
or a status 202 Accepted meaning the update was successful but the propagation of this update is yet to be done (the response body would for this case contain an estimated date and time after which the propagation should be done).
A typical go code would look like this:
func performUpdateUserEmailRequest(ctx context.Context, userID uint64, userEmail string) (bool, error) {
// serialization of the request parameters and content
strUserID := strconv.FormatUint(userID, 10)
body, err := json.Marshal(&UpdateUserEmailRequest{Email: userEmail})
if err != nil {
return false, fmt.Errorf("unable to serialize in json: %v", err)
}
// create the request using the provided context to respect cancellation or deadlines
req, err := http.NewRequestWithContext(ctx, http.MethodPut, "https://example.com/users/"+strUserID+"/email", bytes.NewReader(body))
if err != nil {
return false, fmt.Errorf("unable to create the request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
// the client used is hardcoded to be http.Default client but in real-life scenario it is probably injected somehow to ease tests
client := http.DefaultClient
// perform the request
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("unable to perform request: %v", err)
}
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusAccepted:
// will be handled below
case http.StatusUnauthorized:
return false, ErrUnauthorizedRequest
default:
return false, fmt.Errorf("unhandled http status: %d", resp.StatusCode)
}
// deserialize the 201 response
var updateUserEmailResponse UpdateUserEmailResponse
if err := json.NewDecoder(resp.Body).Decode(&updateUserEmailResponse); err != nil {
return false, fmt.Errorf("unable to deserialize json: %v", err)
}
// return whenever the propagation time is in the future or not
return updateUserEmailResponse.PropagationTime.Before(time.Now()), nil
}
Here is the same code using the request builder and response handler:
func performUpdateUserEmailRequest(ctx context.Context, userID uint64, userEmail string) (bool, error) {
var resp UpdateUserEmailResponse
if err := httpclient.NewRequest(http.MethodPut, "http://example.com/users/{userID}/email").
PathReplacer("{userID}", strconv.FormatUint(userID, 10)).
SendJSON(&UpdateUserEmailRequest{Email: userEmail}).
Do(ctx).
SuccessOnStatus(http.StatusOK).
ReceiveJSON(http.StatusAccepted, &resp).
ErrorOnStatus(http.StatusUnauthorized, ErrUnauthorizedRequest).
Error(); err != nil {
return false, err
}
return resp.PropagationTime.Before(time.Now()), nil
}
More examples on the API object and on how to ease tests can be browsed in ./internal/example
package.
This project is using nix, which mean by using nix develop
you can have the same development environment than me or GitHub Action.
It contains everything needed to develop, lint, and test.
You can also use act
to run the same steps GitHub Action is using during pull requests, but locally.
Feel free to open issues and pull requests!