mirror of
https://github.com/hanzoai/bucket.git
synced 2026-10-30 18:46:23 +00:00
A key containing / (URLs are a common real key) round-trips through percent-escaping locally but many HTTP routers re-decode the path before matching route segments, so an encoded slash can be mistaken for a path separator server-side. base64url never produces /, removing the ambiguity. Co-authored-by: Hanzo Dev <[email protected]>
313 lines
11 KiB
Go
313 lines
11 KiB
Go
// Package bucket is the Go client for cloud's versioned key-value store —
|
|
// the plain-cache replacement for a product's own Redis. The server lives at
|
|
// github.com/hanzoai/cloud/apps/kv, mounted at /v1/kv on api.hanzo.ai; this
|
|
// package is the thin HTTP client every other product imports instead of
|
|
// writing its own.
|
|
//
|
|
// # NOT TO BE CONFUSED WITH "Hanzo KV"
|
|
//
|
|
// github.com/hanzoai/kv is a separate product: a from-scratch Redis-protocol
|
|
// server (RESP2/RESP3, Lua scripting, pub/sub, streams), reached with its own
|
|
// client (github.com/hanzokv/go) over the wire protocol on port 6379. That is
|
|
// still Redis, wire-compatible, wherever it runs. This package speaks to
|
|
// neither of those — it is a plain HTTP/JSON client for cloud's /v1/kv REST
|
|
// surface, which is a narrower, versioned bucket store, not a Redis
|
|
// replacement server.
|
|
//
|
|
// A bucket holds keyed values. Each key keeps up to History revisions, and
|
|
// every value in a bucket shares the bucket's own TTL, set once at creation
|
|
// — there is no per-key TTL. A miss reads back as ErrNotFound.
|
|
//
|
|
// # WHAT IT IS NOT
|
|
//
|
|
// It is not a lock. Put takes a key and a value, nothing else — no expected
|
|
// revision, no compare-and-swap — so two concurrent Puts to the same key
|
|
// race exactly like two concurrent writes to a plain variable: last write
|
|
// wins. A caller that needs mutual exclusion needs a different primitive,
|
|
// not this one.
|
|
//
|
|
// It is not pub/sub. A write here notifies no one; nothing about a bucket
|
|
// publishes, subscribes, or streams.
|
|
//
|
|
// It has no listing or scan. A key is read by name; there is no operation
|
|
// that enumerates the keys a bucket holds. A caller that needs to iterate a
|
|
// keyspace needs to track that keyspace itself (e.g. an index value) or a
|
|
// different store.
|
|
//
|
|
// The HTTP contract is cloud's canonical KV API, mounted under /v1
|
|
// (cloud/apps/kv/kv.go):
|
|
//
|
|
// POST {BaseURL}/v1/kv/{bucket} create a bucket
|
|
// DELETE {BaseURL}/v1/kv/{bucket} drop a bucket and everything in it
|
|
// GET {BaseURL}/v1/kv/{bucket}/{key} read the current value
|
|
// PUT {BaseURL}/v1/kv/{bucket}/{key} write a value, get back its revision
|
|
// DELETE {BaseURL}/v1/kv/{bucket}/{key} delete one key
|
|
// GET {BaseURL}/v1/kv/{bucket}/{key}/history a key's retained revisions
|
|
//
|
|
// Auth is the caller's own Hanzo IAM bearer token, sent as
|
|
//
|
|
// Authorization: Bearer {Token}
|
|
//
|
|
// The org a bucket belongs to comes from that token's own validated claim,
|
|
// never from a field or header this package sends.
|
|
//
|
|
// This is a leaf package: it imports only the standard library, so any
|
|
// product — Go service, CLI, or job — can import github.com/hanzoai/bucket
|
|
// without pulling cloud's server code into its binary.
|
|
package bucket
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ErrNotFound is returned by Get, Delete and History when the bucket does
|
|
// not exist, the key was never written, or (Get only) its latest revision is
|
|
// a delete marker. Test with errors.Is.
|
|
var ErrNotFound = errors.New("bucket: not found")
|
|
|
|
// ErrConflict is returned by Create when the org already has a bucket of
|
|
// that name. Test with errors.Is.
|
|
var ErrConflict = errors.New("bucket: already exists")
|
|
|
|
// HTTPDoer is the minimal HTTP surface the client needs. *http.Client
|
|
// satisfies it; tests substitute their own.
|
|
type HTTPDoer interface {
|
|
Do(req *http.Request) (*http.Response, error)
|
|
}
|
|
|
|
// Config configures a Client. New only rejects an unparseable BaseURL, so a
|
|
// caller can build a Client before Token is available and set it later.
|
|
type Config struct {
|
|
// BaseURL is the Hanzo API base, e.g. "https://api.hanzo.ai". No
|
|
// trailing /v1 — the client appends the canonical KV paths itself.
|
|
BaseURL string
|
|
|
|
// Token is the caller's Hanzo IAM bearer token. MUST come from KMS or an
|
|
// operator-injected secret; never hard-code it. Sent as
|
|
// "Authorization: Bearer".
|
|
Token string
|
|
|
|
// Timeout bounds each HTTP call. Default 5s.
|
|
Timeout time.Duration
|
|
|
|
// HTTPClient overrides the underlying HTTP client. When nil a client
|
|
// with Timeout is created.
|
|
HTTPClient HTTPDoer
|
|
}
|
|
|
|
// Client is the bucket-store HTTP client. It is safe for concurrent use.
|
|
type Client struct {
|
|
baseURL string
|
|
token string
|
|
http HTTPDoer
|
|
}
|
|
|
|
// New builds a Client from cfg. It returns an error only for an unparseable
|
|
// BaseURL.
|
|
func New(cfg Config) (*Client, error) {
|
|
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
|
if base != "" {
|
|
if _, err := url.Parse(base); err != nil {
|
|
return nil, fmt.Errorf("bucket: invalid BaseURL %q: %w", cfg.BaseURL, err)
|
|
}
|
|
}
|
|
|
|
doer := cfg.HTTPClient
|
|
if doer == nil {
|
|
timeout := cfg.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
doer = &http.Client{Timeout: timeout}
|
|
}
|
|
|
|
return &Client{baseURL: base, token: strings.TrimSpace(cfg.Token), http: doer}, nil
|
|
}
|
|
|
|
// Bucket describes one bucket as the server reports it.
|
|
type Bucket struct {
|
|
Bucket string `json:"bucket"`
|
|
History int `json:"history"`
|
|
TTL int64 `json:"ttl"` // seconds; 0 means no expiry
|
|
Values uint64 `json:"values"`
|
|
}
|
|
|
|
// Entry is one key's value at one revision.
|
|
type Entry struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Revision uint64 `json:"revision"`
|
|
Created string `json:"created"` // RFC3339
|
|
Operation string `json:"operation"` // put, del or purge
|
|
}
|
|
|
|
// Create creates a bucket in the caller's org. history is how many
|
|
// revisions each key keeps (1-64; 0 means 1). ttl expires every entry in the
|
|
// bucket after that duration, rounded down to the second (0 means never) —
|
|
// TTL is set once per bucket, not per key. Returns ErrConflict if the org
|
|
// already has a bucket of that name.
|
|
func (c *Client) Create(ctx context.Context, name string, history int, ttl time.Duration) (*Bucket, error) {
|
|
body := struct {
|
|
History int `json:"history"`
|
|
TTL int64 `json:"ttl"`
|
|
}{History: history, TTL: int64(ttl / time.Second)}
|
|
|
|
var out Bucket
|
|
if err := c.do(ctx, http.MethodPost, "/v1/kv/"+url.PathEscape(name), body, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// Drop removes a bucket and every key and revision in it. ErrNotFound if the
|
|
// org has no bucket of that name.
|
|
func (c *Client) Drop(ctx context.Context, name string) error {
|
|
return c.do(ctx, http.MethodDelete, "/v1/kv/"+url.PathEscape(name), nil, nil)
|
|
}
|
|
|
|
// Get returns key's current value and revision in bucket. ErrNotFound if the
|
|
// bucket does not exist, the key was never written, or its latest revision
|
|
// is a delete.
|
|
func (c *Client) Get(ctx context.Context, bucket, key string) (*Entry, error) {
|
|
var out Entry
|
|
if err := c.do(ctx, http.MethodGet, path(bucket, key), nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
out.Key = key
|
|
return &out, nil
|
|
}
|
|
|
|
// GetJSON reads key in bucket and unmarshals its value into dst. A miss
|
|
// leaves dst untouched and reports ErrNotFound, matching Get.
|
|
func (c *Client) GetJSON(ctx context.Context, bucket, key string, dst any) error {
|
|
e, err := c.Get(ctx, bucket, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal([]byte(e.Value), dst); err != nil {
|
|
return fmt.Errorf("bucket: unmarshal value: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Put sets key to value in bucket and returns the revision the write
|
|
// created. Each Put is a new revision; it does not check or depend on any
|
|
// prior value — see the package doc for why this is not a lock.
|
|
func (c *Client) Put(ctx context.Context, bucket, key, value string) (uint64, error) {
|
|
body := struct {
|
|
Value string `json:"value"`
|
|
}{Value: value}
|
|
|
|
var out struct {
|
|
Revision uint64 `json:"revision"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPut, path(bucket, key), body, &out); err != nil {
|
|
return 0, err
|
|
}
|
|
return out.Revision, nil
|
|
}
|
|
|
|
// PutJSON marshals value and Puts it.
|
|
func (c *Client) PutJSON(ctx context.Context, bucket, key string, value any) (uint64, error) {
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("bucket: marshal value: %w", err)
|
|
}
|
|
return c.Put(ctx, bucket, key, string(data))
|
|
}
|
|
|
|
// Delete removes key from bucket. ErrNotFound if the bucket does not exist.
|
|
// Deleting an already-absent key in an existing bucket is not an error.
|
|
func (c *Client) Delete(ctx context.Context, bucket, key string) error {
|
|
return c.do(ctx, http.MethodDelete, path(bucket, key), nil, nil)
|
|
}
|
|
|
|
// History returns key's retained revisions in bucket, oldest first.
|
|
// ErrNotFound if the bucket does not exist or the key was never written.
|
|
func (c *Client) History(ctx context.Context, bucket, key string) ([]Entry, error) {
|
|
var out struct {
|
|
Data []Entry `json:"data"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, path(bucket, key)+"/history", nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out.Data {
|
|
out.Data[i].Key = key
|
|
}
|
|
return out.Data, nil
|
|
}
|
|
|
|
// path builds the request path for one key. The key is base64url-encoded
|
|
// rather than merely percent-escaped: a key containing "/" (URLs are common
|
|
// keys) survives as literal bytes when percent-escaped, but many HTTP routers
|
|
// decode the path before matching route segments, so an encoded slash can be
|
|
// mistaken for a path separator by the time it is matched. base64url never
|
|
// produces "/", so this ambiguity does not exist. The bucket name has no such
|
|
// problem — the server itself constrains it to a short identifier — so it is
|
|
// only percent-escaped, keeping bucket names human-readable on the wire.
|
|
func path(bucket, key string) string {
|
|
return "/v1/kv/" + url.PathEscape(bucket) + "/" + base64.RawURLEncoding.EncodeToString([]byte(key))
|
|
}
|
|
|
|
// do sends one request and decodes a JSON response into out (skipped when
|
|
// out is nil). A 404 becomes ErrNotFound and a 409 becomes ErrConflict so
|
|
// callers can branch with errors.Is instead of inspecting status codes.
|
|
func (c *Client) do(ctx context.Context, method, reqPath string, body, out any) error {
|
|
var reqBody io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("bucket: encode request: %w", err)
|
|
}
|
|
reqBody = bytes.NewReader(data)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+reqPath, reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("bucket: build request: %w", err)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("bucket: %s %s: %w", method, reqPath, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusNotFound:
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return ErrNotFound
|
|
case http.StatusConflict:
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return ErrConflict
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("bucket: %s %s: status %d: %s", method, reqPath, resp.StatusCode, strings.TrimSpace(string(data)))
|
|
}
|
|
if out == nil {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return nil
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return fmt.Errorf("bucket: decode response: %w", err)
|
|
}
|
|
return nil
|
|
}
|