Skip to content

Commit

Permalink
Allow request building outside of httpclient.Client
Browse files Browse the repository at this point in the history
Requests can now be built independently, while taking advantage of
the functional option toolkit, without requiring use of the Client API.

- Properly clone query parameters in `RequestParameters.QueryParameters`.
- Documentation enhancements.
- Add a top-level example.
  • Loading branch information
georgepsarakis committed Oct 6, 2024
1 parent 3f63c0b commit 4c6857e
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 10 deletions.
16 changes: 8 additions & 8 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ func (c *Client) BaseURL() string {
return c.baseURL.String()
}

// WithJSONContentType sets the Content-Type default header to `application/json; charset=utf-8`.
func (c *Client) WithJSONContentType() *Client {
return c.WithDefaultHeaders(map[string]string{"Content-Type": "application/json"})
return c.WithDefaultHeaders(map[string]string{"Content-Type": "application/json; charset=utf-8"})
}

func (c *Client) Get(ctx context.Context, url string, parameters ...RequestParameter) (*http.Response, error) {
Expand All @@ -87,10 +88,6 @@ func (c *Client) Get(ctx context.Context, url string, parameters ...RequestParam
}

func (c *Client) prepareRequest(ctx context.Context, method string, rawURL string, body io.Reader, parameters ...RequestParameter) (*http.Request, error) {
var reqParams []RequestParameter
reqParams = append(reqParams, WithHeaders(c.defaultHeaders))
reqParams = append(reqParams, parameters...)
params := NewRequestParameters(reqParams...)
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
Expand All @@ -101,12 +98,15 @@ func (c *Client) prepareRequest(ctx context.Context, method string, rawURL strin
} else {
fullURL = parsedURL
}
req, err := http.NewRequestWithContext(ctx, method, fullURL.String(), body)
finalURL := fullURL.String()
var reqParams []RequestParameter
reqParams = append(reqParams, WithHeaders(c.defaultHeaders))
reqParams = append(reqParams, parameters...)

req, err := NewRequest(ctx, method, finalURL, body, reqParams...)
if err != nil {
return nil, err
}
req.Header = params.headers
req.URL.RawQuery += params.queryParams.Encode()
return req, nil
}

Expand Down
2 changes: 1 addition & 1 deletion client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func TestClient_Get_JSON(t *testing.T) {
httpmock.RegisterMatcherResponder(
http.MethodGet,
"https://api.github.com/repos/georgepsarakis/go-httpclient",
httpmock.HeaderIs("Content-Type", "application/json"),
httpmock.HeaderIs("Content-Type", "application/json; charset=utf-8"),
responder,
)
url := "/repos/georgepsarakis/go-httpclient"
Expand Down
79 changes: 79 additions & 0 deletions examples_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package httpclient

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

func ExampleClient_Get() {
sdk := NewSDK()
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)
}
}

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
}
1 change: 1 addition & 0 deletions httpassert/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/require"
)

// PrintJSON is a debugging test helper that will pretty-print any JSON-marshallable value.
func PrintJSON(t *testing.T, v any) {
b, err := json.MarshalIndent(v, "", " ")
require.NoError(t, err)
Expand Down
7 changes: 7 additions & 0 deletions httpassert/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import (
"github.com/stretchr/testify/require"
)

// ResponseEqual compares to http.Response objects for equality.
// Individual field comparisons are enabled by the non-nil checks.
// For example, if the expected `http.Response.Header` field is `nil`,
// no comparison with actual `http.Response.Header` takes place.
// JSON responses are autodetected and the response body payloads will be compared
// as valid JSON using `assert.JSONEq`.
func ResponseEqual(t *testing.T, actual, expected *http.Response) {
t.Helper()

Expand Down Expand Up @@ -44,6 +50,7 @@ func ResponseEqual(t *testing.T, actual, expected *http.Response) {
}
}

// SuccessfulJSONResponseEqual is a shorthand for asserting the JSON body contents for a successful response.
func SuccessfulJSONResponseEqual(t *testing.T, actual *http.Response, body []byte) {
t.Helper()
ResponseEqual(t, actual, &http.Response{
Expand Down
6 changes: 6 additions & 0 deletions httptesting/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func (c *Client) Delete(ctx context.Context, url string, parameters ...httpclien
return c.Client.Delete(ctx, url, parameters...)
}

// WithBaseURL sets the base URL setting for the underlying `httpclient.Client`.
func (c *Client) WithBaseURL(baseURL string) (*Client, error) {
_, err := c.Client.WithBaseURL(baseURL)
if err != nil {
Expand Down Expand Up @@ -115,22 +116,27 @@ func (r *MockRequest) Register() {
r.responder)
}

// String provides a representation of the mock request. Only used for debugging purposes.
func (r *MockRequest) String() string {
return fmt.Sprintf("MockRequest: [%s] %s", r.req.Method, r.req.URL.String())
}

// Responder provides access to the current responder for inspection or direct operations.
func (r *MockRequest) Responder(resp httpmock.Responder) *MockRequest {
r.responder = resp
return r
}

// RespondWithJSON will configure a JSON response with the given status code.
func (r *MockRequest) RespondWithJSON(statusCode int, body string) *MockRequest {
responder, err := httpmock.NewJsonResponder(statusCode, json.RawMessage(body))
require.NoError(r.t, err)
r.responder = responder
return r
}

// RespondWithHeaders configures the response headers. It can be used multiple times in order to pass different headers.
// If the header key already exists it will be overwritten.
func (r *MockRequest) RespondWithHeaders(respHeaders map[string]string) *MockRequest {
h := http.Header{}
for k, v := range respHeaders {
Expand Down
29 changes: 28 additions & 1 deletion request.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package httpclient

import (
"bytes"
"context"
"io"
"net/http"
"net/url"
Expand All @@ -16,13 +17,17 @@ type RequestParameters struct {
errorCodes []int
}

// QueryParameters returns a clone of the currently configured query parameters.
// Multiple calls will override already existing keys.
func (rp *RequestParameters) QueryParameters() url.Values {
if rp.queryParams == nil {
return nil
}
qp := make(url.Values, len(rp.queryParams))
for k, v := range rp.queryParams {
qp[k] = v
for _, qv := range v {
qp.Add(k, qv)
}
}
return qp
}
Expand All @@ -35,6 +40,8 @@ func (rp *RequestParameters) ErrorCodes() []int {
return rp.errorCodes
}

// WithQueryParameters configures the given name-value pairs as Query String parameters for the request.
// Multiple calls will override values for existing keys.
func WithQueryParameters(params map[string]string) RequestParameter {
return func(opts *RequestParameters) {
if opts.queryParams == nil {
Expand All @@ -46,6 +53,8 @@ func WithQueryParameters(params 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 WithHeaders(headers map[string]string) RequestParameter {
return func(opts *RequestParameters) {
if opts.headers == nil {
Expand Down Expand Up @@ -84,3 +93,21 @@ func MustInterceptRequestBody(r *http.Request) []byte {
}
return b
}

// NewRequest builds a new request based on the given Method, full URL, body and optional functional option parameters.
func NewRequest(ctx context.Context, method string, rawURL string, body io.Reader, parameters ...RequestParameter) (*http.Request, error) {
reqParams := NewRequestParameters(parameters...)
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
if encodedQP := reqParams.queryParams.Encode(); encodedQP != "" {
parsedURL.RawQuery += encodedQP
}
req, err := http.NewRequestWithContext(ctx, method, parsedURL.String(), body)
if err != nil {
return nil, err
}
req.Header = reqParams.headers
return req, nil
}
10 changes: 10 additions & 0 deletions response.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import (
"reflect"
)

// 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 DeserializeJSON(resp *http.Response, target any) error {
v := reflect.ValueOf(target)
if v.Kind() != reflect.Ptr {
Expand All @@ -21,6 +26,11 @@ func DeserializeJSON(resp *http.Response, target any) error {
return json.Unmarshal(b, target)
}

// 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 InterceptResponseBody(r *http.Response) ([]byte, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
Expand Down

0 comments on commit 4c6857e

Please sign in to comment.