From 3563b7bca50dcc33bd1449ab6d6505f4ca5364a7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 28 Mar 2024 04:26:58 +0000 Subject: [PATCH] feat(client): implement raw requests methods on client (#139) --- client.go | 72 +++++++++++++++++++ internal/requestconfig/requestconfig.go | 93 +++++++++++++++++++------ option/requestoption.go | 27 +++++-- 3 files changed, 165 insertions(+), 27 deletions(-) diff --git a/client.go b/client.go index 22fc8b7..ed27271 100644 --- a/client.go +++ b/client.go @@ -3,8 +3,11 @@ package finchgo import ( + "context" + "net/http" "os" + "github.com/Finch-API/finch-api-go/internal/requestconfig" "github.com/Finch-API/finch-api-go/option" ) @@ -60,3 +63,72 @@ func NewClient(opts ...option.RequestOption) (r *Client) { return } + +// Execute makes a request with the given context, method, URL, request params, +// response, and request options. This is useful for hitting undocumented endpoints +// while retaining the base URL, auth, retries, and other options from the client. +// +// If a byte slice or an [io.Reader] is supplied to params, it will be used as-is +// for the request body. +// +// The params is by default serialized into the body using [encoding/json]. If your +// type implements a MarshalJSON function, it will be used instead to serialize the +// request. If a URLQuery method is implemented, the returned [url.Values] will be +// used as query strings to the url. +// +// If your params struct uses [param.Field], you must provide either [MarshalJSON], +// [URLQuery], and/or [MarshalForm] functions. It is undefined behavior to use a +// struct uses [param.Field] without specifying how it is serialized. +// +// Any "…Params" object defined in this library can be used as the request +// argument. Note that 'path' arguments will not be forwarded into the url. +// +// The response body will be deserialized into the res variable, depending on its +// type: +// +// - A pointer to a [*http.Response] is populated by the raw response. +// - A pointer to a byte array will be populated with the contents of the request +// body. +// - A pointer to any other type uses this library's default JSON decoding, which +// respects UnmarshalJSON if it is defined on the type. +// - A nil value will not read the response body. +// +// For even greater flexibility, see [option.WithResponseInto] and +// [option.WithResponseBodyInto]. +func (r *Client) Execute(ctx context.Context, method string, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + opts = append(r.Options, opts...) + return requestconfig.ExecuteNewRequest(ctx, method, path, params, res, opts...) +} + +// Get makes a GET request with the given URL, params, and optionally deserializes +// to a response. See [Execute] documentation on the params and response. +func (r *Client) Get(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + return r.Execute(ctx, http.MethodGet, path, params, res, opts...) +} + +// Post makes a POST request with the given URL, params, and optionally +// deserializes to a response. See [Execute] documentation on the params and +// response. +func (r *Client) Post(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + return r.Execute(ctx, http.MethodPost, path, params, res, opts...) +} + +// Put makes a PUT request with the given URL, params, and optionally deserializes +// to a response. See [Execute] documentation on the params and response. +func (r *Client) Put(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + return r.Execute(ctx, http.MethodPut, path, params, res, opts...) +} + +// Patch makes a PATCH request with the given URL, params, and optionally +// deserializes to a response. See [Execute] documentation on the params and +// response. +func (r *Client) Patch(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + return r.Execute(ctx, http.MethodPatch, path, params, res, opts...) +} + +// Delete makes a DELETE request with the given URL, params, and optionally +// deserializes to a response. See [Execute] documentation on the params and +// response. +func (r *Client) Delete(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + return r.Execute(ctx, http.MethodDelete, path, params, res, opts...) +} diff --git a/internal/requestconfig/requestconfig.go b/internal/requestconfig/requestconfig.go index e408329..2565d87 100644 --- a/internal/requestconfig/requestconfig.go +++ b/internal/requestconfig/requestconfig.go @@ -77,24 +77,29 @@ func getPlatformProperties() map[string]string { } func NewRequestConfig(ctx context.Context, method string, u string, body interface{}, dst interface{}, opts ...func(*RequestConfig) error) (*RequestConfig, error) { - var b []byte + var reader io.Reader contentType := "application/json" hasSerializationFunc := false + if body, ok := body.(json.Marshaler); ok { - var err error - b, err = body.MarshalJSON() + content, err := body.MarshalJSON() if err != nil { return nil, err } + reader = bytes.NewBuffer(content) hasSerializationFunc = true } if body, ok := body.(apiform.Marshaler); ok { - var err error - b, contentType, err = body.MarshalMultipart() + var ( + content []byte + err error + ) + content, contentType, err = body.MarshalMultipart() if err != nil { return nil, err } + reader = bytes.NewBuffer(content) hasSerializationFunc = true } if body, ok := body.(apiquery.Queryer); ok { @@ -104,22 +109,30 @@ func NewRequestConfig(ctx context.Context, method string, u string, body interfa u = u + "?" + params } } + if body, ok := body.([]byte); ok { + reader = bytes.NewBuffer(body) + hasSerializationFunc = true + } + if body, ok := body.(io.Reader); ok { + reader = body + hasSerializationFunc = true + } // Fallback to json serialization if none of the serialization functions that we expect // to see is present. if body != nil && !hasSerializationFunc { - var err error - b, err = json.Marshal(body) + content, err := json.Marshal(body) if err != nil { return nil, err } + reader = bytes.NewBuffer(content) } req, err := http.NewRequestWithContext(ctx, method, u, nil) if err != nil { return nil, err } - if b != nil { + if reader != nil { req.Header.Set("Content-Type", contentType) } @@ -136,7 +149,7 @@ func NewRequestConfig(ctx context.Context, method string, u string, body interfa Context: ctx, Request: req, HTTPClient: http.DefaultClient, - Buffer: b, + Body: reader, } cfg.ResponseBodyInto = dst err = cfg.Apply(opts...) @@ -171,7 +184,7 @@ type RequestConfig struct { // ResponseInto copies the \*http.Response of the corresponding request into the // given address ResponseInto **http.Response - Buffer []byte + Body io.Reader } // middleware is exactly the same type as the Middleware type found in the [option] package, @@ -290,15 +303,32 @@ func retryDelay(res *http.Response, retryCount int) time.Duration { } func (cfg *RequestConfig) Execute() (err error) { - cfg.Request.URL, err = cfg.BaseURL.Parse(cfg.Request.URL.String()) + cfg.Request.URL, err = cfg.BaseURL.Parse(strings.TrimLeft(cfg.Request.URL.String(), "/")) if err != nil { return err } - if len(cfg.Buffer) != 0 && cfg.Request.Body == nil { - cfg.Request.ContentLength = int64(len(cfg.Buffer)) - cfg.Request.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(cfg.Buffer)), nil } - cfg.Request.Body, _ = cfg.Request.GetBody() + if cfg.Body != nil && cfg.Request.Body == nil { + switch body := cfg.Body.(type) { + case *bytes.Buffer: + b := body.Bytes() + cfg.Request.ContentLength = int64(body.Len()) + cfg.Request.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(b)), nil } + cfg.Request.Body, _ = cfg.Request.GetBody() + case *bytes.Reader: + cfg.Request.ContentLength = int64(body.Len()) + cfg.Request.GetBody = func() (io.ReadCloser, error) { + _, err := body.Seek(0, 0) + return io.NopCloser(body), err + } + cfg.Request.Body, _ = cfg.Request.GetBody() + default: + if rc, ok := body.(io.ReadCloser); ok { + cfg.Request.Body = rc + } else { + cfg.Request.Body = io.NopCloser(body) + } + } } handler := cfg.HTTPClient.Do @@ -331,9 +361,26 @@ func (cfg *RequestConfig) Execute() (err error) { } } + // Can't actually refresh the body, so we don't attempt to retry here + if cfg.Request.GetBody == nil && cfg.Request.Body != nil { + break + } + time.Sleep(retryDelay(res, retryCount)) } + // Save *http.Response if it is requested to, even if there was an error making the request. This is + // useful in cases where you might want to debug by inspecting the response. Note that if err != nil, + // the response should be generally be empty, but there are edge cases. + if cfg.ResponseInto != nil { + *cfg.ResponseInto = res + } + if responseBodyInto, ok := cfg.ResponseBodyInto.(**http.Response); ok { + *responseBodyInto = res + } + + // If there was a connection error in the final request or any other transport error, + // return that early without trying to coerce into an APIError. if err != nil { return err } @@ -354,16 +401,10 @@ func (cfg *RequestConfig) Execute() (err error) { return &aerr } - if cfg.ResponseInto != nil { - *cfg.ResponseInto = res - } - if cfg.ResponseBodyInto == nil { return nil } - - if responseBodyInto, ok := cfg.ResponseBodyInto.(**http.Response); ok { - *responseBodyInto = res + if _, ok := cfg.ResponseBodyInto.(**http.Response); ok { return nil } @@ -372,7 +413,7 @@ func (cfg *RequestConfig) Execute() (err error) { return fmt.Errorf("error reading response body: %w", err) } - // If we are not json return plaintext + // If we are not json, return plaintext isJSON := strings.Contains(res.Header.Get("content-type"), "application/json") if !isJSON { switch dst := cfg.ResponseBodyInto.(type) { @@ -389,6 +430,12 @@ func (cfg *RequestConfig) Execute() (err error) { return nil } + // If the response happens to be a byte array, deserialize the body as-is. + switch dst := cfg.ResponseBodyInto.(type) { + case *[]byte: + *dst = contents + } + err = json.NewDecoder(bytes.NewReader(contents)).Decode(cfg.ResponseBodyInto) if err != nil { err = fmt.Errorf("error parsing response json: %w", err) diff --git a/option/requestoption.go b/option/requestoption.go index ba5a292..721079d 100644 --- a/option/requestoption.go +++ b/option/requestoption.go @@ -3,6 +3,7 @@ package option import ( + "bytes" "encoding/base64" "fmt" "log" @@ -139,8 +140,17 @@ func WithQueryDel(key string) RequestOption { // [sjson format]: https://github.com/tidwall/sjson func WithJSONSet(key string, value interface{}) RequestOption { return func(r *requestconfig.RequestConfig) (err error) { - r.Buffer, err = sjson.SetBytes(r.Buffer, key, value) - return err + if buffer, ok := r.Body.(*bytes.Buffer); ok { + b := buffer.Bytes() + b, err = sjson.SetBytes(b, key, value) + if err != nil { + return err + } + r.Body = bytes.NewBuffer(b) + return nil + } + + return fmt.Errorf("cannot use WithJSONSet on a body that is not serialized as *bytes.Buffer") } } @@ -150,8 +160,17 @@ func WithJSONSet(key string, value interface{}) RequestOption { // [sjson format]: https://github.com/tidwall/sjson func WithJSONDel(key string) RequestOption { return func(r *requestconfig.RequestConfig) (err error) { - r.Buffer, err = sjson.DeleteBytes(r.Buffer, key) - return err + if buffer, ok := r.Body.(*bytes.Buffer); ok { + b := buffer.Bytes() + b, err = sjson.DeleteBytes(b, key) + if err != nil { + return err + } + r.Body = bytes.NewBuffer(b) + return nil + } + + return fmt.Errorf("cannot use WithJSONDel on a body that is not serialized as *bytes.Buffer") } }