runtime-api: /.runtime/objects/* REST endpoints

This commit is contained in:
Zef Hemel
2026-05-14 16:59:14 +02:00
parent 4e47809d86
commit 9cecda19a4
3 changed files with 423 additions and 0 deletions
+311
View File
@@ -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)
}
+108
View File
@@ -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)
}
}
}
+4
View File
@@ -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