From b422b6deb65eef0655b6b19aec8766fd01bcb3cd Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Fri, 5 Jun 2026 19:27:10 -0400 Subject: [PATCH] feat(aw): AW REST client with idempotent EnsureBucket --- internal/aw/aw.go | 70 ++++++++++++++++++++++++++++++++++++++++++ internal/aw/aw_test.go | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 internal/aw/aw.go create mode 100644 internal/aw/aw_test.go diff --git a/internal/aw/aw.go b/internal/aw/aw.go new file mode 100644 index 0000000..e322e16 --- /dev/null +++ b/internal/aw/aw.go @@ -0,0 +1,70 @@ +// Package aw is a thin ActivityWatch REST client: it creates buckets and reads +// and writes events. It holds no Keel concepts, so it stays a leaf package. +package aw + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" +) + +// Event is one ActivityWatch event. Duration is seconds; for Keel's discrete +// derived events it is 0. +type Event struct { + Timestamp time.Time + Duration float64 + Data map[string]any +} + +// Client talks to an aw-server over its /api/0 REST surface. +type Client struct { + baseURL string + hc *http.Client +} + +// New builds a client for the given base URL (e.g. http://localhost:5600). +func New(baseURL string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + hc: &http.Client{Timeout: 5 * time.Second}, + } +} + +// EnsureBucket creates the bucket if absent. It is idempotent: an already-exists +// response (304) is success. +func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error { + body, _ := json.Marshal(map[string]string{ + "client": client, + "type": eventType, + "hostname": hostname(), + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.baseURL+"/api/0/buckets/"+id, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusNotModified: + return nil + default: + return fmt.Errorf("aw: create bucket %s: status %d", id, resp.StatusCode) + } +} + +func hostname() string { + if h, err := os.Hostname(); err == nil && h != "" { + return h + } + return "keel" +} diff --git a/internal/aw/aw_test.go b/internal/aw/aw_test.go new file mode 100644 index 0000000..a95c7e7 --- /dev/null +++ b/internal/aw/aw_test.go @@ -0,0 +1,56 @@ +package aw + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestEnsureBucketPostsCreate(t *testing.T) { + var gotPath string + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &gotBody) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := New(srv.URL) + if err := c.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil { + t.Fatalf("EnsureBucket: %v", err) + } + if gotPath != "/api/0/buckets/keel.events" { + t.Fatalf("path = %q", gotPath) + } + if gotBody["type"] != "keel.event" || gotBody["client"] != "keel" { + t.Fatalf("body = %+v", gotBody) + } +} + +func TestEnsureBucketTreatsExistsAsOK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotModified) // AW returns 304 when the bucket exists + })) + defer srv.Close() + if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil { + t.Fatalf("existing bucket should be OK, got %v", err) + } +} + +func TestEnsureBucketErrorsOnServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err == nil { + t.Fatal("expected error on 500") + } +}