From b0d91e7eeebcf83e5cdb1ee3b3e978ead798a3fe Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sat, 16 May 2026 10:36:14 +0100 Subject: [PATCH] support openfigi api key --- cmd/any2anexoj-cli/main.go | 38 +++++++++++---------- internal/open_figi.go | 24 +++++++++++--- internal/open_figi_test.go | 53 ++++++++++++++++++++++++++++-- internal/trading212/record_test.go | 4 +-- 4 files changed, 92 insertions(+), 27 deletions(-) diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index cb7b239..e512cba 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -21,14 +21,9 @@ var ( // remove/change default platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms") lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code") + ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)") // TODO: improve documentation on selectors selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors:") - - readerFactories = map[string]func() internal.RecordReader{ - "trading212": func() internal.RecordReader { - return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second})) - }, - } ) func main() { @@ -42,6 +37,13 @@ func main() { } func run(ctx context.Context) error { + ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt) + defer cancel() + + eg, ctx := errgroup.WithContext(ctx) + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) + if platform == nil || len(*platform) == 0 { slog.Error("--platform flag is required") os.Exit(1) @@ -52,20 +54,11 @@ func run(ctx context.Context) error { os.Exit(1) } - ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt) - defer cancel() - - eg, ctx := errgroup.WithContext(ctx) - - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) - - factory, ok := readerFactories[*platform] - if !ok { - return fmt.Errorf("unsupported platform: %s", *platform) + reader, err := getReader(*platform, *ofAPIKey) + if err != nil { + return fmt.Errorf("getting reader: %w", err) } - reader := factory() - writer := internal.NewAggregatorWriter() selector, err := internal.ParseSelectors(*selectors) @@ -93,3 +86,12 @@ func run(ctx context.Context) error { return nil } + +func getReader(platform string, ofAPIKey string) (internal.RecordReader, error) { + switch platform { + case "trading212": + return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, ofAPIKey)), nil + default: + return nil, fmt.Errorf("unsupported platform: %s", platform) + } +} diff --git a/internal/open_figi.go b/internal/open_figi.go index e5e1e2d..bdad997 100644 --- a/internal/open_figi.go +++ b/internal/open_figi.go @@ -13,9 +13,12 @@ import ( "golang.org/x/time/rate" ) -// OpenFIGI is a small adapter for the openfigi.com api +var OpenFIGIAPIKeyHeader = http.CanonicalHeaderKey("X-OPENFIGI-APIKEY") + +// OpenFIGI is a small adapter for the openfigi.com api. type OpenFIGI struct { client *http.Client + apiKey string mappingLimiter *rate.Limiter mu sync.RWMutex @@ -25,11 +28,18 @@ type OpenFIGI struct { securityTypeCache map[string]string } -func NewOpenFIGI(c *http.Client) *OpenFIGI { - return &OpenFIGI{ - client: c, - mappingLimiter: rate.NewLimiter(rate.Every(time.Minute), 25), // https://www.openfigi.com/api/documentation#rate-limits +// NewOpenFIGI creates an OpenFIGI client that uses the API key if provided +func NewOpenFIGI(c *http.Client, apiKey string) *OpenFIGI { + // Rate limits as per https://www.openfigi.com/api/documentation#rate-limits + limiter := rate.NewLimiter(rate.Every(time.Minute), 25) + if len(apiKey) > 0 { + limiter = rate.NewLimiter(rate.Every(time.Second*6), 25) + } + return &OpenFIGI{ + client: c, + apiKey: apiKey, + mappingLimiter: limiter, securityTypeCache: make(map[string]string), } } @@ -71,6 +81,10 @@ func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string req.Header.Add("Content-Type", "application/json") + if len(of.apiKey) > 0 { + req.Header.Add(OpenFIGIAPIKeyHeader, of.apiKey) + } + err = of.mappingLimiter.Wait(ctx) if err != nil { return "", fmt.Errorf("wait for mapping request capacity: %w", err) diff --git a/internal/open_figi_test.go b/internal/open_figi_test.go index 4d3870a..3b31107 100644 --- a/internal/open_figi_test.go +++ b/internal/open_figi_test.go @@ -110,7 +110,7 @@ func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - of := internal.NewOpenFIGI(tt.client) + of := internal.NewOpenFIGI(tt.client, "") got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin) if gotErr != nil { @@ -145,7 +145,7 @@ func TestOpenFIGI_SecurityTypeByISIN_Cache(t *testing.T) { }, nil }) - of := internal.NewOpenFIGI(c) + of := internal.NewOpenFIGI(c, "") got, gotErr := of.SecurityTypeByISIN(t.Context(), "NL0000235190") if gotErr != nil { @@ -166,6 +166,55 @@ func TestOpenFIGI_SecurityTypeByISIN_Cache(t *testing.T) { } } +func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) { + t.Run("with API key", func(t *testing.T) { + wantAPIKey := "123abc-456xyz" + + c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { + value, ok := req.Header[internal.OpenFIGIAPIKeyHeader] + if !ok { + t.Fatalf("want %q header but got none: %v", internal.OpenFIGIAPIKeyHeader, req.Header) + } + if len(value) != 1 { + t.Fatalf("want exactly one %q header value but got %d", internal.OpenFIGIAPIKeyHeader, len(value)) + } + if value[0] != wantAPIKey { + t.Fatalf("want %q header value %q but got %q", internal.OpenFIGIAPIKeyHeader, wantAPIKey, value[0]) + } + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)), + }, nil + }) + of := internal.NewOpenFIGI(c, wantAPIKey) + + _, err := of.SecurityTypeByISIN(t.Context(), "US1234567890") + if err != nil { + t.Fatalf("want success but got an error: %s", err) + } + }) + + t.Run("without API key", func(t *testing.T) { + c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { + _, ok := req.Header[internal.OpenFIGIAPIKeyHeader] + if ok { + t.Fatalf("want no %s header but got one", internal.OpenFIGIAPIKeyHeader) + } + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)), + }, nil + }) + of := internal.NewOpenFIGI(c, "") + _, err := of.SecurityTypeByISIN(t.Context(), "US1234567890") + if err != nil { + t.Fatalf("want success but got an error: %s", err) + } + }) +} + type RoundTripFunc func(req *http.Request) (*http.Response, error) func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index 9938eec..e47c084 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -223,7 +223,7 @@ func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *internal. }), } - return internal.NewOpenFIGI(c) + return internal.NewOpenFIGI(c, "") } func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI { @@ -236,5 +236,5 @@ func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI { }), } - return internal.NewOpenFIGI(c) + return internal.NewOpenFIGI(c, "") }