mirror of
https://github.com/diamondburned/arikawa.git
synced 2024-11-01 04:24:19 +00:00
d8438f3b51
This commit gets rid of contain-it-all structs and instead opt for interface union types containing underlying concrete types with no overloading. The code is much more verbose by doing this, but the API is much nicer to use. The only disadvantage in that regard is the interface assertion being too verbose and risky for users at times.
277 lines
6.6 KiB
Go
277 lines
6.6 KiB
Go
// Package httputil provides abstractions around the common needs of HTTP. It
|
|
// also allows swapping in and out the HTTP client.
|
|
package httputil
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"mime/multipart"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/diamondburned/arikawa/v3/utils/httputil/httpdriver"
|
|
"github.com/diamondburned/arikawa/v3/utils/json"
|
|
)
|
|
|
|
// StatusTooManyRequests is the HTTP status code discord sends on rate-limiting.
|
|
const StatusTooManyRequests = 429
|
|
|
|
// Retries is the default attempts to retry if the API returns an error before
|
|
// giving up. If the value is smaller than 1, then requests will retry forever.
|
|
var Retries uint = 5
|
|
|
|
type Client struct {
|
|
httpdriver.Client
|
|
SchemaEncoder
|
|
|
|
// OnRequest, if not nil, will be copied and prefixed on each Request.
|
|
OnRequest []RequestOption
|
|
|
|
// OnResponse is called after every Do() call. Response might be nil if Do()
|
|
// errors out. The error returned will override Do's if it's not nil.
|
|
OnResponse []ResponseFunc
|
|
|
|
// Timeout is the maximum amount of time the client will wait for a request
|
|
// to finish. If this is 0 or smaller the Client won't time out. Otherwise,
|
|
// the timeout will be used as deadline for context of every request.
|
|
Timeout time.Duration
|
|
|
|
// Default to the global Retries variable (5).
|
|
Retries uint
|
|
|
|
context context.Context
|
|
}
|
|
|
|
func NewClient() *Client {
|
|
return &Client{
|
|
Client: httpdriver.NewClient(),
|
|
SchemaEncoder: &DefaultSchema{},
|
|
Retries: Retries,
|
|
context: context.Background(),
|
|
}
|
|
}
|
|
|
|
// Copy returns a shallow copy of the client.
|
|
func (c *Client) Copy() *Client {
|
|
cl := new(Client)
|
|
*cl = *c
|
|
return cl
|
|
}
|
|
|
|
// WithContext returns a client copy of the client with the given context.
|
|
func (c *Client) WithContext(ctx context.Context) *Client {
|
|
c = c.Copy()
|
|
c.context = ctx
|
|
return c
|
|
}
|
|
|
|
// Context is a shared context for all future calls. It's Background by
|
|
// default.
|
|
func (c *Client) Context() context.Context {
|
|
return c.context
|
|
}
|
|
|
|
// applyOptions tries to apply all options. It does not halt if a single option
|
|
// fails, and the error returned is the latest error.
|
|
func (c *Client) applyOptions(r httpdriver.Request, extra []RequestOption) (e error) {
|
|
for _, opt := range c.OnRequest {
|
|
if err := opt(r); err != nil {
|
|
e = err
|
|
}
|
|
}
|
|
|
|
for _, opt := range extra {
|
|
if err := opt(r); err != nil {
|
|
e = err
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// MultipartWriter is the interface for a data structure that can write into a
|
|
// multipart writer.
|
|
type MultipartWriter interface {
|
|
WriteMultipart(body *multipart.Writer) error
|
|
}
|
|
|
|
// MeanwhileMultipart concurrently encodes and writes the given multipart writer
|
|
// at the same time. The writer will be called in another goroutine, but the
|
|
// writer will be closed when MeanwhileMultipart returns.
|
|
func (c *Client) MeanwhileMultipart(
|
|
writer MultipartWriter,
|
|
method, url string, opts ...RequestOption) (httpdriver.Response, error) {
|
|
|
|
r, w := io.Pipe()
|
|
body := multipart.NewWriter(w)
|
|
|
|
// Ensure the writer is closed by the time this function exits, so
|
|
// WriteMultipart will exit.
|
|
defer w.Close()
|
|
|
|
go func() {
|
|
err := writer.WriteMultipart(body)
|
|
body.Close()
|
|
w.CloseWithError(err)
|
|
}()
|
|
|
|
// Prepend the multipart writer and the correct Content-Type header options.
|
|
opts = PrependOptions(
|
|
opts,
|
|
WithBody(r),
|
|
WithContentType(body.FormDataContentType()),
|
|
)
|
|
|
|
// Request with the current client and our own context:
|
|
return c.Request(method, url, opts...)
|
|
}
|
|
|
|
// FastRequest performs a request without waiting for the body.
|
|
func (c *Client) FastRequest(method, url string, opts ...RequestOption) error {
|
|
r, err := c.Request(method, url, opts...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return r.GetBody().Close()
|
|
}
|
|
|
|
// RequestJSON performs a request and unmarshals the JSON body into "to".
|
|
func (c *Client) RequestJSON(to interface{}, method, url string, opts ...RequestOption) error {
|
|
opts = PrependOptions(opts, JSONRequest)
|
|
|
|
r, err := c.Request(method, url, opts...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var body, status = r.GetBody(), r.GetStatus()
|
|
defer body.Close()
|
|
|
|
// No content, working as intended (tm)
|
|
if status == httpdriver.NoContent {
|
|
return nil
|
|
}
|
|
// to is nil for some reason. Ignore.
|
|
if to == nil {
|
|
return nil
|
|
}
|
|
|
|
if err := json.DecodeStream(body, to); err != nil {
|
|
return JSONError{err}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Request performs a request and returns a response with an unread body. The
|
|
// caller must close it manually.
|
|
func (c *Client) Request(method, url string, opts ...RequestOption) (httpdriver.Response, error) {
|
|
response, cancel, err := c.request(method, url, opts)
|
|
if err != nil {
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if cancel != nil {
|
|
return wrapCancelableResponse(response, cancel), nil
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (c *Client) request(
|
|
method, url string,
|
|
opts []RequestOption) (r httpdriver.Response, cancel context.CancelFunc, doErr error) {
|
|
|
|
// Error that represents the latest error in the chain.
|
|
var onRespErr error
|
|
|
|
var status int
|
|
|
|
ctx := c.context
|
|
|
|
if c.Timeout > 0 {
|
|
ctx, cancel = context.WithTimeout(ctx, c.Timeout)
|
|
}
|
|
|
|
// The c.Retries < 1 check ensures that we retry forever if that field is
|
|
// less than 1.
|
|
for i := uint(0); c.Retries < 1 || i < c.Retries; i++ {
|
|
q, err := c.Client.NewRequest(ctx, method, url)
|
|
if err != nil {
|
|
doErr = RequestError{err}
|
|
return
|
|
}
|
|
|
|
if err := c.applyOptions(q, opts); err != nil {
|
|
// We failed to apply an option, so we should call all OnResponse
|
|
// handler to clean everything up.
|
|
for _, fn := range c.OnResponse {
|
|
fn(q, nil)
|
|
}
|
|
|
|
doErr = errors.Wrap(err, "failed to apply http request options")
|
|
return
|
|
}
|
|
|
|
r, doErr = c.Client.Do(q)
|
|
|
|
// Call OnResponse() even if the request failed.
|
|
for _, fn := range c.OnResponse {
|
|
// Be sure to call ALL OnResponse handlers.
|
|
if err := fn(q, r); err != nil {
|
|
onRespErr = err
|
|
}
|
|
}
|
|
|
|
if onRespErr != nil || doErr != nil {
|
|
continue
|
|
}
|
|
|
|
if status = r.GetStatus(); status == StatusTooManyRequests || status >= 500 {
|
|
continue
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
if onRespErr != nil {
|
|
doErr = errors.Wrap(onRespErr, "OnResponse handler failed")
|
|
return
|
|
}
|
|
|
|
// If all retries failed, then wrap and return.
|
|
if doErr != nil {
|
|
doErr = RequestError{doErr}
|
|
return
|
|
}
|
|
|
|
// Response received, but with a failure status code:
|
|
if status < 200 || status > 299 {
|
|
// Try and parse the body.
|
|
var body = r.GetBody()
|
|
defer body.Close()
|
|
|
|
// This rarely happens, so we can (probably) make an exception for it.
|
|
buf := bytes.Buffer{}
|
|
buf.ReadFrom(body)
|
|
|
|
httpErr := &HTTPError{
|
|
Status: status,
|
|
Body: buf.Bytes(),
|
|
}
|
|
|
|
// Optionally unmarshal the error.
|
|
json.Unmarshal(httpErr.Body, httpErr)
|
|
|
|
doErr = httpErr
|
|
}
|
|
|
|
return
|
|
}
|