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 }