Files
miti99bot/internal/migration/cloudflare_kv_client.go
T
tiennm99 0e6f2d0a1f fix(migration): satisfy errcheck on Close + Fprint* in migration toolchain
Pre-existing 9 errcheck violations introduced by 1a8da2d blocked
golangci-lint on every CI run after the migration toolchain landed.

- Wrap defer Close() in anonymous func to match house style
  (cf. internal/modules/lolschedule/api_client.go:162).
- Mark Fprint*/Fprintln to io.Writer with _, _ = (best-effort writes
  to os.Stdout / bytes.Buffer; errors not actionable for callers).

No behavior change. go vet, golangci-lint, go test ./... all clean
locally.
2026-05-16 11:02:15 +07:00

136 lines
4.1 KiB
Go

package migration
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// CloudflareKVClient is a thin read-only REST client for the legacy
// Cloudflare Workers KV namespace. List+Get are the only operations the
// migration needs; mutations stay out of scope.
type CloudflareKVClient struct {
httpClient *http.Client
apiBase string
accountID string
namespaceID string
apiToken string
}
// NewCloudflareKVClient panics on empty required fields so misconfiguration
// surfaces at startup rather than after partial work.
func NewCloudflareKVClient(accountID, namespaceID, apiToken string) *CloudflareKVClient {
if accountID == "" || namespaceID == "" || apiToken == "" {
panic("migration: CloudflareKVClient requires accountID, namespaceID, apiToken")
}
return &CloudflareKVClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
apiBase: "https://api.cloudflare.com/client/v4",
accountID: accountID,
namespaceID: namespaceID,
apiToken: apiToken,
}
}
// SetBaseURL overrides the API base; used by tests with httptest.Server.
func (c *CloudflareKVClient) SetBaseURL(base string) { c.apiBase = base }
// kvListEnvelope matches the Cloudflare REST list-keys response shape.
// See: https://developers.cloudflare.com/api/operations/workers-kv-namespace-list-a-namespace-s-keys
type kvListEnvelope struct {
Result []struct {
Name string `json:"name"`
} `json:"result"`
Success bool `json:"success"`
Errors []map[string]any `json:"errors"`
ResultInfo struct {
Cursor string `json:"cursor"`
Count int `json:"count"`
} `json:"result_info"`
}
// ListKeys returns every key in the namespace. The Phase 01 inventory
// proved the namespace fits in well under one REST page (21 keys vs 1000
// page limit), but pagination is still honored for safety.
func (c *CloudflareKVClient) ListKeys(ctx context.Context) ([]string, error) {
var out []string
cursor := ""
for {
page, next, err := c.listPage(ctx, cursor)
if err != nil {
return nil, err
}
out = append(out, page...)
if next == "" {
return out, nil
}
cursor = next
}
}
func (c *CloudflareKVClient) listPage(ctx context.Context, cursor string) ([]string, string, error) {
endpoint := fmt.Sprintf("%s/accounts/%s/storage/kv/namespaces/%s/keys",
c.apiBase, c.accountID, c.namespaceID)
if cursor != "" {
endpoint += "?" + url.Values{"cursor": []string{cursor}}.Encode()
}
body, err := c.do(ctx, http.MethodGet, endpoint)
if err != nil {
return nil, "", err
}
var env kvListEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, "", fmt.Errorf("kv list: decode: %w", err)
}
if !env.Success {
return nil, "", fmt.Errorf("kv list: api error: %v", env.Errors)
}
names := make([]string, 0, len(env.Result))
for _, r := range env.Result {
names = append(names, r.Name)
}
return names, env.ResultInfo.Cursor, nil
}
// GetValue returns the raw value bytes for one KV key. CF returns 404 as
// errKeyNotFound so callers can decide whether a missing key is fatal.
func (c *CloudflareKVClient) GetValue(ctx context.Context, key string) ([]byte, error) {
endpoint := fmt.Sprintf("%s/accounts/%s/storage/kv/namespaces/%s/values/%s",
c.apiBase, c.accountID, c.namespaceID, url.PathEscape(key))
return c.do(ctx, http.MethodGet, endpoint)
}
// ErrKeyNotFound signals a 404 on a value GET. Returned wrapped so callers
// can use errors.Is.
var ErrKeyNotFound = errors.New("cloudflare kv: key not found")
func (c *CloudflareKVClient) do(ctx context.Context, method, endpoint string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("%w: %s", ErrKeyNotFound, endpoint)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("cloudflare api %s %s: status %d: %s",
method, endpoint, resp.StatusCode, string(body))
}
return body, nil
}