runtime-api: /.runtime/objects/* REST endpoints
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user