From ed4966927cbb2dd4129c205a6c2e9feb18c2e7d9 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 21:32:48 +0200 Subject: [PATCH] feat(hyperguild): brain HTTP REST client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds brainClient with Query and Write methods against the brain's HTTP REST endpoints (/query, /write). Constructor reads BRAIN_URL env var, defaulting to http://koala:30330 — the Tailscale-exposed NodePort that serves both MCP and REST. Tests cover success, transport error, and non-200 cases via httptest fakes; URL override is verified via t.Setenv. --- cmd/hyperguild/http.go | 117 ++++++++++++++++++++++++++++++++++++ cmd/hyperguild/http_test.go | 88 +++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 cmd/hyperguild/http.go create mode 100644 cmd/hyperguild/http_test.go diff --git a/cmd/hyperguild/http.go b/cmd/hyperguild/http.go new file mode 100644 index 0000000..36fefda --- /dev/null +++ b/cmd/hyperguild/http.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "time" +) + +const defaultBrainURL = "http://koala:30330" + +// brainClient calls the brain HTTP REST API exposed alongside the MCP +// endpoint at the same host:port. /mcp serves MCP framing; /query and /write +// serve plain REST. We use the REST surface because the CLI is a +// shell-friendly client; MCP framing is unnecessary. +type brainClient struct { + baseURL string + http *http.Client +} + +func newBrainClient() *brainClient { + u := os.Getenv("BRAIN_URL") + if u == "" { + u = defaultBrainURL + } + return &brainClient{ + baseURL: u, + http: &http.Client{Timeout: 5 * time.Second}, + } +} + +// QueryHit mirrors a single result from the brain's /query endpoint. +type QueryHit struct { + Path string `json:"path"` + Title string `json:"title"` + Excerpt string `json:"excerpt"` + Score int `json:"score"` +} + +// QueryResult mirrors the /query response envelope. +type QueryResult struct { + Results []QueryHit `json:"results"` +} + +func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*QueryResult, error) { + q := url.Values{} + q.Set("q", topic) + q.Set("limit", strconv.Itoa(limit)) + u := c.baseURL + "/query?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("brain GET /query: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("brain GET /query: status %d: %s", resp.StatusCode, string(body)) + } + var out QueryResult + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode /query response: %w", err) + } + return &out, nil +} + +// WriteResult mirrors the /write response envelope. +type WriteResult struct { + Path string `json:"path"` +} + +func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.Reader) (*WriteResult, error) { + body, err := io.ReadAll(content) + if err != nil { + return nil, fmt.Errorf("read content: %w", err) + } + payload, err := json.Marshal(struct { + Type string `json:"type"` + Slug string `json:"slug"` + Content string `json:"content"` + }{Type: kind, Slug: slug, Content: string(body)}) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + u := c.baseURL + "/write" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("brain POST /write: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("brain POST /write: status %d: %s", resp.StatusCode, string(respBody)) + } + var out WriteResult + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode /write response: %w", err) + } + return &out, nil +} diff --git a/cmd/hyperguild/http_test.go b/cmd/hyperguild/http_test.go new file mode 100644 index 0000000..c697dc6 --- /dev/null +++ b/cmd/hyperguild/http_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBrainClient_Query_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/query", r.URL.Path) + assert.Equal(t, "find-h", r.URL.Query().Get("q")) + assert.Equal(t, "3", r.URL.Query().Get("limit")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"path":"knowledge/x.md","title":"x","excerpt":"...","score":7}]}`)) + })) + defer srv.Close() + + c := &brainClient{baseURL: srv.URL, http: srv.Client()} + res, err := c.Query(context.Background(), "find-h", 3) + require.NoError(t, err) + require.Len(t, res.Results, 1) + assert.Equal(t, "knowledge/x.md", res.Results[0].Path) + assert.Equal(t, 7, res.Results[0].Score) +} + +func TestBrainClient_Query_TransportError(t *testing.T) { + c := &brainClient{baseURL: "http://127.0.0.1:1", http: http.DefaultClient} + _, err := c.Query(context.Background(), "x", 5) + assert.Error(t, err) +} + +func TestBrainClient_Query_Non200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer srv.Close() + + c := &brainClient{baseURL: srv.URL, http: srv.Client()} + _, err := c.Query(context.Background(), "x", 5) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestBrainClient_Write_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/write", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + body, _ := io.ReadAll(r.Body) + var got struct { + Type string `json:"type"` + Slug string `json:"slug"` + Content string `json:"content"` + } + require.NoError(t, json.Unmarshal(body, &got)) + assert.Equal(t, "knowledge", got.Type) + assert.Equal(t, "find-h", got.Slug) + assert.Equal(t, "# body\n", got.Content) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"path":"knowledge/find-h.md"}`)) + })) + defer srv.Close() + + c := &brainClient{baseURL: srv.URL, http: srv.Client()} + res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n")) + require.NoError(t, err) + assert.Equal(t, "knowledge/find-h.md", res.Path) +} + +func TestNewBrainClient_DefaultURL(t *testing.T) { + t.Setenv("BRAIN_URL", "") + c := newBrainClient() + assert.Equal(t, "http://koala:30330", c.baseURL) +} + +func TestNewBrainClient_OverrideURL(t *testing.T) { + t.Setenv("BRAIN_URL", "http://localhost:9999") + c := newBrainClient() + assert.Equal(t, "http://localhost:9999", c.baseURL) +}