339 lines
		
	
	
		
			8.5 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			339 lines
		
	
	
		
			8.5 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Package cloudflare is a store implementation backed by cloudflare workers kv
 | |
| // Note that the cloudflare workers KV API is eventually consistent.
 | |
| package cloudflare
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"context"
 | |
| 	"encoding/json"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"io/ioutil"
 | |
| 	"log"
 | |
| 	"math"
 | |
| 	"net/http"
 | |
| 	"net/url"
 | |
| 	"os"
 | |
| 	"strconv"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/micro/go-micro/config/options"
 | |
| 	"github.com/micro/go-micro/store"
 | |
| 	"github.com/pkg/errors"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	apiBaseURL = "https://api.cloudflare.com/client/v4/"
 | |
| )
 | |
| 
 | |
| type workersKV struct {
 | |
| 	options.Options
 | |
| 	// cf account id
 | |
| 	account string
 | |
| 	// cf api token
 | |
| 	token string
 | |
| 	// cf kv namespace
 | |
| 	namespace string
 | |
| 	// http client to use
 | |
| 	httpClient *http.Client
 | |
| }
 | |
| 
 | |
| // apiResponse is a cloudflare v4 api response
 | |
| type apiResponse struct {
 | |
| 	Result []struct {
 | |
| 		ID         string    `json:"id"`
 | |
| 		Type       string    `json:"type"`
 | |
| 		Name       string    `json:"name"`
 | |
| 		Expiration string    `json:"expiration"`
 | |
| 		Content    string    `json:"content"`
 | |
| 		Proxiable  bool      `json:"proxiable"`
 | |
| 		Proxied    bool      `json:"proxied"`
 | |
| 		TTL        int       `json:"ttl"`
 | |
| 		Priority   int       `json:"priority"`
 | |
| 		Locked     bool      `json:"locked"`
 | |
| 		ZoneID     string    `json:"zone_id"`
 | |
| 		ZoneName   string    `json:"zone_name"`
 | |
| 		ModifiedOn time.Time `json:"modified_on"`
 | |
| 		CreatedOn  time.Time `json:"created_on"`
 | |
| 	} `json:"result"`
 | |
| 	Success bool         `json:"success"`
 | |
| 	Errors  []apiMessage `json:"errors"`
 | |
| 	// not sure Messages is ever populated?
 | |
| 	Messages   []apiMessage `json:"messages"`
 | |
| 	ResultInfo struct {
 | |
| 		Page       int `json:"page"`
 | |
| 		PerPage    int `json:"per_page"`
 | |
| 		Count      int `json:"count"`
 | |
| 		TotalCount int `json:"total_count"`
 | |
| 	} `json:"result_info"`
 | |
| }
 | |
| 
 | |
| // apiMessage is a Cloudflare v4 API Error
 | |
| type apiMessage struct {
 | |
| 	Code    int    `json:"code"`
 | |
| 	Message string `json:"message"`
 | |
| }
 | |
| 
 | |
| // getOptions returns account id, token and namespace
 | |
| func getOptions() (string, string, string) {
 | |
| 	accountID := os.Getenv("CF_ACCOUNT_ID")
 | |
| 	apiToken := os.Getenv("CF_API_TOKEN")
 | |
| 	namespace := os.Getenv("KV_NAMESPACE_ID")
 | |
| 
 | |
| 	return accountID, apiToken, namespace
 | |
| }
 | |
| 
 | |
| func validateOptions(account, token, namespace string) {
 | |
| 	if len(account) == 0 {
 | |
| 		log.Fatal("Store: CF_ACCOUNT_ID is blank")
 | |
| 	}
 | |
| 
 | |
| 	if len(token) == 0 {
 | |
| 		log.Fatal("Store: CF_API_TOKEN is blank")
 | |
| 	}
 | |
| 
 | |
| 	if len(namespace) == 0 {
 | |
| 		log.Fatal("Store: KV_NAMESPACE_ID is blank")
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // In the cloudflare workers KV implemention, List() doesn't guarantee
 | |
| // anything as the workers API is eventually consistent.
 | |
| func (w *workersKV) List() ([]*store.Record, error) {
 | |
| 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 | |
| 	defer cancel()
 | |
| 
 | |
| 	path := fmt.Sprintf("accounts/%s/storage/kv/namespaces/%s/keys", w.account, w.namespace)
 | |
| 
 | |
| 	response, _, _, err := w.request(ctx, http.MethodGet, path, nil, make(http.Header))
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	a := &apiResponse{}
 | |
| 	if err := json.Unmarshal(response, a); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	if !a.Success {
 | |
| 		messages := ""
 | |
| 		for _, m := range a.Errors {
 | |
| 			messages += strconv.Itoa(m.Code) + " " + m.Message + "\n"
 | |
| 		}
 | |
| 		return nil, errors.New(messages)
 | |
| 	}
 | |
| 
 | |
| 	keys := make([]string, 0, len(a.Result))
 | |
| 
 | |
| 	for _, r := range a.Result {
 | |
| 		keys = append(keys, r.Name)
 | |
| 	}
 | |
| 
 | |
| 	return w.Read(keys...)
 | |
| }
 | |
| 
 | |
| func (w *workersKV) Read(keys ...string) ([]*store.Record, error) {
 | |
| 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 | |
| 	defer cancel()
 | |
| 
 | |
| 	//nolint:prealloc
 | |
| 	var records []*store.Record
 | |
| 
 | |
| 	for _, k := range keys {
 | |
| 		path := fmt.Sprintf("accounts/%s/storage/kv/namespaces/%s/values/%s", w.account, w.namespace, url.PathEscape(k))
 | |
| 		response, headers, status, err := w.request(ctx, http.MethodGet, path, nil, make(http.Header))
 | |
| 		if err != nil {
 | |
| 			return records, err
 | |
| 		}
 | |
| 		if status < 200 || status >= 300 {
 | |
| 			return records, errors.New("Received unexpected Status " + strconv.Itoa(status) + string(response))
 | |
| 		}
 | |
| 		record := &store.Record{
 | |
| 			Key:   k,
 | |
| 			Value: response,
 | |
| 		}
 | |
| 		if expiry := headers.Get("Expiration"); len(expiry) != 0 {
 | |
| 			expiryUnix, err := strconv.ParseInt(expiry, 10, 64)
 | |
| 			if err != nil {
 | |
| 				return records, err
 | |
| 			}
 | |
| 			record.Expiry = time.Until(time.Unix(expiryUnix, 0))
 | |
| 		}
 | |
| 		records = append(records, record)
 | |
| 	}
 | |
| 
 | |
| 	return records, nil
 | |
| }
 | |
| 
 | |
| func (w *workersKV) Write(records ...*store.Record) error {
 | |
| 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 | |
| 	defer cancel()
 | |
| 
 | |
| 	for _, r := range records {
 | |
| 		path := fmt.Sprintf("accounts/%s/storage/kv/namespaces/%s/values/%s", w.account, w.namespace, url.PathEscape(r.Key))
 | |
| 		if r.Expiry != 0 {
 | |
| 			// Minimum cloudflare TTL is 60 Seconds
 | |
| 			exp := int(math.Max(60, math.Round(r.Expiry.Seconds())))
 | |
| 			path = path + "?expiration_ttl=" + strconv.Itoa(exp)
 | |
| 		}
 | |
| 
 | |
| 		headers := make(http.Header)
 | |
| 
 | |
| 		resp, _, _, err := w.request(ctx, http.MethodPut, path, r.Value, headers)
 | |
| 		if err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		a := &apiResponse{}
 | |
| 		if err := json.Unmarshal(resp, a); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		if !a.Success {
 | |
| 			messages := ""
 | |
| 			for _, m := range a.Errors {
 | |
| 				messages += strconv.Itoa(m.Code) + " " + m.Message + "\n"
 | |
| 			}
 | |
| 			return errors.New(messages)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (w *workersKV) Delete(keys ...string) error {
 | |
| 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 | |
| 	defer cancel()
 | |
| 
 | |
| 	for _, k := range keys {
 | |
| 		path := fmt.Sprintf("accounts/%s/storage/kv/namespaces/%s/values/%s", w.account, w.namespace, url.PathEscape(k))
 | |
| 		resp, _, _, err := w.request(ctx, http.MethodDelete, path, nil, make(http.Header))
 | |
| 		if err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		a := &apiResponse{}
 | |
| 		if err := json.Unmarshal(resp, a); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		if !a.Success {
 | |
| 			messages := ""
 | |
| 			for _, m := range a.Errors {
 | |
| 				messages += strconv.Itoa(m.Code) + " " + m.Message + "\n"
 | |
| 			}
 | |
| 			return errors.New(messages)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (w *workersKV) request(ctx context.Context, method, path string, body interface{}, headers http.Header) ([]byte, http.Header, int, error) {
 | |
| 	var jsonBody []byte
 | |
| 	var err error
 | |
| 
 | |
| 	if body != nil {
 | |
| 		if paramBytes, ok := body.([]byte); ok {
 | |
| 			jsonBody = paramBytes
 | |
| 		} else {
 | |
| 			jsonBody, err = json.Marshal(body)
 | |
| 			if err != nil {
 | |
| 				return nil, nil, 0, errors.Wrap(err, "error marshalling params to JSON")
 | |
| 			}
 | |
| 		}
 | |
| 	} else {
 | |
| 		jsonBody = nil
 | |
| 	}
 | |
| 
 | |
| 	var reqBody io.Reader
 | |
| 
 | |
| 	if jsonBody != nil {
 | |
| 		reqBody = bytes.NewReader(jsonBody)
 | |
| 	}
 | |
| 
 | |
| 	req, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, reqBody)
 | |
| 	if err != nil {
 | |
| 		return nil, nil, 0, errors.Wrap(err, "error creating new request")
 | |
| 	}
 | |
| 
 | |
| 	for key, value := range headers {
 | |
| 		req.Header[key] = value
 | |
| 	}
 | |
| 
 | |
| 	// set token if it exists
 | |
| 	if len(w.token) > 0 {
 | |
| 		req.Header.Set("Authorization", "Bearer "+w.token)
 | |
| 	}
 | |
| 
 | |
| 	// set the user agent to micro
 | |
| 	req.Header.Set("User-Agent", "micro/1.0 (https://micro.mu)")
 | |
| 
 | |
| 	// Official cloudflare client does exponential backoff here
 | |
| 	// TODO: retry and use util/backoff
 | |
| 	resp, err := w.httpClient.Do(req)
 | |
| 	if err != nil {
 | |
| 		return nil, nil, 0, err
 | |
| 	}
 | |
| 	defer resp.Body.Close()
 | |
| 
 | |
| 	respBody, err := ioutil.ReadAll(resp.Body)
 | |
| 	if err != nil {
 | |
| 		return respBody, resp.Header, resp.StatusCode, err
 | |
| 	}
 | |
| 
 | |
| 	return respBody, resp.Header, resp.StatusCode, nil
 | |
| }
 | |
| 
 | |
| // New returns a cloudflare Store implementation.
 | |
| // Account ID, Token and Namespace must either be passed as options or
 | |
| // environment variables. If set as env vars we expect the following;
 | |
| // CF_API_TOKEN to a cloudflare API token scoped to Workers KV.
 | |
| // CF_ACCOUNT_ID to contain a string with your cloudflare account ID.
 | |
| // KV_NAMESPACE_ID to contain the namespace UUID for your KV storage.
 | |
| func NewStore(opts ...options.Option) store.Store {
 | |
| 	// create new Options
 | |
| 	options := options.NewOptions(opts...)
 | |
| 
 | |
| 	// get values from the environment
 | |
| 	account, token, namespace := getOptions()
 | |
| 
 | |
| 	// set api token from options if exists
 | |
| 	if apiToken, ok := options.Values().Get("CF_API_TOKEN"); ok {
 | |
| 		tk, ok := apiToken.(string)
 | |
| 		if !ok {
 | |
| 			log.Fatal("Store: Option CF_API_TOKEN contains a non-string")
 | |
| 		}
 | |
| 		token = tk
 | |
| 	}
 | |
| 
 | |
| 	// set account id from options if exists
 | |
| 	if accountID, ok := options.Values().Get("CF_ACCOUNT_ID"); ok {
 | |
| 		id, ok := accountID.(string)
 | |
| 		if !ok {
 | |
| 			log.Fatal("Store: Option CF_ACCOUNT_ID contains a non-string")
 | |
| 		}
 | |
| 		account = id
 | |
| 	}
 | |
| 
 | |
| 	// set namespace from options if exists
 | |
| 	if uuid, ok := options.Values().Get("KV_NAMESPACE_ID"); ok {
 | |
| 		ns, ok := uuid.(string)
 | |
| 		if !ok {
 | |
| 			log.Fatal("Store: Option KV_NAMESPACE_ID contains a non-string")
 | |
| 		}
 | |
| 		namespace = ns
 | |
| 	}
 | |
| 
 | |
| 	// validate options are not blank or log.Fatal
 | |
| 	validateOptions(account, token, namespace)
 | |
| 
 | |
| 	return &workersKV{
 | |
| 		account:    account,
 | |
| 		namespace:  namespace,
 | |
| 		token:      token,
 | |
| 		Options:    options,
 | |
| 		httpClient: &http.Client{},
 | |
| 	}
 | |
| }
 |