diff --git a/client.go b/client.go index a8f8017..b95353a 100644 --- a/client.go +++ b/client.go @@ -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) { @@ -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 @@ -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 } diff --git a/client_test.go b/client_test.go index c55cff4..eaba34a 100644 --- a/client_test.go +++ b/client_test.go @@ -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" diff --git a/examples_test.go b/examples_test.go new file mode 100644 index 0000000..aa734e7 --- /dev/null +++ b/examples_test.go @@ -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 +} diff --git a/httpassert/debug.go b/httpassert/debug.go index 96be4bb..00cf6ff 100644 --- a/httpassert/debug.go +++ b/httpassert/debug.go @@ -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) diff --git a/httpassert/response.go b/httpassert/response.go index fc20f6a..07d696c 100644 --- a/httpassert/response.go +++ b/httpassert/response.go @@ -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() @@ -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{ diff --git a/httptesting/client.go b/httptesting/client.go index 47a0312..5007ebc 100644 --- a/httptesting/client.go +++ b/httptesting/client.go @@ -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 { @@ -115,15 +116,18 @@ 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) @@ -131,6 +135,8 @@ func (r *MockRequest) RespondWithJSON(statusCode int, body string) *MockRequest 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 { diff --git a/request.go b/request.go index 09774cf..1d95a54 100644 --- a/request.go +++ b/request.go @@ -2,6 +2,7 @@ package httpclient import ( "bytes" + "context" "io" "net/http" "net/url" @@ -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 } @@ -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 { @@ -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 { @@ -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 +} diff --git a/response.go b/response.go index 9d6d991..2e4e550 100644 --- a/response.go +++ b/response.go @@ -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 { @@ -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 {