gitee.com/liuxuezhan/go-micro-v1.18.0@v1.0.0/store/cloudflare/cloudflare.go (about)

     1  // Package cloudflare is a store implementation backed by cloudflare workers kv
     2  // Note that the cloudflare workers KV API is eventually consistent.
     3  package cloudflare
     4  
     5  import (
     6  	"bytes"
     7  	"context"
     8  	"encoding/json"
     9  	"fmt"
    10  	"io"
    11  	"io/ioutil"
    12  	"log"
    13  	"math"
    14  	"net/http"
    15  	"net/url"
    16  	"os"
    17  	"strconv"
    18  	"time"
    19  
    20  	"gitee.com/liuxuezhan/go-micro-v1.18.0/config/options"
    21  	"gitee.com/liuxuezhan/go-micro-v1.18.0/store"
    22  	"github.com/pkg/errors"
    23  )
    24  
    25  const (
    26  	apiBaseURL = "https://api.cloudflare.com/client/v4/"
    27  )
    28  
    29  type workersKV struct {
    30  	options.Options
    31  	// cf account id
    32  	account string
    33  	// cf api token
    34  	token string
    35  	// cf kv namespace
    36  	namespace string
    37  	// http client to use
    38  	httpClient *http.Client
    39  }
    40  
    41  // apiResponse is a cloudflare v4 api response
    42  type apiResponse struct {
    43  	Result []struct {
    44  		ID         string    `json:"id"`
    45  		Type       string    `json:"type"`
    46  		Name       string    `json:"name"`
    47  		Expiration string    `json:"expiration"`
    48  		Content    string    `json:"content"`
    49  		Proxiable  bool      `json:"proxiable"`
    50  		Proxied    bool      `json:"proxied"`
    51  		TTL        int       `json:"ttl"`
    52  		Priority   int       `json:"priority"`
    53  		Locked     bool      `json:"locked"`
    54  		ZoneID     string    `json:"zone_id"`
    55  		ZoneName   string    `json:"zone_name"`
    56  		ModifiedOn time.Time `json:"modified_on"`
    57  		CreatedOn  time.Time `json:"created_on"`
    58  	} `json:"result"`
    59  	Success bool         `json:"success"`
    60  	Errors  []apiMessage `json:"errors"`
    61  	// not sure Messages is ever populated?
    62  	Messages   []apiMessage `json:"messages"`
    63  	ResultInfo struct {
    64  		Page       int `json:"page"`
    65  		PerPage    int `json:"per_page"`
    66  		Count      int `json:"count"`
    67  		TotalCount int `json:"total_count"`
    68  	} `json:"result_info"`
    69  }
    70  
    71  // apiMessage is a Cloudflare v4 API Error
    72  type apiMessage struct {
    73  	Code    int    `json:"code"`
    74  	Message string `json:"message"`
    75  }
    76  
    77  // getOptions returns account id, token and namespace
    78  func getOptions() (string, string, string) {
    79  	accountID := os.Getenv("CF_ACCOUNT_ID")
    80  	apiToken := os.Getenv("CF_API_TOKEN")
    81  	namespace := os.Getenv("KV_NAMESPACE_ID")
    82  
    83  	return accountID, apiToken, namespace
    84  }
    85  
    86  func validateOptions(account, token, namespace string) {
    87  	if len(account) == 0 {
    88  		log.Fatal("Store: CF_ACCOUNT_ID is blank")
    89  	}
    90  
    91  	if len(token) == 0 {
    92  		log.Fatal("Store: CF_API_TOKEN is blank")
    93  	}
    94  
    95  	if len(namespace) == 0 {
    96  		log.Fatal("Store: KV_NAMESPACE_ID is blank")
    97  	}
    98  }
    99  
   100  // In the cloudflare workers KV implemention, List() doesn't guarantee
   101  // anything as the workers API is eventually consistent.
   102  func (w *workersKV) List() ([]*store.Record, error) {
   103  	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
   104  	defer cancel()
   105  
   106  	path := fmt.Sprintf("accounts/%s/storage/kv/namespaces/%s/keys", w.account, w.namespace)
   107  
   108  	response, _, _, err := w.request(ctx, http.MethodGet, path, nil, make(http.Header))
   109  	if err != nil {
   110  		return nil, err
   111  	}
   112  
   113  	a := &apiResponse{}
   114  	if err := json.Unmarshal(response, a); err != nil {
   115  		return nil, err
   116  	}
   117  
   118  	if !a.Success {
   119  		messages := ""
   120  		for _, m := range a.Errors {
   121  			messages += strconv.Itoa(m.Code) + " " + m.Message + "\n"
   122  		}
   123  		return nil, errors.New(messages)
   124  	}
   125  
   126  	keys := make([]string, 0, len(a.Result))
   127  
   128  	for _, r := range a.Result {
   129  		keys = append(keys, r.Name)
   130  	}
   131  
   132  	return w.Read(keys...)
   133  }
   134  
   135  func (w *workersKV) Read(keys ...string) ([]*store.Record, error) {
   136  	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
   137  	defer cancel()
   138  
   139  	//nolint:prealloc
   140  	var records []*store.Record
   141  
   142  	for _, k := range keys {
   143  		path := fmt.Sprintf("accounts/%s/storage/kv/namespaces/%s/values/%s", w.account, w.namespace, url.PathEscape(k))
   144  		response, headers, status, err := w.request(ctx, http.MethodGet, path, nil, make(http.Header))
   145  		if err != nil {
   146  			return records, err
   147  		}
   148  		if status < 200 || status >= 300 {
   149  			return records, errors.New("Received unexpected Status " + strconv.Itoa(status) + string(response))
   150  		}
   151  		record := &store.Record{
   152  			Key:   k,
   153  			Value: response,
   154  		}
   155  		if expiry := headers.Get("Expiration"); len(expiry) != 0 {
   156  			expiryUnix, err := strconv.ParseInt(expiry, 10, 64)
   157  			if err != nil {
   158  				return records, err
   159  			}
   160  			record.Expiry = time.Until(time.Unix(expiryUnix, 0))
   161  		}
   162  		records = append(records, record)
   163  	}
   164  
   165  	return records, nil
   166  }
   167  
   168  func (w *workersKV) Write(records ...*store.Record) error {
   169  	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
   170  	defer cancel()
   171  
   172  	for _, r := range records {
   173  		path := fmt.Sprintf("accounts/%s/storage/kv/namespaces/%s/values/%s", w.account, w.namespace, url.PathEscape(r.Key))
   174  		if r.Expiry != 0 {
   175  			// Minimum cloudflare TTL is 60 Seconds
   176  			exp := int(math.Max(60, math.Round(r.Expiry.Seconds())))
   177  			path = path + "?expiration_ttl=" + strconv.Itoa(exp)
   178  		}
   179  
   180  		headers := make(http.Header)
   181  
   182  		resp, _, _, err := w.request(ctx, http.MethodPut, path, r.Value, headers)
   183  		if err != nil {
   184  			return err
   185  		}
   186  
   187  		a := &apiResponse{}
   188  		if err := json.Unmarshal(resp, a); err != nil {
   189  			return err
   190  		}
   191  
   192  		if !a.Success {
   193  			messages := ""
   194  			for _, m := range a.Errors {
   195  				messages += strconv.Itoa(m.Code) + " " + m.Message + "\n"
   196  			}
   197  			return errors.New(messages)
   198  		}
   199  	}
   200  
   201  	return nil
   202  }
   203  
   204  func (w *workersKV) Delete(keys ...string) error {
   205  	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
   206  	defer cancel()
   207  
   208  	for _, k := range keys {
   209  		path := fmt.Sprintf("accounts/%s/storage/kv/namespaces/%s/values/%s", w.account, w.namespace, url.PathEscape(k))
   210  		resp, _, _, err := w.request(ctx, http.MethodDelete, path, nil, make(http.Header))
   211  		if err != nil {
   212  			return err
   213  		}
   214  
   215  		a := &apiResponse{}
   216  		if err := json.Unmarshal(resp, a); err != nil {
   217  			return err
   218  		}
   219  
   220  		if !a.Success {
   221  			messages := ""
   222  			for _, m := range a.Errors {
   223  				messages += strconv.Itoa(m.Code) + " " + m.Message + "\n"
   224  			}
   225  			return errors.New(messages)
   226  		}
   227  	}
   228  
   229  	return nil
   230  }
   231  
   232  func (w *workersKV) request(ctx context.Context, method, path string, body interface{}, headers http.Header) ([]byte, http.Header, int, error) {
   233  	var jsonBody []byte
   234  	var err error
   235  
   236  	if body != nil {
   237  		if paramBytes, ok := body.([]byte); ok {
   238  			jsonBody = paramBytes
   239  		} else {
   240  			jsonBody, err = json.Marshal(body)
   241  			if err != nil {
   242  				return nil, nil, 0, errors.Wrap(err, "error marshalling params to JSON")
   243  			}
   244  		}
   245  	} else {
   246  		jsonBody = nil
   247  	}
   248  
   249  	var reqBody io.Reader
   250  
   251  	if jsonBody != nil {
   252  		reqBody = bytes.NewReader(jsonBody)
   253  	}
   254  
   255  	req, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, reqBody)
   256  	if err != nil {
   257  		return nil, nil, 0, errors.Wrap(err, "error creating new request")
   258  	}
   259  
   260  	for key, value := range headers {
   261  		req.Header[key] = value
   262  	}
   263  
   264  	// set token if it exists
   265  	if len(w.token) > 0 {
   266  		req.Header.Set("Authorization", "Bearer "+w.token)
   267  	}
   268  
   269  	// set the user agent to micro
   270  	req.Header.Set("User-Agent", "micro/1.0 (https://micro.mu)")
   271  
   272  	// Official cloudflare client does exponential backoff here
   273  	// TODO: retry and use util/backoff
   274  	resp, err := w.httpClient.Do(req)
   275  	if err != nil {
   276  		return nil, nil, 0, err
   277  	}
   278  	defer resp.Body.Close()
   279  
   280  	respBody, err := ioutil.ReadAll(resp.Body)
   281  	if err != nil {
   282  		return respBody, resp.Header, resp.StatusCode, err
   283  	}
   284  
   285  	return respBody, resp.Header, resp.StatusCode, nil
   286  }
   287  
   288  // New returns a cloudflare Store implementation.
   289  // Account ID, Token and Namespace must either be passed as options or
   290  // environment variables. If set as env vars we expect the following;
   291  // CF_API_TOKEN to a cloudflare API token scoped to Workers KV.
   292  // CF_ACCOUNT_ID to contain a string with your cloudflare account ID.
   293  // KV_NAMESPACE_ID to contain the namespace UUID for your KV storage.
   294  func NewStore(opts ...options.Option) store.Store {
   295  	// create new Options
   296  	options := options.NewOptions(opts...)
   297  
   298  	// get values from the environment
   299  	account, token, namespace := getOptions()
   300  
   301  	// set api token from options if exists
   302  	if apiToken, ok := options.Values().Get("CF_API_TOKEN"); ok {
   303  		tk, ok := apiToken.(string)
   304  		if !ok {
   305  			log.Fatal("Store: Option CF_API_TOKEN contains a non-string")
   306  		}
   307  		token = tk
   308  	}
   309  
   310  	// set account id from options if exists
   311  	if accountID, ok := options.Values().Get("CF_ACCOUNT_ID"); ok {
   312  		id, ok := accountID.(string)
   313  		if !ok {
   314  			log.Fatal("Store: Option CF_ACCOUNT_ID contains a non-string")
   315  		}
   316  		account = id
   317  	}
   318  
   319  	// set namespace from options if exists
   320  	if uuid, ok := options.Values().Get("KV_NAMESPACE_ID"); ok {
   321  		ns, ok := uuid.(string)
   322  		if !ok {
   323  			log.Fatal("Store: Option KV_NAMESPACE_ID contains a non-string")
   324  		}
   325  		namespace = ns
   326  	}
   327  
   328  	// validate options are not blank or log.Fatal
   329  	validateOptions(account, token, namespace)
   330  
   331  	return &workersKV{
   332  		account:    account,
   333  		namespace:  namespace,
   334  		token:      token,
   335  		Options:    options,
   336  		httpClient: &http.Client{},
   337  	}
   338  }