This commit is contained in:
Stephan D
2026-02-12 13:39:25 +01:00
parent 7cbcbb4b3c
commit e36fb88a9a
5 changed files with 94 additions and 47 deletions

View File

@@ -39,6 +39,7 @@ func New(logger mlogger.Logger, cfgPath string) (*App, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &App{ return &App{
logger: logger, logger: logger,
cfg: cfg, cfg: cfg,
@@ -50,7 +51,9 @@ func (a *App) Run(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
a.logger.Debug("Metrics server initialised") a.logger.Debug("Metrics server initialised")
defer metricsSrv.Close(context.Background()) //nolint:contextcheck defer metricsSrv.Close(context.Background()) //nolint:contextcheck
conn, err := db.ConnectMongo(a.logger, a.cfg.Database) //nolint:contextcheck conn, err := db.ConnectMongo(a.logger, a.cfg.Database) //nolint:contextcheck
@@ -65,6 +68,7 @@ func (a *App) Run(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
a.logger.Debug("Storage repository initialised") a.logger.Debug("Storage repository initialised")
service, err := ingestor.New(a.logger, a.cfg, repo) service, err := ingestor.New(a.logger, a.cfg, repo)
@@ -95,7 +99,8 @@ func (a *App) Run(ctx context.Context) error {
a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info())) a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info()))
metricsSrv.SetStatus(health.SSRunning) metricsSrv.SetStatus(health.SSRunning)
if err := service.Run(ctx); err != nil { err = service.Run(ctx)
if err != nil {
if !errors.Is(err, context.Canceled) { // ignore termination reques error if !errors.Is(err, context.Canceled) { // ignore termination reques error
a.logger.Error("Ingestor service exited with error", zap.Error(err)) a.logger.Error("Ingestor service exited with error", zap.Error(err))
} }

View File

@@ -15,7 +15,7 @@ import (
const defaultPollInterval = 30 * time.Second const defaultPollInterval = 30 * time.Second
type Config struct { type Config struct {
PollIntervalSeconds int `yaml:"poll_interval_seconds"` PollIntervalSeconds int `yaml:"poll_interval_seconds"` //nolint:tagliatelle // matches config file format
Market MarketConfig `yaml:"market"` Market MarketConfig `yaml:"market"`
Database *db.Config `yaml:"database"` Database *db.Config `yaml:"database"`
Metrics *MetricsConfig `yaml:"metrics"` Metrics *MetricsConfig `yaml:"metrics"`
@@ -25,7 +25,7 @@ type Config struct {
pairsBySource map[mmodel.Driver][]PairConfig pairsBySource map[mmodel.Driver][]PairConfig
} }
//nolint:cyclop //nolint:cyclop,funlen
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
if path == "" { if path == "" {
return nil, merrors.InvalidArgument("config: path is empty") return nil, merrors.InvalidArgument("config: path is empty")
@@ -63,6 +63,7 @@ func Load(path string) (*Config, error) {
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs)) normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
pairsBySource := make(map[mmodel.Driver][]PairConfig, len(cfg.Market.Pairs)) pairsBySource := make(map[mmodel.Driver][]PairConfig, len(cfg.Market.Pairs))
var flattened []Pair var flattened []Pair
for rawSource, pairList := range cfg.Market.Pairs { for rawSource, pairList := range cfg.Market.Pairs {
@@ -72,7 +73,8 @@ func Load(path string) (*Config, error) {
} }
if _, ok := sourceSet[driver]; !ok { if _, ok := sourceSet[driver]; !ok {
return nil, merrors.InvalidArgument("config: pair references unknown source: "+driver.String(), "pairs."+driver.String()) return nil, merrors.InvalidArgument( //nolint:lll
"config: pair references unknown source: "+driver.String(), "pairs."+driver.String())
} }
processed := make([]PairConfig, len(pairList)) processed := make([]PairConfig, len(pairList))
@@ -82,13 +84,16 @@ func Load(path string) (*Config, error) {
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote)) pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
pair.Symbol = strings.TrimSpace(pair.Symbol) pair.Symbol = strings.TrimSpace(pair.Symbol)
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" { if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
return nil, merrors.InvalidArgument("config: pair entries must define base, quote, and symbol", "pairs."+driver.String()) return nil, merrors.InvalidArgument( //nolint:lll
"config: pair entries must define base, quote, and symbol", "pairs."+driver.String())
} }
if strings.TrimSpace(pair.Provider) == "" { if strings.TrimSpace(pair.Provider) == "" {
pair.Provider = strings.ToLower(driver.String()) pair.Provider = strings.ToLower(driver.String())
} }
processed[idx] = pair processed[idx] = pair
flattened = append(flattened, Pair{ flattened = append(flattened, Pair{
PairConfig: pair, PairConfig: pair,
@@ -111,7 +116,7 @@ func Load(path string) (*Config, error) {
if cfg.Metrics != nil && cfg.Metrics.Enabled { if cfg.Metrics != nil && cfg.Metrics.Enabled {
cfg.Metrics.Address = strings.TrimSpace(cfg.Metrics.Address) cfg.Metrics.Address = strings.TrimSpace(cfg.Metrics.Address)
if cfg.Metrics.Address == "" { if cfg.Metrics.Address == "" {
cfg.Metrics.Address = ":9102" //nolint:mnd cfg.Metrics.Address = ":9102"
} }
} }

View File

@@ -42,21 +42,26 @@ const (
) )
const ( const (
defaultDialTimeoutSeconds = 5 * time.Second defaultDialTimeout = 5 * time.Second
defaultDialKeepAliveSeconds = 30 * time.Second defaultDialKeepAlive = 30 * time.Second
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second defaultTLSHandshakeTimeout = 5 * time.Second
defaultResponseHeaderTimeoutSeconds = 10 * time.Second defaultResponseHeaderTimeout = 10 * time.Second
defaultRequestTimeoutSeconds = 10 * time.Second defaultRequestTimeout = 10 * time.Second
) )
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { //nolint:cyclop,ireturn const (
maxSymbolParts = 2
isoCodeLen = 3
)
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { //nolint:cyclop,funlen,nestif,ireturn
baseURL := defaultCBRBaseURL baseURL := defaultCBRBaseURL
provider := strings.ToLower(mmodel.DriverCBR.String()) provider := strings.ToLower(mmodel.DriverCBR.String())
dialTimeout := defaultDialTimeoutSeconds dialTimeout := defaultDialTimeout
dialKeepAlive := defaultDialKeepAliveSeconds dialKeepAlive := defaultDialKeepAlive
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds tlsHandshakeTimeout := defaultTLSHandshakeTimeout
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds responseHeaderTimeout := defaultResponseHeaderTimeout
requestTimeout := defaultRequestTimeoutSeconds requestTimeout := defaultRequestTimeout
directoryPath := defaultDirectoryPath directoryPath := defaultDirectoryPath
dailyPath := defaultDailyPath dailyPath := defaultDailyPath
dynamicPath := defaultDynamicPath dynamicPath := defaultDynamicPath
@@ -79,9 +84,11 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
dynamicPath = strings.TrimSpace(value) dynamicPath = strings.TrimSpace(value)
} }
if value, ok := settings["user_agent"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["user_agent"].(string); ok && strings.TrimSpace(value) != "" {
userAgent = strings.TrimSpace(value) userAgent = strings.TrimSpace(value)
} }
if value, ok := settings["accept_header"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["accept_header"].(string); ok && strings.TrimSpace(value) != "" {
acceptHeader = strings.TrimSpace(value) acceptHeader = strings.TrimSpace(value)
} }
@@ -132,7 +139,8 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
logger: logger.Named("cbr"), logger: logger.Named("cbr"),
} }
if err := connector.refreshDirectory(); err != nil { err = connector.refreshDirectory()
if err != nil {
return nil, err return nil, err
} }
@@ -165,6 +173,7 @@ func (c *cbrConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.
} }
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
return &mmodel.Ticker{ return &mmodel.Ticker{
Symbol: formatSymbol(isoCode, asOfDate), Symbol: formatSymbol(isoCode, asOfDate),
BidPrice: price, BidPrice: price,
@@ -213,7 +222,9 @@ func (c *cbrConnector) refreshDirectory() error {
decoder.CharsetReader = charset.NewReaderLabel decoder.CharsetReader = charset.NewReaderLabel
var directory valuteDirectory var directory valuteDirectory
if err := decoder.Decode(&directory); err != nil {
err = decoder.Decode(&directory)
if err != nil {
c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint)) c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint))
return merrors.InternalWrap(err, "cbr: decode directory") return merrors.InternalWrap(err, "cbr: decode directory")
} }
@@ -226,6 +237,7 @@ func (c *cbrConnector) refreshDirectory() error {
c.byISO = mapping.byISO c.byISO = mapping.byISO
c.byID = mapping.byID c.byID = mapping.byID
return nil return nil
} }
@@ -265,7 +277,9 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
decoder.CharsetReader = charset.NewReaderLabel decoder.CharsetReader = charset.NewReaderLabel
var payload dailyRates var payload dailyRates
if err := decoder.Decode(&payload); err != nil {
err = decoder.Decode(&payload)
if err != nil {
c.logger.Warn("CBR daily decode failed", zap.Error(err), c.logger.Warn("CBR daily decode failed", zap.Error(err),
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint), zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
) )
@@ -284,13 +298,16 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
return computePrice(entry.Value, entry.Nominal) return computePrice(entry.Value, entry.Nominal)
} }
func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInfo, date time.Time) (string, error) { //nolint:funlen func (c *cbrConnector) fetchHistoricalRate( //nolint:funlen
ctx context.Context, valute valuteInfo, date time.Time,
) (string, error) {
query := map[string]string{ query := map[string]string{
"date_req1": date.Format("02/01/2006"), "date_req1": date.Format("02/01/2006"),
"date_req2": date.Format("02/01/2006"), "date_req2": date.Format("02/01/2006"),
"VAL_NM_RQ": valute.ID, "VAL_NM_RQ": valute.ID,
} }
dateStr := date.Format("2006-01-02") dateStr := date.Format("2006-01-02")
endpoint, err := c.buildURL(c.dynamicPath, query) endpoint, err := c.buildURL(c.dynamicPath, query)
if err != nil { if err != nil {
return "", err return "", err
@@ -365,6 +382,7 @@ func (c *cbrConnector) buildURL(path string, query map[string]string) (string, e
if err != nil { if err != nil {
return "", merrors.InternalWrap(err, "cbr: parse base url") return "", merrors.InternalWrap(err, "cbr: parse base url")
} }
base.Path = strings.TrimRight(base.Path, "/") + path base.Path = strings.TrimRight(base.Path, "/") + path
q := base.Query() q := base.Query()
@@ -402,13 +420,13 @@ type valuteMapping struct {
byID map[string]valuteInfo byID map[string]valuteInfo
} }
func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) { //nolint:gocognit,nestif func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) { //nolint:cyclop,gocognit,nestif
byISO := make(map[string]valuteInfo, len(items)) byISO := make(map[string]valuteInfo, len(items))
byID := make(map[string]valuteInfo, len(items)) byID := make(map[string]valuteInfo, len(items))
byNum := make(map[string]string, len(items)) byNum := make(map[string]string, len(items))
for _, item := range items { for _, item := range items {
id := strings.TrimSpace(item.ID) valuteID := strings.TrimSpace(item.ID)
isoChar := strings.ToUpper(strings.TrimSpace(item.ISOChar)) isoChar := strings.ToUpper(strings.TrimSpace(item.ISOChar))
isoNum := strings.TrimSpace(item.ISONum) isoNum := strings.TrimSpace(item.ISONum)
name := strings.TrimSpace(item.Name) name := strings.TrimSpace(item.Name)
@@ -419,17 +437,18 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error()) return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
} }
if id == "" || isoChar == "" { if valuteID == "" || isoChar == "" {
logger.Info("Skipping invalid currency entry", logger.Info("Skipping invalid currency entry",
zap.String("id", id), zap.String("id", valuteID),
zap.String("iso_char", isoChar), zap.String("iso_char", isoChar),
zap.String("name", name), zap.String("name", name),
) )
continue continue
} }
info := valuteInfo{ info := valuteInfo{
ID: id, ID: valuteID,
ISOCharCode: isoChar, ISOCharCode: isoChar,
ISONumCode: isoNum, ISONumCode: isoNum,
Name: name, Name: name,
@@ -440,11 +459,12 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
// Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals). // Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals).
if existing, ok := byISO[isoChar]; ok { if existing, ok := byISO[isoChar]; ok {
// Same ISO + same ID: duplicate entry, just ignore. // Same ISO + same ID: duplicate entry, just ignore.
if existing.ID == id { if existing.ID == valuteID {
logger.Debug("Duplicate directory entry for same ISO and ID, ignoring", logger.Debug("Duplicate directory entry for same ISO and ID, ignoring",
zap.String("iso_code", isoChar), zap.String("iso_code", isoChar),
zap.String("id", id), zap.String("id", valuteID),
) )
continue continue
} }
@@ -486,17 +506,18 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
// Update byID: drop old ID, add new one // Update byID: drop old ID, add new one
delete(byID, existing.ID) delete(byID, existing.ID)
byID[id] = info byID[valuteID] = info
// Update ISO mapping // Update ISO mapping
byISO[isoChar] = info byISO[isoChar] = info
// Update numeric-code index if present // Update numeric-code index if present
if isoNum != "" { if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id { if existingID, ok := byNum[isoNum]; ok && existingID != valuteID {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum) return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
} }
byNum[isoNum] = id
byNum[isoNum] = valuteID
} }
continue continue
@@ -504,23 +525,24 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
// No existing ISO entry, do normal uniqueness checks. // No existing ISO entry, do normal uniqueness checks.
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar { if existing, ok := byID[valuteID]; ok && existing.ISOCharCode != isoChar {
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id) return nil, merrors.InvalidDataType("cbr: duplicate valute id " + valuteID)
} }
if isoNum != "" { if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id { if existingID, ok := byNum[isoNum]; ok && existingID != valuteID {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum) return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
} }
byNum[isoNum] = id
byNum[isoNum] = valuteID
} }
logger.Info("Installing currency code", logger.Info("Installing currency code",
zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal), zap.String("iso_code", isoChar), zap.String("id", valuteID), zap.Int64("nominal", nominal),
) )
byISO[isoChar] = info byISO[isoChar] = info
byID[id] = info byID[valuteID] = info
} }
if len(byISO) == 0 { if len(byISO) == 0 {

View File

@@ -1,4 +1,4 @@
package cbr package cbr //nolint:testpackage
import ( import (
"context" "context"
@@ -14,6 +14,8 @@ import (
) )
func TestFetchTickerDaily(t *testing.T) { func TestFetchTickerDaily(t *testing.T) {
t.Parallel()
transport := &stubRoundTripper{ transport := &stubRoundTripper{
responses: map[string]stubResponse{ responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML}, "/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
@@ -47,10 +49,16 @@ func TestFetchTickerDaily(t *testing.T) {
} }
func TestFetchTickerValidatesDailyEntry(t *testing.T) { func TestFetchTickerValidatesDailyEntry(t *testing.T) {
t.Parallel()
transport := &stubRoundTripper{ transport := &stubRoundTripper{
responses: map[string]stubResponse{ responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML}, "/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
"/scripts/XML_daily.asp": {body: strings.ReplaceAll(dailyRatesXML, "<CharCode>USD</CharCode>", "<CharCode>XXX</CharCode>")}, "/scripts/XML_daily.asp": {
body: strings.ReplaceAll(
dailyRatesXML, "<CharCode>USD</CharCode>", "<CharCode>XXX</CharCode>",
),
},
}, },
} }
@@ -68,6 +76,8 @@ func TestFetchTickerValidatesDailyEntry(t *testing.T) {
} }
func TestFetchTickerHistorical(t *testing.T) { func TestFetchTickerHistorical(t *testing.T) {
t.Parallel()
transport := &stubRoundTripper{ transport := &stubRoundTripper{
responses: map[string]stubResponse{ responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML}, "/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
@@ -75,13 +85,13 @@ func TestFetchTickerHistorical(t *testing.T) {
body: dynamicRatesXML, body: dynamicRatesXML,
check: func(r *http.Request) error { check: func(r *http.Request) error {
if got := r.URL.Query().Get("VAL_NM_RQ"); got != "R01235" { if got := r.URL.Query().Get("VAL_NM_RQ"); got != "R01235" {
return fmt.Errorf("unexpected valute id: %s", got) return fmt.Errorf("unexpected valute id: %s", got) //nolint:err113
} }
if got := r.URL.Query().Get("date_req1"); got != "05/01/2023" { if got := r.URL.Query().Get("date_req1"); got != "05/01/2023" {
return fmt.Errorf("unexpected date_req1: %s", got) return fmt.Errorf("unexpected date_req1: %s", got) //nolint:err113
} }
if got := r.URL.Query().Get("date_req2"); got != "05/01/2023" { if got := r.URL.Query().Get("date_req2"); got != "05/01/2023" {
return fmt.Errorf("unexpected date_req2: %s", got) return fmt.Errorf("unexpected date_req2: %s", got) //nolint:err113
} }
return nil return nil
}, },
@@ -111,6 +121,8 @@ func TestFetchTickerHistorical(t *testing.T) {
} }
func TestFetchTickerUnknownCurrency(t *testing.T) { func TestFetchTickerUnknownCurrency(t *testing.T) {
t.Parallel()
transport := &stubRoundTripper{ transport := &stubRoundTripper{
responses: map[string]stubResponse{ responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML}, "/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
@@ -136,6 +148,8 @@ func TestFetchTickerUnknownCurrency(t *testing.T) {
} }
func TestFetchTickerRespectsCustomPaths(t *testing.T) { func TestFetchTickerRespectsCustomPaths(t *testing.T) {
t.Parallel()
transport := &stubRoundTripper{ transport := &stubRoundTripper{
responses: map[string]stubResponse{ responses: map[string]stubResponse{
"/dir.xml": {body: valuteDirectoryXML}, "/dir.xml": {body: valuteDirectoryXML},
@@ -200,11 +214,11 @@ type stubRoundTripper struct {
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if s.responses == nil { if s.responses == nil {
return nil, fmt.Errorf("no responses configured") return nil, fmt.Errorf("no responses configured") //nolint:err113
} }
res, ok := s.responses[req.URL.Path] res, ok := s.responses[req.URL.Path]
if !ok { if !ok {
return nil, fmt.Errorf("unexpected request path: %s", req.URL.Path) return nil, fmt.Errorf("unexpected request path: %s", req.URL.Path) //nolint:err113
} }
if res.check != nil { if res.check != nil {
if err := res.check(req); err != nil { if err := res.check(req); err != nil {

View File

@@ -41,20 +41,20 @@ func newHTTPClient(logger *zap.Logger, client *http.Client, opts httpClientOptio
if strings.TrimSpace(referer) == "" { if strings.TrimSpace(referer) == "" {
referer = defaultCBRBaseURL referer = defaultCBRBaseURL
} }
l := logger.Named("http_client") httpLogger := logger.Named("http_client")
headers := make(http.Header, 3) headers := make(http.Header, 3)
headers.Set("User-Agent", userAgent) headers.Set("User-Agent", userAgent)
headers.Set("Accept", accept) headers.Set("Accept", accept)
headers.Set("Referer", referer) headers.Set("Referer", referer)
l.Info("HTTP client initialized", zap.String("user_agent", userAgent), httpLogger.Info("HTTP client initialized", zap.String("user_agent", userAgent),
zap.String("accept", accept), zap.String("referrer", referer)) zap.String("accept", accept), zap.String("referrer", referer))
return &httpClient{ return &httpClient{
client: client, client: client,
headers: headers, headers: headers,
logger: l, logger: httpLogger,
} }
} }
@@ -68,6 +68,7 @@ func (h *httpClient) Do(req *http.Request) (*http.Response, error) {
if enriched.Header.Get(key) != "" { if enriched.Header.Get(key) != "" {
continue continue
} }
for _, value := range values { for _, value := range values {
enriched.Header.Add(key, value) enriched.Header.Add(key, value)
} }