fix: parse MCP result payloads and drop place idempotency_key
Typed result structs replace empty envelopes. Equity place sends ref_id only so live additionalProperties:false schemas accept the call.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnmarshalRows unpacks a list from an object (trying keys in order) or a JSON array.
|
||||
// next is next_cursor, else the cursor query param of next, else next itself when it
|
||||
// is not a URL.
|
||||
func UnmarshalRows[T any](raw json.RawMessage, keys ...string) (rows []T, next string, err error) {
|
||||
data := Unwrap(raw)
|
||||
var obj map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &obj) != nil {
|
||||
var list []T
|
||||
if err := json.Unmarshal(data, &list); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return list, "", nil
|
||||
}
|
||||
next = cursorFromMap(obj)
|
||||
for _, k := range keys {
|
||||
item, ok := obj[k]
|
||||
if !ok || len(item) == 0 || string(item) == "null" {
|
||||
continue
|
||||
}
|
||||
var list []T
|
||||
if json.Unmarshal(item, &list) == nil {
|
||||
return list, next, nil
|
||||
}
|
||||
var one T
|
||||
if json.Unmarshal(item, &one) == nil {
|
||||
return []T{one}, next, nil
|
||||
}
|
||||
}
|
||||
return nil, next, nil
|
||||
}
|
||||
|
||||
func cursorFromMap(obj map[string]json.RawMessage) string {
|
||||
if c := stringField(obj, "next_cursor"); c != "" {
|
||||
return c
|
||||
}
|
||||
return Cursor(stringField(obj, "next"), "")
|
||||
}
|
||||
|
||||
func stringField(obj map[string]json.RawMessage, key string) string {
|
||||
raw, ok := obj[key]
|
||||
if !ok || len(raw) == 0 || string(raw) == "null" {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Cursor prefers nextCursor; otherwise extracts cursor from a next URL.
|
||||
func Cursor(next, nextCursor string) string {
|
||||
if nextCursor != "" {
|
||||
return nextCursor
|
||||
}
|
||||
next = strings.TrimSpace(next)
|
||||
if next == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(next)
|
||||
if err != nil || (u.Scheme == "" && u.Host == "" && !strings.Contains(next, "?")) {
|
||||
return next
|
||||
}
|
||||
if v := u.Query().Get("cursor"); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package wire_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
|
||||
)
|
||||
|
||||
func TestUnmarshalRows_objectAndArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
type row struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
rows, next, err := wire.UnmarshalRows[row](json.RawMessage(`{"data":{"positions":[{"id":"a"}],"next_cursor":"c2"}}`), "positions", "results")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != "a" || next != "c2" {
|
||||
t.Fatalf("%+v %q", rows, next)
|
||||
}
|
||||
rows, next, err = wire.UnmarshalRows[row](json.RawMessage(`[{"id":"b"}]`), "positions")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != "b" || next != "" {
|
||||
t.Fatalf("%+v %q", rows, next)
|
||||
}
|
||||
rows, next, err = wire.UnmarshalRows[row](json.RawMessage(`{"results":{"id":"one"},"next":"https://x/?cursor=n3"}`), "positions", "results")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != "one" || next != "n3" {
|
||||
t.Fatalf("%+v %q", rows, next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
if wire.Cursor("https://x/y?cursor=abc", "") != "abc" {
|
||||
t.Fatal("url")
|
||||
}
|
||||
if wire.Cursor("rawtok", "pref") != "pref" {
|
||||
t.Fatal("pref")
|
||||
}
|
||||
if wire.Cursor("rawtok", "") != "rawtok" {
|
||||
t.Fatal("bare")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user