From 9cecda19a470dd59cd28278672c0a2123b839be1 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Thu, 14 May 2026 16:59:14 +0200 Subject: [PATCH] runtime-api: /.runtime/objects/* REST endpoints --- server/runtime_objects.go | 311 +++++++++++++++++++++++++++++++++ server/runtime_objects_test.go | 108 ++++++++++++ server/server.go | 4 + 3 files changed, 423 insertions(+) create mode 100644 server/runtime_objects.go create mode 100644 server/runtime_objects_test.go diff --git a/server/runtime_objects.go b/server/runtime_objects.go new file mode 100644 index 00000000..c40c7a66 --- /dev/null +++ b/server/runtime_objects.go @@ -0,0 +1,311 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" +) + +// Error codes returned by the /.runtime/objects/* endpoints. Kept in one +// place so backend, handler, and HTTP-status mapping stay in sync. +const ( + codeNotFound = "not_found" + codeUnknownTag = "unknown_tag" + codeBadField = "bad_field" + codeBadQuery = "bad_query" + codeUnknownOperator = "unknown_operator" + codeBadLimit = "bad_limit" + codeBridgeUnavailable = "bridge_unavailable" + codeTimeout = "timeout" + codeInternal = "internal_error" +) + +// parseErr is a typed parser error so callers don't have to parse error +// strings back out. +type parseErr struct { + Code string + Msg string +} + +func (e parseErr) Error() string { return e.Code + ": " + e.Msg } + +// Filter is one REST query filter clause. +type Filter struct { + Field string `json:"field"` + Op string `json:"op"` + Value string `json:"value"` +} + +// OrderKey is one sort key. +type OrderKey struct { + Field string `json:"field"` + Desc bool `json:"desc"` +} + +// ObjectsListQuery is the parsed query for GET /.runtime/objects/{tag}. +type ObjectsListQuery struct { + Filters []Filter `json:"filters"` + Order []OrderKey `json:"order"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Select []string `json:"select,omitempty"` + Debug bool `json:"debug,omitempty"` +} + +var ( + fieldPathRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`) + whereParamRe = regexp.MustCompile(`^where\[([^\]]+)\](?:\[([^\]]+)\])?$`) + allowedOps = map[string]struct{}{ + "eq": {}, "ne": {}, "gt": {}, "gte": {}, "lt": {}, "lte": {}, + "in": {}, "contains": {}, "startsWith": {}, + } +) + +const ( + defaultLimit = 100 + maxLimit = 1000 +) + +// parseObjectsQuery extracts an ObjectsListQuery from URL values. +func parseObjectsQuery(v url.Values) (ObjectsListQuery, error) { + // Always initialize Filters/Order to non-nil empty slices so that the + // JSON envelope sent across the bridge has `[]` rather than `null` — + // the TS side does `.map(...)` on these fields unconditionally. + q := ObjectsListQuery{ + Limit: defaultLimit, + Filters: []Filter{}, + Order: []OrderKey{}, + } + + for key, vals := range v { + m := whereParamRe.FindStringSubmatch(key) + if m == nil { + continue + } + field, op := m[1], m[2] + if op == "" { + op = "eq" + } + if !fieldPathRe.MatchString(field) { + return q, parseErr{codeBadField, fmt.Sprintf("%q is not a valid field path", field)} + } + if _, ok := allowedOps[op]; !ok { + return q, parseErr{codeUnknownOperator, fmt.Sprintf("%q", op)} + } + for _, val := range vals { + q.Filters = append(q.Filters, Filter{Field: field, Op: op, Value: val}) + } + } + + for _, raw := range v["order"] { + field, desc := raw, false + if i := strings.IndexByte(raw, ':'); i > 0 { + field = raw[:i] + switch raw[i+1:] { + case "asc": + desc = false + case "desc": + desc = true + default: + return q, parseErr{codeBadQuery, fmt.Sprintf("order direction must be asc|desc, got %q", raw[i+1:])} + } + } + if !fieldPathRe.MatchString(field) { + return q, parseErr{codeBadField, fmt.Sprintf("%q is not a valid field path", field)} + } + q.Order = append(q.Order, OrderKey{Field: field, Desc: desc}) + } + + if s := v.Get("limit"); s != "" { + n, err := strconv.Atoi(s) + if err != nil || n <= 0 || n > maxLimit { + return q, parseErr{codeBadLimit, fmt.Sprintf("limit must be 1..%d", maxLimit)} + } + q.Limit = n + } + if s := v.Get("offset"); s != "" { + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return q, parseErr{codeBadQuery, "offset must be >= 0"} + } + q.Offset = n + } + if s := v.Get("select"); s != "" { + for _, f := range strings.Split(s, ",") { + f = strings.TrimSpace(f) + if !fieldPathRe.MatchString(f) { + return q, parseErr{codeBadField, fmt.Sprintf("%q is not a valid field path", f)} + } + q.Select = append(q.Select, f) + } + } + if v.Get("debug") == "1" { + q.Debug = true + } + return q, nil +} + +type objectsResponse struct { + OK bool `json:"ok"` + Items json.RawMessage `json:"items,omitempty"` + Item json.RawMessage `json:"item,omitempty"` + EquivalentLua *string `json:"equivalentLua,omitempty"` + Error string `json:"error,omitempty"` + Code string `json:"code,omitempty"` +} + +func writeError(w http.ResponseWriter, status int, code, msg string) { + writeJSON(w, status, map[string]any{"error": msg, "code": code}) +} + +func errorStatus(code string) int { + switch code { + case codeNotFound, codeUnknownTag: + return http.StatusNotFound + case codeBadField, codeBadQuery, codeUnknownOperator, codeBadLimit: + return http.StatusBadRequest + case codeBridgeUnavailable: + return http.StatusServiceUnavailable + case codeTimeout: + return http.StatusGatewayTimeout + default: + return http.StatusInternalServerError + } +} + +func (b *RuntimeBridge) callObjectsAPI(ctx context.Context, browser *HeadlessBrowser, req any) (objectsResponse, error) { + reqJSON, err := json.Marshal(req) + if err != nil { + return objectsResponse{}, err + } + raw, err := browser.evalViaGlobal(ctx, "sbRuntime.objectsAPI", string(reqJSON)) + if err != nil { + return objectsResponse{}, err + } + s, ok := raw.(string) + if !ok { + return objectsResponse{}, fmt.Errorf("unexpected bridge return type %T", raw) + } + var resp objectsResponse + if err := json.Unmarshal([]byte(s), &resp); err != nil { + return objectsResponse{}, err + } + return resp, nil +} + +// HandleObjectsListTags handles GET /.runtime/objects. +func (b *RuntimeBridge) HandleObjectsListTags(w http.ResponseWriter, r *http.Request) { + res, ok := b.withBridge(w, r, func(ctx context.Context, browser *HeadlessBrowser) (any, error) { + return b.callObjectsAPI(ctx, browser, map[string]any{"kind": "list_tags"}) + }) + if !ok { + return + } + resp := res.(objectsResponse) + if !resp.OK { + writeError(w, errorStatus(resp.Code), resp.Code, resp.Error) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(resp.Items) +} + +// splitObjectsPath parses the encoded path suffix after /.runtime/objects/ +// into (tag, ref). Tags or refs containing `/` must be percent-encoded +// (`%2F`); we split on the first un-encoded `/` and then decode. Returns +// ("", "", err) if either segment has an invalid percent escape. +func splitObjectsPath(escapedSuffix string) (tag, ref string, err error) { + rawTag, rawRef, hasRef := strings.Cut(escapedSuffix, "/") + tag, err = url.PathUnescape(rawTag) + if err != nil { + return "", "", fmt.Errorf("invalid tag encoding: %w", err) + } + if hasRef { + ref, err = url.PathUnescape(rawRef) + if err != nil { + return "", "", fmt.Errorf("invalid ref encoding: %w", err) + } + } + return tag, ref, nil +} + +// HandleObjectsByPath routes /.runtime/objects/{tag}[/{ref}] to either the +// list or get handler. A wildcard route plus manual percent-decoding lets +// tags and refs contain `/` characters (encoded as `%2F` on the wire). +func (b *RuntimeBridge) HandleObjectsByPath(w http.ResponseWriter, r *http.Request) { + suffix := strings.TrimPrefix(r.URL.EscapedPath(), "/.runtime/objects/") + tag, ref, err := splitObjectsPath(suffix) + if err != nil { + writeError(w, http.StatusBadRequest, codeBadQuery, err.Error()) + return + } + if tag == "" { + writeError(w, http.StatusBadRequest, codeBadQuery, "missing tag") + return + } + if ref == "" { + b.handleObjectsList(w, r, tag) + return + } + b.handleObjectsGet(w, r, tag, ref) +} + +func (b *RuntimeBridge) handleObjectsList(w http.ResponseWriter, r *http.Request, tag string) { + q, err := parseObjectsQuery(r.URL.Query()) + if err != nil { + pe := err.(parseErr) + writeError(w, errorStatus(pe.Code), pe.Code, pe.Msg) + return + } + req := map[string]any{ + "kind": "list", + "tag": tag, + "filters": q.Filters, + "order": q.Order, + "limit": q.Limit, + "offset": q.Offset, + "debug": q.Debug, + } + if len(q.Select) > 0 { + req["select"] = q.Select + } + res, ok := b.withBridge(w, r, func(ctx context.Context, browser *HeadlessBrowser) (any, error) { + return b.callObjectsAPI(ctx, browser, req) + }) + if !ok { + return + } + resp := res.(objectsResponse) + if !resp.OK { + writeError(w, errorStatus(resp.Code), resp.Code, resp.Error) + return + } + w.Header().Set("Content-Type", "application/json") + if q.Debug && resp.EquivalentLua != nil { + w.Header().Set("X-Equivalent-Lua", *resp.EquivalentLua) + } + w.Write(resp.Items) +} + +func (b *RuntimeBridge) handleObjectsGet(w http.ResponseWriter, r *http.Request, tag, ref string) { + req := map[string]any{"kind": "get", "tag": tag, "ref": ref} + res, ok := b.withBridge(w, r, func(ctx context.Context, browser *HeadlessBrowser) (any, error) { + return b.callObjectsAPI(ctx, browser, req) + }) + if !ok { + return + } + resp := res.(objectsResponse) + if !resp.OK { + writeError(w, errorStatus(resp.Code), resp.Code, resp.Error) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(resp.Item) +} + diff --git a/server/runtime_objects_test.go b/server/runtime_objects_test.go new file mode 100644 index 00000000..ba1d1481 --- /dev/null +++ b/server/runtime_objects_test.go @@ -0,0 +1,108 @@ +package server + +import ( + "net/url" + "reflect" + "sort" + "testing" +) + +func TestSplitObjectsPath(t *testing.T) { + cases := []struct { + in string + wantTag, wantRef string + wantErr bool + }{ + {"page", "page", "", false}, + {"page/index", "page", "index", false}, + // Tag with slash: encoded as %2F, must round-trip as one segment. + {"meta%2Flibrary", "meta/library", "", false}, + {"meta%2Flibrary/Some%20Page", "meta/library", "Some Page", false}, + // Ref containing %2F decodes to a single ref with `/` in it. + {"task/Daily%2F2026-05-14%40L3", "task", "Daily/2026-05-14@L3", false}, + // Empty / bad percent escape + {"", "", "", false}, // empty: caller will reject empty tag + {"%ZZ/x", "", "", true}, + } + for _, c := range cases { + gotTag, gotRef, err := splitObjectsPath(c.in) + if c.wantErr { + if err == nil { + t.Errorf("%q: expected error", c.in) + } + continue + } + if err != nil { + t.Errorf("%q: unexpected error %v", c.in, err) + continue + } + if gotTag != c.wantTag || gotRef != c.wantRef { + t.Errorf("%q: got (%q, %q) want (%q, %q)", c.in, gotTag, gotRef, c.wantTag, c.wantRef) + } + } +} + +func sortFilters(in []Filter) []Filter { + out := append([]Filter(nil), in...) + sort.Slice(out, func(i, j int) bool { return out[i].Field < out[j].Field }) + return out +} + +func TestParseObjectsQuery_Empty(t *testing.T) { + q, err := parseObjectsQuery(url.Values{}) + if err != nil { + t.Fatal(err) + } + if q.Limit != 100 || q.Offset != 0 || len(q.Filters) != 0 { + t.Fatalf("unexpected defaults: %+v", q) + } +} + +func TestParseObjectsQuery_WhereVariants(t *testing.T) { + v := url.Values{} + v.Add("where[name]", "foo") + v.Add("where[age][gte]", "10") + v.Add("where[status][in]", "open,pending") + v.Add("order", "name") + v.Add("order", "age:desc") + v.Add("limit", "20") + v.Add("offset", "5") + v.Add("select", "name,age") + q, err := parseObjectsQuery(v) + if err != nil { + t.Fatal(err) + } + want := []Filter{ + {Field: "name", Op: "eq", Value: "foo"}, + {Field: "age", Op: "gte", Value: "10"}, + {Field: "status", Op: "in", Value: "open,pending"}, + } + if !reflect.DeepEqual(sortFilters(q.Filters), sortFilters(want)) { + t.Fatalf("filters: got %+v want %+v", q.Filters, want) + } + if q.Limit != 20 || q.Offset != 5 { + t.Fatalf("paging: %+v", q) + } + if len(q.Order) != 2 || q.Order[0].Field != "name" || q.Order[1].Desc != true { + t.Fatalf("order: %+v", q.Order) + } + if !reflect.DeepEqual(q.Select, []string{"name", "age"}) { + t.Fatalf("select: %+v", q.Select) + } +} + +func TestParseObjectsQuery_Errors(t *testing.T) { + cases := []url.Values{ + {"limit": {"abc"}}, + {"limit": {"-1"}}, + {"limit": {"1001"}}, + {"offset": {"-1"}}, + {"where[bad name]": {"v"}}, + {"where[f][nope]": {"v"}}, + } + for _, c := range cases { + if _, err := parseObjectsQuery(c); err == nil { + t.Errorf("expected error for %v", c) + } + } +} diff --git a/server/server.go b/server/server.go index 40077f15..1519f812 100644 --- a/server/server.go +++ b/server/server.go @@ -114,6 +114,10 @@ func Router(config *ServerConfig) chi.Router { r.Post("/.runtime/lua_script", bridge.HandleLuaScriptAPI) r.Get("/.runtime/screenshot", bridge.HandleScreenshot) r.Get("/.runtime/logs", bridge.HandleConsoleLogs) + r.Get("/.runtime/objects", bridge.HandleObjectsListTags) + // Wildcard route + manual path split (see HandleObjectsByPath) so + // tags and refs containing `/` work when percent-encoded as `%2F`. + r.Get("/.runtime/objects/*", bridge.HandleObjectsByPath) }) // Proxy endpoint