Files
plainleaf/server/runtime_objects.go
T

312 lines
8.8 KiB
Go

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)
}