check isin format and enforce rate limit
This commit is contained in:
9
go.mod
9
go.mod
@@ -3,17 +3,18 @@ module github.com/nmoniz/any2anexoj
|
|||||||
go 1.25.3
|
go 1.25.3
|
||||||
|
|
||||||
require (
|
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
|
go.uber.org/mock v0.6.0
|
||||||
golang.org/x/sync v0.18.0
|
golang.org/x/sync v0.18.0
|
||||||
github.com/biter777/countries v1.7.5
|
golang.org/x/time v0.14.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jedib0t/go-pretty/v6 v6.7.2 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // 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/mod v0.27.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
|||||||
5
go.sum
5
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/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 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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 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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
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/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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
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 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -6,19 +6,30 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"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 {
|
type OpenFIGI struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
mappingLimiter *rate.Limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOpenFIGI(c *http.Client) *OpenFIGI {
|
func NewOpenFIGI(c *http.Client) *OpenFIGI {
|
||||||
return &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) {
|
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{{
|
rawBody, err := json.Marshal([]mappingRequestBody{{
|
||||||
IDType: "ID_ISIN",
|
IDType: "ID_ISIN",
|
||||||
IDValue: isin,
|
IDValue: isin,
|
||||||
@@ -34,6 +45,11 @@ func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string
|
|||||||
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
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)
|
res, err := of.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("make mapping request: %w", err)
|
return "", fmt.Errorf("make mapping request: %w", err)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package internal_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -13,59 +14,91 @@ import (
|
|||||||
|
|
||||||
func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) {
|
func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string // description of this test case
|
name string // description of this test case
|
||||||
response *http.Response
|
client *http.Client
|
||||||
isin string
|
isin string
|
||||||
want string
|
want string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "all good",
|
name: "all good",
|
||||||
response: &http.Response{
|
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||||
Status: http.StatusText(http.StatusOK),
|
return &http.Response{
|
||||||
StatusCode: http.StatusOK,
|
Status: http.StatusText(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"}]}]`)),
|
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",
|
isin: "NL0000235190",
|
||||||
want: "Common Stock",
|
want: "Common Stock",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bas status code",
|
name: "bas status code",
|
||||||
response: &http.Response{
|
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||||
Status: http.StatusText(http.StatusTooManyRequests),
|
return &http.Response{
|
||||||
StatusCode: http.StatusTooManyRequests,
|
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",
|
isin: "NL0000235190",
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty top-level",
|
name: "empty top-level",
|
||||||
response: &http.Response{
|
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||||
Status: http.StatusText(http.StatusOK),
|
return &http.Response{
|
||||||
StatusCode: http.StatusOK,
|
Status: http.StatusText(http.StatusOK),
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`[]`)),
|
StatusCode: http.StatusOK,
|
||||||
},
|
Body: io.NopCloser(bytes.NewBufferString(`[]`)),
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
isin: "NL0000235190",
|
isin: "NL0000235190",
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty data elements",
|
name: "empty data elements",
|
||||||
response: &http.Response{
|
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||||
Status: http.StatusText(http.StatusOK),
|
return &http.Response{
|
||||||
StatusCode: http.StatusOK,
|
Status: http.StatusText(http.StatusOK),
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[]}]`)),
|
StatusCode: http.StatusOK,
|
||||||
},
|
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[]}]`)),
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
isin: "NL0000235190",
|
isin: "NL0000235190",
|
||||||
wantErr: true,
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
of := internal.NewOpenFIGI(tt.client)
|
||||||
return tt.response, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
of := internal.NewOpenFIGI(c)
|
|
||||||
|
|
||||||
got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin)
|
got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin)
|
||||||
if gotErr != nil {
|
if gotErr != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user