From 7d549fda582db2f90c24ec8ed607b55afc1a4845 Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Tue, 1 Sep 2026 11:12:10 -0500 Subject: [PATCH] feat: add module and decimal wire helpers --- Makefile | 7 ++++ go.mod | 12 ++++++ go.sum | 18 ++++++++ internal/wire/wire.go | 69 +++++++++++++++++++++++++++++++ internal/wire/wire_test.go | 85 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+) create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/wire/wire.go create mode 100644 internal/wire/wire_test.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f1573b1 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: test vet + +test: + go test ./... + +vet: + go vet ./... diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f0d342d --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module s1d3sw1ped/robinhood-agentic-mcp + +go 1.25.0 + +require github.com/alpacahq/alpacadecimal v0.0.9 + +require ( + github.com/google/go-cmp v0.7.0 // indirect + github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9dc417f --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/alpacahq/alpacadecimal v0.0.9 h1:geeT3ZMyfgBV1mrqRDJWQ/4u+FaHep9MmQNFoHKFDNs= +github.com/alpacahq/alpacadecimal v0.0.9/go.mod h1:DmR0Qs+sFJ7nyhfYD0/UUzE+9tCbEF1Wa4iz1lZnlwg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/wire/wire.go b/internal/wire/wire.go new file mode 100644 index 0000000..b3a57b2 --- /dev/null +++ b/internal/wire/wire.go @@ -0,0 +1,69 @@ +package wire + +import ( + "encoding/json" + "fmt" + + decimal "github.com/alpacahq/alpacadecimal" +) + +// Unwrap returns the inner JSON of an optional {"data": ...} envelope. +func Unwrap(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return raw + } + var wrap struct { + Data json.RawMessage `json:"data"` + } + if json.Unmarshal(raw, &wrap) == nil && len(wrap.Data) > 0 { + return wrap.Data + } + return raw +} + +// Dec parses a required money/size/price value. +func Dec(v any) (decimal.Decimal, error) { + switch x := v.(type) { + case nil: + return decimal.Zero, nil + case float64: + return decimal.NewFromFloat(x), nil + case json.Number: + d, err := decimal.NewFromString(string(x)) + if err != nil { + return decimal.Zero, fmt.Errorf("parse decimal: %v", v) + } + return d, nil + case string: + if x == "" { + return decimal.Zero, nil + } + d, err := decimal.NewFromString(x) + if err != nil { + return decimal.Zero, fmt.Errorf("parse decimal: %v", v) + } + return d, nil + default: + return decimal.Zero, fmt.Errorf("parse decimal: %v", v) + } +} + +// DecOpt parses an optional money/size/price value. +func DecOpt(v any) (*decimal.Decimal, error) { + if v == nil { + return nil, nil + } + if s, ok := v.(string); ok && s == "" { + return nil, nil + } + d, err := Dec(v) + if err != nil { + return nil, err + } + return &d, nil +} + +// Encode is Decimal.String for Robinhood string wire fields. +func Encode(d decimal.Decimal) string { + return d.String() +} diff --git a/internal/wire/wire_test.go b/internal/wire/wire_test.go new file mode 100644 index 0000000..9b9c70f --- /dev/null +++ b/internal/wire/wire_test.go @@ -0,0 +1,85 @@ +package wire_test + +import ( + "encoding/json" + "testing" + + decimal "github.com/alpacahq/alpacadecimal" + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +func TestUnwrap_dataEnvelope(t *testing.T) { + t.Parallel() + in := json.RawMessage(`{"data":{"cash":"1000"}}`) + got := wire.Unwrap(in) + if string(got) != `{"cash":"1000"}` { + t.Fatalf("got %s", got) + } +} + +func TestDec_table(t *testing.T) { + t.Parallel() + zero := decimal.Zero + tests := []struct { + name string + in any + want decimal.Decimal + wantErr bool + }{ + {"number", float64(99.6), decimal.RequireFromString("99.6"), false}, + {"string", "99.60", decimal.RequireFromString("99.60"), false}, + {"zeroNum", float64(0), zero, false}, + {"zeroStr", "0", zero, false}, + {"emptyStr", "", zero, false}, + {"nil", nil, zero, false}, + {"bad", "n/a", zero, true}, + {"obj", map[string]any{"x": 1}, zero, true}, + {"bool", true, zero, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := wire.Dec(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("want error") + } + return + } + if err != nil { + t.Fatal(err) + } + if !got.Equal(tc.want) { + t.Fatalf("got %s want %s", got, tc.want) + } + }) + } +} + +func TestDecOpt_nullIsNil(t *testing.T) { + t.Parallel() + got, err := wire.DecOpt(nil) + if err != nil || got != nil { + t.Fatalf("got %v err %v", got, err) + } + got, err = wire.DecOpt("") + if err != nil || got != nil { + t.Fatalf("empty string: %v %v", got, err) + } + got, err = wire.DecOpt("0") + if err != nil || got == nil || !got.IsZero() { + t.Fatalf("zero: %v %v", got, err) + } + _, err = wire.DecOpt("n/a") + if err == nil { + t.Fatal("unparseable must error") + } +} + +func TestEncode(t *testing.T) { + t.Parallel() + d := decimal.RequireFromString("99.6") + if wire.Encode(d) != d.String() { + t.Fatalf("%q", wire.Encode(d)) + } +}