From 8c784f3b747dba5f0b440c6a03d1dd468648e07c Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 16:01:15 +0000 Subject: [PATCH] check isin format and enforce rate limit --- go.mod | 9 ++-- go.sum | 5 ++- internal/open_figi.go | 20 ++++++++- internal/open_figi_test.go | 91 ++++++++++++++++++++++++++------------ 4 files changed, 88 insertions(+), 37 deletions(-) diff --git a/go.mod b/go.mod index d775733..9a68879 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,18 @@ module github.com/nmoniz/any2anexoj go 1.25.3 require ( + github.com/biter777/countries v1.7.5 + github.com/jedib0t/go-pretty/v6 v6.7.2 + github.com/shopspring/decimal v1.4.0 + github.com/spf13/pflag v1.0.10 go.uber.org/mock v0.6.0 golang.org/x/sync v0.18.0 - github.com/biter777/countries v1.7.5 + golang.org/x/time v0.14.0 ) require ( - github.com/jedib0t/go-pretty/v6 v6.7.2 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/shopspring/decimal v1.4.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.22.0 // indirect diff --git a/go.sum b/go.sum index 84d147b..199b11d 100644 --- a/go.sum +++ b/go.sum @@ -17,9 +17,8 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= @@ -30,6 +29,8 @@ golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/open_figi.go b/internal/open_figi.go index ca33709..958e4e1 100644 --- a/internal/open_figi.go +++ b/internal/open_figi.go @@ -6,19 +6,30 @@ import ( "encoding/json" "fmt" "net/http" + "time" + + "github.com/biter777/countries" + "golang.org/x/time/rate" ) +// OpenFIGI is a small adapter for the openfigi.com api type OpenFIGI struct { - client *http.Client + client *http.Client + mappingLimiter *rate.Limiter } func NewOpenFIGI(c *http.Client) *OpenFIGI { return &OpenFIGI{ - client: c, + client: c, + mappingLimiter: rate.NewLimiter(rate.Every(time.Minute), 25), // https://www.openfigi.com/api/documentation#rate-limits } } func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) { + if len(isin) != 12 || countries.ByName(isin[:2]) == countries.Unknown { + return "", fmt.Errorf("invalid ISIN: %s", isin) + } + rawBody, err := json.Marshal([]mappingRequestBody{{ IDType: "ID_ISIN", IDValue: isin, @@ -34,6 +45,11 @@ func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string req.Header.Add("Content-Type", "application/json") + err = of.mappingLimiter.Wait(ctx) + if err != nil { + return "", fmt.Errorf("wait for mapping request capacity: %w", err) + } + res, err := of.client.Do(req) if err != nil { return "", fmt.Errorf("make mapping request: %w", err) diff --git a/internal/open_figi_test.go b/internal/open_figi_test.go index 73a11c6..313a6d4 100644 --- a/internal/open_figi_test.go +++ b/internal/open_figi_test.go @@ -3,6 +3,7 @@ package internal_test import ( "bytes" "context" + "fmt" "io" "net/http" "testing" @@ -13,59 +14,91 @@ import ( func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) { tests := []struct { - name string // description of this test case - response *http.Response - isin string - want string - wantErr bool + name string // description of this test case + client *http.Client + isin string + want string + wantErr bool }{ { name: "all good", - response: &http.Response{ - Status: http.StatusText(http.StatusOK), - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"figi":"BBG000BJJR23","name":"AIRBUS SE","ticker":"EADSF","exchCode":"US","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"},{"figi":"BBG000BJJXJ2","name":"AIRBUS SE","ticker":"EADSF","exchCode":"PQ","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"}]}]`)), - }, + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"figi":"BBG000BJJR23","name":"AIRBUS SE","ticker":"EADSF","exchCode":"US","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"},{"figi":"BBG000BJJXJ2","name":"AIRBUS SE","ticker":"EADSF","exchCode":"PQ","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"}]}]`)), + }, nil + }), isin: "NL0000235190", want: "Common Stock", }, { name: "bas status code", - response: &http.Response{ - Status: http.StatusText(http.StatusTooManyRequests), - StatusCode: http.StatusTooManyRequests, - }, + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusTooManyRequests), + StatusCode: http.StatusTooManyRequests, + }, nil + }), + isin: "NL0000235190", + wantErr: true, + }, + { + name: "bad json", + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"bad": "json"}`)), + }, nil + }), isin: "NL0000235190", wantErr: true, }, { name: "empty top-level", - response: &http.Response{ - Status: http.StatusText(http.StatusOK), - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewBufferString(`[]`)), - }, + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[]`)), + }, nil + }), isin: "NL0000235190", wantErr: true, }, { name: "empty data elements", - response: &http.Response{ - Status: http.StatusText(http.StatusOK), - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewBufferString(`[{"data":[]}]`)), - }, + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"data":[]}]`)), + }, nil + }), isin: "NL0000235190", wantErr: true, }, + { + name: "client error", + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("boom") + }), + isin: "NL0000235190", + wantErr: true, + }, + { + name: "empty isin", + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + t.Fatalf("should not make api request") + return nil, nil + }), + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { - return tt.response, nil - }) - - of := internal.NewOpenFIGI(c) + of := internal.NewOpenFIGI(tt.client) got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin) if gotErr != nil {