Changeset 0.29.1 (#196)
This commit is contained in:
344
server/handlers/ext_api.go
Normal file
344
server/handlers/ext_api.go
Normal file
@@ -0,0 +1,344 @@
|
||||
// Package handlers — ext_api.go
|
||||
//
|
||||
// v0.29.1 CS1: Extension API routes. Starlark packages serve custom
|
||||
// JSON endpoints via the existing Gin router. Mounted at
|
||||
// /s/:slug/api/*path with JWT auth.
|
||||
//
|
||||
// Manifest format:
|
||||
//
|
||||
// {
|
||||
// "api_routes": [
|
||||
// {"method": "GET", "path": "/status"},
|
||||
// {"method": "POST", "path": "/webhook"},
|
||||
// {"method": "*", "path": "/proxy/*"}
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// Route matching:
|
||||
// - method: exact match (case-insensitive), or "*" for any method
|
||||
// - path: exact match, or trailing "*" for prefix match
|
||||
// - If api_routes is boolean true, all routes are forwarded
|
||||
//
|
||||
// Starlark contract:
|
||||
//
|
||||
// def on_request(req):
|
||||
// # req = {"method": "GET", "path": "/status", "headers": {...},
|
||||
// # "query": {...}, "body": "", "user_id": "..."}
|
||||
// return {"status": 200, "body": '{"ok": true}'}
|
||||
//
|
||||
// Response dict fields:
|
||||
// - status: int (default 200)
|
||||
// - headers: dict (optional, string→string)
|
||||
// - body: string (default "")
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ExtAPIHandler serves extension API routes via Starlark.
|
||||
type ExtAPIHandler struct {
|
||||
stores store.Stores
|
||||
runner *sandbox.Runner
|
||||
}
|
||||
|
||||
// NewExtAPIHandler creates the extension API handler.
|
||||
func NewExtAPIHandler(stores store.Stores, runner *sandbox.Runner) *ExtAPIHandler {
|
||||
return &ExtAPIHandler{
|
||||
stores: stores,
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle processes an incoming request to /s/:slug/api/*path.
|
||||
// It loads the target package, validates it, matches the route against
|
||||
// the manifest's api_routes, and dispatches to the Starlark on_request
|
||||
// entry point.
|
||||
func (h *ExtAPIHandler) Handle(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
rawPath := c.Param("path") // includes leading "/"
|
||||
|
||||
if slug == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "extension not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Gin *path captures include leading slash; normalize
|
||||
if rawPath == "" {
|
||||
rawPath = "/"
|
||||
}
|
||||
|
||||
// ── 1. Load package ────────────────────────
|
||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), slug)
|
||||
if err != nil || pkg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "extension not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 2. Validate package state ──────────────
|
||||
if !pkg.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "extension is disabled"})
|
||||
return
|
||||
}
|
||||
if pkg.Status != models.PackageStatusActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "extension is " + pkg.Status})
|
||||
return
|
||||
}
|
||||
if pkg.Tier != models.ExtTierStarlark {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "extension is not a starlark package"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 3. Check api.http permission ───────────
|
||||
if h.stores.ExtPermissions == nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "permission system unavailable"})
|
||||
return
|
||||
}
|
||||
granted, err := h.stores.ExtPermissions.GrantedForPackage(c.Request.Context(), pkg.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "permission check failed"})
|
||||
return
|
||||
}
|
||||
if !hasPermission(granted, models.ExtPermAPIHTTP) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "extension does not have api.http permission"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 4. Match route against manifest ────────
|
||||
if !matchAPIRoute(pkg.Manifest, c.Request.Method, rawPath) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "route not declared in extension manifest"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 5. Build request dict ──────────────────
|
||||
reqDict, err := buildRequestDict(c, rawPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 6. Call on_request entry point ─────────
|
||||
rc := &sandbox.RunContext{UserID: getUserID(c)}
|
||||
val, output, err := h.runner.CallEntryPoint(
|
||||
c.Request.Context(), pkg, "on_request",
|
||||
starlark.Tuple{reqDict}, nil, rc,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ ext_api %s: on_request error: %v", pkg.ID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "extension error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if output != "" {
|
||||
log.Printf(" 🔧 ext_api %s print: %s", pkg.ID, output)
|
||||
}
|
||||
|
||||
// ── 7. Parse and write response ────────────
|
||||
writeStarlarkResponse(c, val)
|
||||
}
|
||||
|
||||
// ─── Route Matching ─────────────────────────
|
||||
|
||||
// matchAPIRoute checks whether the incoming method+path is declared in
|
||||
// the package manifest's api_routes field.
|
||||
//
|
||||
// Formats accepted:
|
||||
// - boolean true: all routes match
|
||||
// - []any of route dicts: {"method": "GET", "path": "/status"}
|
||||
// method "*" matches any method; path trailing "*" matches prefix
|
||||
func matchAPIRoute(manifest map[string]any, method, path string) bool {
|
||||
raw, ok := manifest["api_routes"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Boolean shorthand: true = all routes
|
||||
if b, ok := raw.(bool); ok {
|
||||
return b
|
||||
}
|
||||
|
||||
// Array of route declarations
|
||||
routes, ok := raw.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
method = strings.ToUpper(method)
|
||||
|
||||
for _, entry := range routes {
|
||||
route, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Match method
|
||||
rm, _ := route["method"].(string)
|
||||
rm = strings.ToUpper(rm)
|
||||
if rm != "*" && rm != method {
|
||||
continue
|
||||
}
|
||||
|
||||
// Match path
|
||||
rp, _ := route["path"].(string)
|
||||
if rp == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(rp, "*") {
|
||||
// Prefix match: "/proxy/*" matches "/proxy/anything"
|
||||
prefix := strings.TrimSuffix(rp, "*")
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if path == rp {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Request Building ───────────────────────
|
||||
|
||||
// buildRequestDict creates a Starlark dict from the Gin context:
|
||||
//
|
||||
// {
|
||||
// "method": "GET",
|
||||
// "path": "/status",
|
||||
// "headers": {"content-type": "application/json", ...},
|
||||
// "query": {"page": "1", ...},
|
||||
// "body": "...",
|
||||
// "user_id": "uuid",
|
||||
// }
|
||||
func buildRequestDict(c *gin.Context, path string) (*starlark.Dict, error) {
|
||||
// Read body (capped at 1 MB for safety)
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading body: %w", err)
|
||||
}
|
||||
|
||||
// Headers dict (lowercase keys, first value)
|
||||
hdrs := starlark.NewDict(len(c.Request.Header))
|
||||
for k, vals := range c.Request.Header {
|
||||
if len(vals) > 0 {
|
||||
_ = hdrs.SetKey(starlark.String(strings.ToLower(k)), starlark.String(vals[0]))
|
||||
}
|
||||
}
|
||||
|
||||
// Query dict (first value per key)
|
||||
query := starlark.NewDict(len(c.Request.URL.Query()))
|
||||
for k, vals := range c.Request.URL.Query() {
|
||||
if len(vals) > 0 {
|
||||
_ = query.SetKey(starlark.String(k), starlark.String(vals[0]))
|
||||
}
|
||||
}
|
||||
|
||||
d := starlark.NewDict(6)
|
||||
_ = d.SetKey(starlark.String("method"), starlark.String(c.Request.Method))
|
||||
_ = d.SetKey(starlark.String("path"), starlark.String(path))
|
||||
_ = d.SetKey(starlark.String("headers"), hdrs)
|
||||
_ = d.SetKey(starlark.String("query"), query)
|
||||
_ = d.SetKey(starlark.String("body"), starlark.String(string(body)))
|
||||
_ = d.SetKey(starlark.String("user_id"), starlark.String(getUserID(c)))
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ─── Response Writing ───────────────────────
|
||||
|
||||
// writeStarlarkResponse parses the Starlark return value and writes
|
||||
// the HTTP response. Expected: dict with "status", "headers", "body".
|
||||
// Missing fields use defaults (200, no extra headers, empty body).
|
||||
func writeStarlarkResponse(c *gin.Context, val starlark.Value) {
|
||||
if val == nil || val == starlark.None {
|
||||
c.Status(http.StatusNoContent)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
|
||||
dict, ok := val.(*starlark.Dict)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("on_request must return a dict, got %s", val.Type()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Status code
|
||||
status := http.StatusOK
|
||||
if sv, found, _ := dict.Get(starlark.String("status")); found {
|
||||
if i, err := starlark.AsInt32(sv); err == nil {
|
||||
status = i
|
||||
}
|
||||
}
|
||||
|
||||
// Response headers
|
||||
if hv, found, _ := dict.Get(starlark.String("headers")); found {
|
||||
if hd, ok := hv.(*starlark.Dict); ok {
|
||||
for _, item := range hd.Items() {
|
||||
k, ok1 := starlark.AsString(item[0])
|
||||
v, ok2 := starlark.AsString(item[1])
|
||||
if ok1 && ok2 {
|
||||
c.Header(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
body := ""
|
||||
if bv, found, _ := dict.Get(starlark.String("body")); found {
|
||||
body, _ = starlark.AsString(bv)
|
||||
}
|
||||
|
||||
// Auto-detect content type if not set explicitly
|
||||
if c.Writer.Header().Get("Content-Type") == "" {
|
||||
if len(body) > 0 && (body[0] == '{' || body[0] == '[') {
|
||||
c.Header("Content-Type", "application/json")
|
||||
} else {
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
c.String(status, body)
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────
|
||||
|
||||
func hasPermission(granted []string, perm string) bool {
|
||||
for _, p := range granted {
|
||||
if p == perm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasAPIRoutes checks whether a package manifest declares API routes.
|
||||
// Used by startup discovery and admin UI to identify API-capable packages.
|
||||
func HasAPIRoutes(manifest map[string]any) bool {
|
||||
raw, ok := manifest["api_routes"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if b, ok := raw.(bool); ok {
|
||||
return b
|
||||
}
|
||||
if routes, ok := raw.([]any); ok {
|
||||
return len(routes) > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
386
server/handlers/ext_api_test.go
Normal file
386
server/handlers/ext_api_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.starlark.net/starlark"
|
||||
)
|
||||
|
||||
// ─── matchAPIRoute ──────────────────────────
|
||||
|
||||
func TestMatchAPIRoute_BooleanTrue(t *testing.T) {
|
||||
m := map[string]any{"api_routes": true}
|
||||
if !matchAPIRoute(m, "GET", "/anything") {
|
||||
t.Error("boolean true should match any route")
|
||||
}
|
||||
if !matchAPIRoute(m, "POST", "/foo/bar") {
|
||||
t.Error("boolean true should match any route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_BooleanFalse(t *testing.T) {
|
||||
m := map[string]any{"api_routes": false}
|
||||
if matchAPIRoute(m, "GET", "/anything") {
|
||||
t.Error("boolean false should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_Missing(t *testing.T) {
|
||||
m := map[string]any{}
|
||||
if matchAPIRoute(m, "GET", "/anything") {
|
||||
t.Error("missing api_routes should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_ExactMatch(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/status"},
|
||||
map[string]any{"method": "POST", "path": "/webhook"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
method, path string
|
||||
want bool
|
||||
}{
|
||||
{"GET", "/status", true},
|
||||
{"POST", "/webhook", true},
|
||||
{"GET", "/webhook", false}, // wrong method
|
||||
{"POST", "/status", false}, // wrong method
|
||||
{"GET", "/unknown", false}, // undeclared path
|
||||
{"GET", "/status/", false}, // trailing slash mismatch
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := matchAPIRoute(m, tt.method, tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchAPIRoute(%s %s) = %v, want %v", tt.method, tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_WildcardMethod(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "*", "path": "/anything"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, method := range []string{"GET", "POST", "PUT", "DELETE", "PATCH"} {
|
||||
if !matchAPIRoute(m, method, "/anything") {
|
||||
t.Errorf("wildcard method should match %s", method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_PrefixMatch(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "*", "path": "/proxy/*"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/proxy/", true},
|
||||
{"/proxy/foo", true},
|
||||
{"/proxy/foo/bar/baz", true},
|
||||
{"/prox", false}, // not the prefix
|
||||
{"/other", false}, // different path
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := matchAPIRoute(m, "GET", tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchAPIRoute(GET %s) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_CaseInsensitiveMethod(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "get", "path": "/status"},
|
||||
},
|
||||
}
|
||||
if !matchAPIRoute(m, "GET", "/status") {
|
||||
t.Error("method matching should be case-insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_EmptyArray(t *testing.T) {
|
||||
m := map[string]any{"api_routes": []any{}}
|
||||
if matchAPIRoute(m, "GET", "/anything") {
|
||||
t.Error("empty array should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_MalformedEntries(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"api_routes": []any{
|
||||
"not a map",
|
||||
42,
|
||||
map[string]any{"method": "GET"}, // missing path
|
||||
map[string]any{"method": "GET", "path": "/ok"},
|
||||
},
|
||||
}
|
||||
// Malformed entries skipped, valid one matches
|
||||
if !matchAPIRoute(m, "GET", "/ok") {
|
||||
t.Error("should skip malformed entries and match valid one")
|
||||
}
|
||||
if matchAPIRoute(m, "GET", "/not-declared") {
|
||||
t.Error("undeclared route should not match")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HasAPIRoutes ───────────────────────────
|
||||
|
||||
func TestHasAPIRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
manifest map[string]any
|
||||
want bool
|
||||
}{
|
||||
{"empty manifest", map[string]any{}, false},
|
||||
{"boolean true", map[string]any{"api_routes": true}, true},
|
||||
{"boolean false", map[string]any{"api_routes": false}, false},
|
||||
{"non-empty array", map[string]any{"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/x"},
|
||||
}}, true},
|
||||
{"empty array", map[string]any{"api_routes": []any{}}, false},
|
||||
{"wrong type", map[string]any{"api_routes": "yes"}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := HasAPIRoutes(tt.manifest)
|
||||
if got != tt.want {
|
||||
t.Errorf("HasAPIRoutes() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── writeStarlarkResponse ──────────────────
|
||||
|
||||
func TestWriteStarlarkResponse_FullDict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
hdrs := starlark.NewDict(1)
|
||||
_ = hdrs.SetKey(starlark.String("X-Custom"), starlark.String("hello"))
|
||||
|
||||
resp := starlark.NewDict(3)
|
||||
_ = resp.SetKey(starlark.String("status"), starlark.MakeInt(201))
|
||||
_ = resp.SetKey(starlark.String("headers"), hdrs)
|
||||
_ = resp.SetKey(starlark.String("body"), starlark.String(`{"created":true}`))
|
||||
|
||||
writeStarlarkResponse(c, resp)
|
||||
|
||||
if w.Code != 201 {
|
||||
t.Errorf("status = %d, want 201", w.Code)
|
||||
}
|
||||
if w.Header().Get("X-Custom") != "hello" {
|
||||
t.Errorf("X-Custom = %q, want %q", w.Header().Get("X-Custom"), "hello")
|
||||
}
|
||||
if w.Header().Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", w.Header().Get("Content-Type"))
|
||||
}
|
||||
if w.Body.String() != `{"created":true}` {
|
||||
t.Errorf("body = %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_DefaultStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
resp := starlark.NewDict(1)
|
||||
_ = resp.SetKey(starlark.String("body"), starlark.String("ok"))
|
||||
|
||||
writeStarlarkResponse(c, resp)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("status = %d, want 200 (default)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_None(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
writeStarlarkResponse(c, starlark.None)
|
||||
|
||||
if w.Code != 204 {
|
||||
t.Errorf("status = %d, want 204 for None", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_Nil(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
writeStarlarkResponse(c, nil)
|
||||
|
||||
if w.Code != 204 {
|
||||
t.Errorf("status = %d, want 204 for nil", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_NotDict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
writeStarlarkResponse(c, starlark.String("oops"))
|
||||
|
||||
if w.Code != 500 {
|
||||
t.Errorf("status = %d, want 500 for non-dict", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "must return a dict") {
|
||||
t.Errorf("body = %q, expected error about dict", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_PlainText(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
resp := starlark.NewDict(1)
|
||||
_ = resp.SetKey(starlark.String("body"), starlark.String("plain text"))
|
||||
|
||||
writeStarlarkResponse(c, resp)
|
||||
|
||||
if w.Header().Get("Content-Type") != "text/plain; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want text/plain", w.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_ExplicitContentType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
hdrs := starlark.NewDict(1)
|
||||
_ = hdrs.SetKey(starlark.String("Content-Type"), starlark.String("text/xml"))
|
||||
|
||||
resp := starlark.NewDict(2)
|
||||
_ = resp.SetKey(starlark.String("headers"), hdrs)
|
||||
_ = resp.SetKey(starlark.String("body"), starlark.String(`<ok/>`))
|
||||
|
||||
writeStarlarkResponse(c, resp)
|
||||
|
||||
if w.Header().Get("Content-Type") != "text/xml" {
|
||||
t.Errorf("Content-Type = %q, want text/xml (explicit)", w.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── buildRequestDict ───────────────────────
|
||||
|
||||
func TestBuildRequestDict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
c.Request = httptest.NewRequest("POST", "/s/my-ext/api/data?page=2&limit=10", strings.NewReader(`{"input":"test"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("X-Token", "abc")
|
||||
c.Set("user_id", "user-uuid-123")
|
||||
|
||||
d, err := buildRequestDict(c, "/data")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRequestDict: %v", err)
|
||||
}
|
||||
|
||||
// Method
|
||||
mv, _, _ := d.Get(starlark.String("method"))
|
||||
if s, _ := starlark.AsString(mv); s != "POST" {
|
||||
t.Errorf("method = %q, want POST", s)
|
||||
}
|
||||
|
||||
// Path
|
||||
pv, _, _ := d.Get(starlark.String("path"))
|
||||
if s, _ := starlark.AsString(pv); s != "/data" {
|
||||
t.Errorf("path = %q, want /data", s)
|
||||
}
|
||||
|
||||
// Body
|
||||
bv, _, _ := d.Get(starlark.String("body"))
|
||||
if s, _ := starlark.AsString(bv); s != `{"input":"test"}` {
|
||||
t.Errorf("body = %q", s)
|
||||
}
|
||||
|
||||
// User ID
|
||||
uv, _, _ := d.Get(starlark.String("user_id"))
|
||||
if s, _ := starlark.AsString(uv); s != "user-uuid-123" {
|
||||
t.Errorf("user_id = %q", s)
|
||||
}
|
||||
|
||||
// Headers
|
||||
hv, _, _ := d.Get(starlark.String("headers"))
|
||||
hd := hv.(*starlark.Dict)
|
||||
ctv, found, _ := hd.Get(starlark.String("content-type"))
|
||||
if !found {
|
||||
t.Fatal("missing content-type header")
|
||||
}
|
||||
if s, _ := starlark.AsString(ctv); s != "application/json" {
|
||||
t.Errorf("content-type = %q", s)
|
||||
}
|
||||
|
||||
// Query
|
||||
qv, _, _ := d.Get(starlark.String("query"))
|
||||
qd := qv.(*starlark.Dict)
|
||||
pageV, found, _ := qd.Get(starlark.String("page"))
|
||||
if !found {
|
||||
t.Fatal("missing page query param")
|
||||
}
|
||||
if s, _ := starlark.AsString(pageV); s != "2" {
|
||||
t.Errorf("query page = %q, want 2", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestDict_EmptyBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
c.Request = httptest.NewRequest("GET", "/s/ext/api/health", nil)
|
||||
|
||||
d, err := buildRequestDict(c, "/health")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRequestDict: %v", err)
|
||||
}
|
||||
|
||||
bv, _, _ := d.Get(starlark.String("body"))
|
||||
if s, _ := starlark.AsString(bv); s != "" {
|
||||
t.Errorf("body should be empty for GET, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── hasPermission ──────────────────────────
|
||||
|
||||
func TestHasPermission(t *testing.T) {
|
||||
granted := []string{"secrets.read", "api.http", "notifications.send"}
|
||||
|
||||
if !hasPermission(granted, "api.http") {
|
||||
t.Error("should find api.http")
|
||||
}
|
||||
if hasPermission(granted, "db.read") {
|
||||
t.Error("should not find db.read")
|
||||
}
|
||||
if hasPermission(nil, "api.http") {
|
||||
t.Error("nil slice should return false")
|
||||
}
|
||||
if hasPermission([]string{}, "api.http") {
|
||||
t.Error("empty slice should return false")
|
||||
}
|
||||
}
|
||||
51
server/handlers/provider_resolver_adapter.go
Normal file
51
server/handlers/provider_resolver_adapter.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Package handlers — provider_resolver_adapter.go
|
||||
//
|
||||
// v0.29.1 CS2: Adapter that satisfies sandbox.ProviderResolver using
|
||||
// ResolveProviderConfig + providers.Get. Avoids circular dependency
|
||||
// (sandbox → handlers → sandbox) by having handlers implement the
|
||||
// sandbox-defined interface.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ProviderResolverAdapter implements sandbox.ProviderResolver using
|
||||
// the existing ResolveProviderConfig function and provider registry.
|
||||
type ProviderResolverAdapter struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
// NewProviderResolverAdapter creates an adapter for Starlark provider resolution.
|
||||
func NewProviderResolverAdapter(stores store.Stores, vault *crypto.KeyResolver) *ProviderResolverAdapter {
|
||||
return &ProviderResolverAdapter{stores: stores, vault: vault}
|
||||
}
|
||||
|
||||
// Resolve satisfies sandbox.ProviderResolver. It resolves the provider
|
||||
// config via the BYOK chain and returns the provider instance + config.
|
||||
func (a *ProviderResolverAdapter) Resolve(ctx context.Context, userID, channelID, providerConfigID, model string) (*sandbox.ProviderResolution, error) {
|
||||
res, err := ResolveProviderConfig(a.stores, a.vault, userID, channelID, providerConfigID, model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider, err := providers.Get(res.ProviderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider unavailable: %w", err)
|
||||
}
|
||||
|
||||
return &sandbox.ProviderResolution{
|
||||
Provider: provider,
|
||||
Config: res.Config,
|
||||
ProviderID: res.ProviderID,
|
||||
Model: res.Model,
|
||||
ConfigID: res.ConfigID,
|
||||
}, nil
|
||||
}
|
||||
@@ -78,7 +78,8 @@ func (h *RoutingAdminHandler) CreatePolicy(c *gin.Context) {
|
||||
// Validate policy type
|
||||
switch routing.PolicyType(p.Type) {
|
||||
case routing.PolicyProviderPrefer, routing.PolicyTeamRoute,
|
||||
routing.PolicyCostLimit, routing.PolicyModelAlias:
|
||||
routing.PolicyCostLimit, routing.PolicyModelAlias,
|
||||
routing.PolicyCapabilityMatch:
|
||||
// valid
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy_type: " + p.Type})
|
||||
|
||||
Reference in New Issue
Block a user