lego/vendor/github.com/cloudflare/cloudflare-go/cloudflare.go
2019-07-04 18:24:33 +02:00

408 lines
13 KiB
Go

// Package cloudflare implements the Cloudflare v4 API.
package cloudflare
import (
"bytes"
"context"
"encoding/json"
"io"
"io/ioutil"
"log"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"golang.org/x/time/rate"
)
const apiURL = "https://api.cloudflare.com/client/v4"
const (
// AuthKeyEmail specifies that we should authenticate with API key and email address
AuthKeyEmail = 1 << iota
// AuthUserService specifies that we should authenticate with a User-Service key
AuthUserService
)
// API holds the configuration for the current API client. A client should not
// be modified concurrently.
type API struct {
APIKey string
APIEmail string
APIUserServiceKey string
BaseURL string
OrganizationID string
UserAgent string
headers http.Header
httpClient *http.Client
authType int
rateLimiter *rate.Limiter
retryPolicy RetryPolicy
logger Logger
}
// newClient provides shared logic for New and NewWithUserServiceKey
func newClient(opts ...Option) (*API, error) {
silentLogger := log.New(ioutil.Discard, "", log.LstdFlags)
api := &API{
BaseURL: apiURL,
headers: make(http.Header),
rateLimiter: rate.NewLimiter(rate.Limit(4), 1), // 4rps equates to default api limit (1200 req/5 min)
retryPolicy: RetryPolicy{
MaxRetries: 3,
MinRetryDelay: time.Duration(1) * time.Second,
MaxRetryDelay: time.Duration(30) * time.Second,
},
logger: silentLogger,
}
err := api.parseOptions(opts...)
if err != nil {
return nil, errors.Wrap(err, "options parsing failed")
}
// Fall back to http.DefaultClient if the package user does not provide
// their own.
if api.httpClient == nil {
api.httpClient = http.DefaultClient
}
return api, nil
}
// New creates a new Cloudflare v4 API client.
func New(key, email string, opts ...Option) (*API, error) {
if key == "" || email == "" {
return nil, errors.New(errEmptyCredentials)
}
api, err := newClient(opts...)
if err != nil {
return nil, err
}
api.APIKey = key
api.APIEmail = email
api.authType = AuthKeyEmail
return api, nil
}
// NewWithUserServiceKey creates a new Cloudflare v4 API client using service key authentication.
func NewWithUserServiceKey(key string, opts ...Option) (*API, error) {
if key == "" {
return nil, errors.New(errEmptyCredentials)
}
api, err := newClient(opts...)
if err != nil {
return nil, err
}
api.APIUserServiceKey = key
api.authType = AuthUserService
return api, nil
}
// SetAuthType sets the authentication method (AuthyKeyEmail or AuthUserService).
func (api *API) SetAuthType(authType int) {
api.authType = authType
}
// ZoneIDByName retrieves a zone's ID from the name.
func (api *API) ZoneIDByName(zoneName string) (string, error) {
res, err := api.ListZonesContext(context.TODO(), WithZoneFilter(zoneName))
if err != nil {
return "", errors.Wrap(err, "ListZonesContext command failed")
}
if len(res.Result) > 1 && api.OrganizationID == "" {
return "", errors.New("ambiguous zone name used without an account ID")
}
for _, zone := range res.Result {
if api.OrganizationID != "" {
if zone.Name == zoneName && api.OrganizationID == zone.Account.ID {
return zone.ID, nil
}
} else {
if zone.Name == zoneName {
return zone.ID, nil
}
}
}
return "", errors.New("Zone could not be found")
}
// makeRequest makes a HTTP request and returns the body as a byte slice,
// closing it before returnng. params will be serialized to JSON.
func (api *API) makeRequest(method, uri string, params interface{}) ([]byte, error) {
return api.makeRequestWithAuthType(context.TODO(), method, uri, params, api.authType)
}
func (api *API) makeRequestContext(ctx context.Context, method, uri string, params interface{}) ([]byte, error) {
return api.makeRequestWithAuthType(ctx, method, uri, params, api.authType)
}
func (api *API) makeRequestWithHeaders(method, uri string, params interface{}, headers http.Header) ([]byte, error) {
return api.makeRequestWithAuthTypeAndHeaders(context.TODO(), method, uri, params, api.authType, headers)
}
func (api *API) makeRequestWithAuthType(ctx context.Context, method, uri string, params interface{}, authType int) ([]byte, error) {
return api.makeRequestWithAuthTypeAndHeaders(ctx, method, uri, params, authType, nil)
}
func (api *API) makeRequestWithAuthTypeAndHeaders(ctx context.Context, method, uri string, params interface{}, authType int, headers http.Header) ([]byte, error) {
// Replace nil with a JSON object if needed
var jsonBody []byte
var err error
if params != nil {
if paramBytes, ok := params.([]byte); ok {
jsonBody = paramBytes
} else {
jsonBody, err = json.Marshal(params)
if err != nil {
return nil, errors.Wrap(err, "error marshalling params to JSON")
}
}
} else {
jsonBody = nil
}
var resp *http.Response
var respErr error
var reqBody io.Reader
var respBody []byte
for i := 0; i <= api.retryPolicy.MaxRetries; i++ {
if jsonBody != nil {
reqBody = bytes.NewReader(jsonBody)
}
if i > 0 {
// expect the backoff introduced here on errored requests to dominate the effect of rate limiting
// dont need a random component here as the rate limiter should do something similar
// nb time duration could truncate an arbitrary float. Since our inputs are all ints, we should be ok
sleepDuration := time.Duration(math.Pow(2, float64(i-1)) * float64(api.retryPolicy.MinRetryDelay))
if sleepDuration > api.retryPolicy.MaxRetryDelay {
sleepDuration = api.retryPolicy.MaxRetryDelay
}
// useful to do some simple logging here, maybe introduce levels later
api.logger.Printf("Sleeping %s before retry attempt number %d for request %s %s", sleepDuration.String(), i, method, uri)
time.Sleep(sleepDuration)
}
api.rateLimiter.Wait(context.TODO())
if err != nil {
return nil, errors.Wrap(err, "Error caused by request rate limiting")
}
resp, respErr = api.request(ctx, method, uri, reqBody, authType, headers)
// retry if the server is rate limiting us or if it failed
// assumes server operations are rolled back on failure
if respErr != nil || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
// if we got a valid http response, try to read body so we can reuse the connection
// see https://golang.org/pkg/net/http/#Client.Do
if respErr == nil {
respBody, err = ioutil.ReadAll(resp.Body)
resp.Body.Close()
respErr = errors.Wrap(err, "could not read response body")
api.logger.Printf("Request: %s %s got an error response %d: %s\n", method, uri, resp.StatusCode,
strings.Replace(strings.Replace(string(respBody), "\n", "", -1), "\t", "", -1))
} else {
api.logger.Printf("Error performing request: %s %s : %s \n", method, uri, respErr.Error())
}
continue
} else {
respBody, err = ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
return nil, errors.Wrap(err, "could not read response body")
}
break
}
}
if respErr != nil {
return nil, respErr
}
switch {
case resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices:
case resp.StatusCode == http.StatusUnauthorized:
return nil, errors.Errorf("HTTP status %d: invalid credentials", resp.StatusCode)
case resp.StatusCode == http.StatusForbidden:
return nil, errors.Errorf("HTTP status %d: insufficient permissions", resp.StatusCode)
case resp.StatusCode == http.StatusServiceUnavailable,
resp.StatusCode == http.StatusBadGateway,
resp.StatusCode == http.StatusGatewayTimeout,
resp.StatusCode == 522,
resp.StatusCode == 523,
resp.StatusCode == 524:
return nil, errors.Errorf("HTTP status %d: service failure", resp.StatusCode)
// This isn't a great solution due to the way the `default` case is
// a catch all and that the `filters/validate-expr` returns a HTTP 400
// yet the clients need to use the HTTP body as a JSON string.
case resp.StatusCode == 400 && strings.HasSuffix(resp.Request.URL.Path, "/filters/validate-expr"):
return nil, errors.Errorf("%s", respBody)
default:
var s string
if respBody != nil {
s = string(respBody)
}
return nil, errors.Errorf("HTTP status %d: content %q", resp.StatusCode, s)
}
return respBody, nil
}
// request makes a HTTP request to the given API endpoint, returning the raw
// *http.Response, or an error if one occurred. The caller is responsible for
// closing the response body.
func (api *API) request(ctx context.Context, method, uri string, reqBody io.Reader, authType int, headers http.Header) (*http.Response, error) {
req, err := http.NewRequest(method, api.BaseURL+uri, reqBody)
if err != nil {
return nil, errors.Wrap(err, "HTTP request creation failed")
}
req.WithContext(ctx)
combinedHeaders := make(http.Header)
copyHeader(combinedHeaders, api.headers)
copyHeader(combinedHeaders, headers)
req.Header = combinedHeaders
if authType&AuthKeyEmail != 0 {
req.Header.Set("X-Auth-Key", api.APIKey)
req.Header.Set("X-Auth-Email", api.APIEmail)
}
if authType&AuthUserService != 0 {
req.Header.Set("X-Auth-User-Service-Key", api.APIUserServiceKey)
}
if api.UserAgent != "" {
req.Header.Set("User-Agent", api.UserAgent)
}
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := api.httpClient.Do(req)
if err != nil {
return nil, errors.Wrap(err, "HTTP request failed")
}
return resp, nil
}
// Returns the base URL to use for API endpoints that exist for both accounts and organizations.
// If an Organization option was used when creating the API instance, returns the org URL.
//
// accountBase is the base URL for endpoints referring to the current user. It exists as a
// parameter because it is not consistent across APIs.
func (api *API) userBaseURL(accountBase string) string {
if api.OrganizationID != "" {
return "/accounts/" + api.OrganizationID
}
return accountBase
}
// copyHeader copies all headers for `source` and sets them on `target`.
// based on https://godoc.org/github.com/golang/gddo/httputil/header#Copy
func copyHeader(target, source http.Header) {
for k, vs := range source {
target[k] = vs
}
}
// ResponseInfo contains a code and message returned by the API as errors or
// informational messages inside the response.
type ResponseInfo struct {
Code int `json:"code"`
Message string `json:"message"`
}
// Response is a template. There will also be a result struct. There will be a
// unique response type for each response, which will include this type.
type Response struct {
Success bool `json:"success"`
Errors []ResponseInfo `json:"errors"`
Messages []ResponseInfo `json:"messages"`
}
// ResultInfo contains metadata about the Response.
type ResultInfo struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
Count int `json:"count"`
Total int `json:"total_count"`
}
// RawResponse keeps the result as JSON form
type RawResponse struct {
Response
Result json.RawMessage `json:"result"`
}
// Raw makes a HTTP request with user provided params and returns the
// result as untouched JSON.
func (api *API) Raw(method, endpoint string, data interface{}) (json.RawMessage, error) {
res, err := api.makeRequest(method, endpoint, data)
if err != nil {
return nil, errors.Wrap(err, errMakeRequestError)
}
var r RawResponse
if err := json.Unmarshal(res, &r); err != nil {
return nil, errors.Wrap(err, errUnmarshalError)
}
return r.Result, nil
}
// PaginationOptions can be passed to a list request to configure paging
// These values will be defaulted if omitted, and PerPage has min/max limits set by resource
type PaginationOptions struct {
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
}
// RetryPolicy specifies number of retries and min/max retry delays
// This config is used when the client exponentially backs off after errored requests
type RetryPolicy struct {
MaxRetries int
MinRetryDelay time.Duration
MaxRetryDelay time.Duration
}
// Logger defines the interface this library needs to use logging
// This is a subset of the methods implemented in the log package
type Logger interface {
Printf(format string, v ...interface{})
}
// ReqOption is a functional option for configuring API requests
type ReqOption func(opt *reqOption)
type reqOption struct {
params url.Values
}
// WithZoneFilter applies a filter based on zone name.
func WithZoneFilter(zone string) ReqOption {
return func(opt *reqOption) {
opt.params.Set("name", zone)
}
}
// WithPagination configures the pagination for a response.
func WithPagination(opts PaginationOptions) ReqOption {
return func(opt *reqOption) {
opt.params.Set("page", strconv.Itoa(opts.Page))
opt.params.Set("per_page", strconv.Itoa(opts.PerPage))
}
}