From 476cf0b2c57e3dbc16b50821ed73d10ef00ddbaa Mon Sep 17 00:00:00 2001 From: r266-tech Date: Tue, 23 Jun 2026 01:13:41 +0800 Subject: [PATCH] feat(sdk/go): add Relations API (Relations/Link/Unlink) to client The Go SDK exposed Find/Search/Grep/Glob but had no relations methods, even though the /api/v1/relations server router (and both Python clients) support querying and editing resource/memory-graph relations. Go users had to hand-roll raw HTTP for a core graph primitive. Add Relations(uri), Link(fromURI, toURIs, reason) and Unlink(fromURI, toURI), mirroring the Python client and the server contract: GET /api/v1/relations, POST /api/v1/relations/link, DELETE /api/v1/relations/link (with body), exact snake_case JSON keys (from_uri/to_uris/to_uri/reason), URI normalization, and the standard response envelope via the existing transport. Adds a client_test covering method/path/body for all three calls. --- sdk/go/client_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++ sdk/go/relations.go | 41 +++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 sdk/go/relations.go diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go index a80b74fa0..0c30dbb18 100644 --- a/sdk/go/client_test.go +++ b/sdk/go/client_test.go @@ -661,3 +661,60 @@ func TestHealth(t *testing.T) { t.Fatal("expected healthy") } } + +func TestRelationsLinkUnlink(t *testing.T) { + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/relations": + if got := r.URL.Query().Get("uri"); got != "viking://resources/a" { + t.Fatalf("relations uri query = %q", got) + } + writeOK(t, w, []map[string]any{ + {"uri": "viking://resources/b", "reason": "cites"}, + }) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/relations/link": + body := readJSONBody(t, r) + if got := body["from_uri"]; got != "viking://resources/a" { + t.Fatalf("link from_uri = %#v", got) + } + toURIs, ok := body["to_uris"].([]any) + if !ok || len(toURIs) != 2 || toURIs[0] != "viking://resources/b" || toURIs[1] != "viking://resources/c" { + t.Fatalf("link to_uris = %#v", body["to_uris"]) + } + if got := body["reason"]; got != "cites" { + t.Fatalf("link reason = %#v", got) + } + writeOK(t, w, map[string]any{"from": "viking://resources/a", "to": toURIs}) + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/relations/link": + body := readJSONBody(t, r) + if got := body["from_uri"]; got != "viking://resources/a" { + t.Fatalf("unlink from_uri = %#v", got) + } + if got := body["to_uri"]; got != "viking://resources/b" { + t.Fatalf("unlink to_uri = %#v", got) + } + writeOK(t, w, map[string]any{"from": "viking://resources/a", "to": "viking://resources/b"}) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer closeServer() + + rels, err := client.Relations(context.Background(), "viking://resources/a") + if err != nil { + t.Fatal(err) + } + if len(rels) != 1 || rels[0]["uri"] != "viking://resources/b" || rels[0]["reason"] != "cites" { + t.Fatalf("unexpected relations: %#v", rels) + } + + // Short URIs are normalized to viking:// form, and a single target is sent as a list. + if err := client.Link(context.Background(), "resources/a", + []string{"resources/b", "resources/c"}, "cites"); err != nil { + t.Fatal(err) + } + + if err := client.Unlink(context.Background(), "resources/a", "resources/b"); err != nil { + t.Fatal(err) + } +} diff --git a/sdk/go/relations.go b/sdk/go/relations.go new file mode 100644 index 000000000..1458eaacb --- /dev/null +++ b/sdk/go/relations.go @@ -0,0 +1,41 @@ +package openviking + +import ( + "context" + "net/http" + "net/url" +) + +// Relations returns the relations recorded for a resource URI. +// Each entry mirrors the server payload, e.g. {"uri": "...", "reason": "..."}. +func (c *Client) Relations(ctx context.Context, uri string) ([]map[string]any, error) { + var result []map[string]any + err := c.doJSON(ctx, http.MethodGet, "/api/v1/relations", + url.Values{"uri": {NormalizeURI(uri)}}, nil, &result) + return result, err +} + +// Link creates relations from one resource to one or more target resources. +// The server accepts a single target or a list (Union[str, List[str]]); the Go +// client always sends a list, so pass []string{target} to link a single URI. +func (c *Client) Link(ctx context.Context, fromURI string, toURIs []string, reason string) error { + normalized := make([]string, len(toURIs)) + for i, u := range toURIs { + normalized[i] = NormalizeURI(u) + } + payload := map[string]any{ + "from_uri": NormalizeURI(fromURI), + "to_uris": normalized, + "reason": reason, + } + return c.doJSON(ctx, http.MethodPost, "/api/v1/relations/link", nil, payload, nil) +} + +// Unlink removes the relation between two resources. +func (c *Client) Unlink(ctx context.Context, fromURI, toURI string) error { + payload := map[string]any{ + "from_uri": NormalizeURI(fromURI), + "to_uri": NormalizeURI(toURI), + } + return c.doJSON(ctx, http.MethodDelete, "/api/v1/relations/link", nil, payload, nil) +}