Skip to content

Commit

Permalink
feat(client): implement raw requests methods on client (#139)
Browse files Browse the repository at this point in the history
  • Loading branch information
stainless-app[bot] authored Mar 28, 2024
1 parent 824cbff commit 3563b7b
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 27 deletions.
72 changes: 72 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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...)
}
93 changes: 70 additions & 23 deletions internal/requestconfig/requestconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}

Expand All @@ -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...)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}

Expand All @@ -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) {
Expand All @@ -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)
Expand Down
27 changes: 23 additions & 4 deletions option/requestoption.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package option

import (
"bytes"
"encoding/base64"
"fmt"
"log"
Expand Down Expand Up @@ -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")
}
}

Expand All @@ -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")
}
}

Expand Down

0 comments on commit 3563b7b

Please sign in to comment.