mirror of
https://github.com/diamondburned/arikawa.git
synced 2024-12-11 16:05:00 +00:00
d36955acea
Signed-off-by: Cléo Rebert <cleo.rebert@gmail.com>
274 lines
6.5 KiB
Go
274 lines
6.5 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"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"time"
|
|
|
|
"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 {
|
|
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 = fmt.Errorf("failed to apply http request options: %w", err)
|
|
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 = fmt.Errorf("OnResponse handler failed: %w", onRespErr)
|
|
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
|
|
}
|