127 lines
3.3 KiB
Go
127 lines
3.3 KiB
Go
package internal
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"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
|
|
mappingLimiter *rate.Limiter
|
|
|
|
mu sync.RWMutex
|
|
// TODO: there's no eviction policy at the moment as this is only used by short-lived application
|
|
// which processes a relatively small amount of records. We need to consider using an external
|
|
// cache lib (like golang-lru or go-cache) if this becomes a problem or implement this ourselves.
|
|
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
|
|
|
|
securityTypeCache: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) {
|
|
of.mu.RLock()
|
|
if secType, ok := of.securityTypeCache[isin]; ok {
|
|
of.mu.RUnlock()
|
|
return secType, nil
|
|
}
|
|
of.mu.RUnlock()
|
|
|
|
of.mu.Lock()
|
|
defer of.mu.Unlock()
|
|
|
|
// we check again because there could be more than one concurrent cache miss and we want only one
|
|
// of them to result in an actual request. When the first one releases the lock the following
|
|
// reads will hit the cache.
|
|
if secType, ok := of.securityTypeCache[isin]; ok {
|
|
return secType, nil
|
|
}
|
|
|
|
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,
|
|
}})
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal mapping request body: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openfigi.com/v3/mapping", bytes.NewBuffer(rawBody))
|
|
if err != nil {
|
|
return "", fmt.Errorf("create mapping request: %w", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode >= 400 {
|
|
return "", fmt.Errorf("bad mapping response status code: %s", res.Status)
|
|
}
|
|
|
|
var resBody []mappingResponseBody
|
|
err = json.NewDecoder(res.Body).Decode(&resBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("unmarshal response: %w", err)
|
|
}
|
|
|
|
if len(resBody) == 0 {
|
|
return "", fmt.Errorf("missing top-level elements")
|
|
}
|
|
|
|
if len(resBody[0].Data) == 0 {
|
|
return "", fmt.Errorf("missing data elements")
|
|
}
|
|
|
|
// It is not possible that an isin is assign to different security types, therefore we can assume
|
|
// all entries have the same securityType value.
|
|
secType := resBody[0].Data[0].SecurityType
|
|
if secType == "" {
|
|
return "", fmt.Errorf("empty security type returned for ISIN: %s", isin)
|
|
}
|
|
|
|
of.securityTypeCache[isin] = secType
|
|
|
|
return secType, nil
|
|
}
|
|
|
|
type mappingRequestBody struct {
|
|
IDType string `json:"idType"`
|
|
IDValue string `json:"idValue"`
|
|
}
|
|
|
|
type mappingResponseBody struct {
|
|
Data []struct {
|
|
FIGI string `json:"figi"`
|
|
SecurityType string `json:"securityType"`
|
|
Ticker string `json:"ticker"`
|
|
} `json:"data"`
|
|
}
|