diff --git a/currencies/constant_rates.go b/currencies/constant_rates.go index bbebd98f0eb..d600b748fc4 100644 --- a/currencies/constant_rates.go +++ b/currencies/constant_rates.go @@ -34,3 +34,8 @@ func (r *ConstantRates) GetRate(from string, to string) (float64, error) { return 1, nil } + +// GetRates returns current rates +func (r *ConstantRates) GetRates() *map[string]map[string]float64 { + return nil +} diff --git a/currencies/converter_info.go b/currencies/converter_info.go new file mode 100644 index 00000000000..6f4fda64c09 --- /dev/null +++ b/currencies/converter_info.go @@ -0,0 +1,45 @@ +package currencies + +import "time" + +// ConverterInfo holds information about converter setup +type ConverterInfo interface { + Source() string + FetchingInterval() time.Duration + LastUpdated() time.Time + Rates() *map[string]map[string]float64 + AdditionalInfo() interface{} +} + +type converterInfo struct { + source string + fetchingInterval time.Duration + lastUpdated time.Time + rates *map[string]map[string]float64 + additionalInfo interface{} +} + +// Source returns converter's URL source +func (ci converterInfo) Source() string { + return ci.source +} + +// FetchingInterval returns converter's fetching interval in nanoseconds +func (ci converterInfo) FetchingInterval() time.Duration { + return ci.fetchingInterval +} + +// LastUpdated returns converter's last updated time +func (ci converterInfo) LastUpdated() time.Time { + return ci.lastUpdated +} + +// Rates returns converter's internal rates +func (ci converterInfo) Rates() *map[string]map[string]float64 { + return ci.rates +} + +// AdditionalInfo returns converter's additional infos +func (ci converterInfo) AdditionalInfo() interface{} { + return ci.additionalInfo +} diff --git a/currencies/rate_converter.go b/currencies/rate_converter.go index ed33a3f6f8f..63f09bd3c2e 100644 --- a/currencies/rate_converter.go +++ b/currencies/rate_converter.go @@ -170,6 +170,16 @@ func (rc *RateConverter) Rates() Conversions { return nil } +// GetInfo returns setup information about the converter +func (rc *RateConverter) GetInfo() ConverterInfo { + return converterInfo{ + source: rc.syncSourceURL, + fetchingInterval: rc.fetchingInterval, + lastUpdated: rc.LastUpdated(), + rates: rc.Rates().GetRates(), + } +} + type httpClient interface { Do(req *http.Request) (*http.Response, error) } @@ -179,4 +189,5 @@ type httpClient interface { // currencies, then an err is returned and rate is 0. type Conversions interface { GetRate(from string, to string) (float64, error) + GetRates() *map[string]map[string]float64 } diff --git a/currencies/rates.go b/currencies/rates.go index 07a28be4187..1126330952a 100644 --- a/currencies/rates.go +++ b/currencies/rates.go @@ -69,3 +69,8 @@ func (r *Rates) GetRate(from string, to string) (float64, error) { } return 0, errors.New("rates are nil") } + +// GetRates returns current rates +func (r *Rates) GetRates() *map[string]map[string]float64 { + return &r.Conversions +} diff --git a/docs/developers/currency-converter.md b/docs/developers/currency-converter.md index cd9c02a23d7..9acb31e7903 100644 --- a/docs/developers/currency-converter.md +++ b/docs/developers/currency-converter.md @@ -48,4 +48,9 @@ This configuration can be changed: | 1 | USD | 1 | NO | 1 | YES | | 1 | EUR | 1.13 | YES | 1.13 | YES | | 1 | EUR | N/A | YES | N/A | NO | -| 1 | EUR | 1.13 | NO | N/A | NO | \ No newline at end of file +| 1 | EUR | 1.13 | NO | N/A | NO | + +## Debug + +A dedicated endpoint will allow you to see what's happening within the currency converter. +See [currency rates endpoint](../endpoints/currency_rates.md) for more details. \ No newline at end of file diff --git a/docs/endpoints/currency_rates.md b/docs/endpoints/currency_rates.md new file mode 100644 index 00000000000..537713b147e --- /dev/null +++ b/docs/endpoints/currency_rates.md @@ -0,0 +1,111 @@ +## `GET /currency/rates` + +This endpoint exposes active currency rate converter information in the server. +Information are: +- `info.active`: true if currency converter is active +- `info.source`: URL from which rates are fetched +- `info.fetchingIntervalNs`: Fetching interval from source in nanoseconds +- `info.lastUpdated`: Datetime when the rates where updated +- `info.rates`: Internal rates values + +### Sample responses +#### Rate converter active +```json +{ + "active": true, + "info": { + "source": "https://cdn.jsdelivr.net/gh/prebid/currency-file@1/latest.json", + "fetchingIntervalNs": 60000000000, + "lastUpdated": "2019-03-02T14:18:41.221063+01:00", + "rates": { + "GBP": { + "AUD": 1.8611576401, + "BGN": 2.2750325703, + "BRL": 5.0061650847, + "CAD": 1.7414619393, + "CHF": 1.3217708915, + "CNY": 8.8791178113, + "CZK": 29.8203982877, + "DKK": 8.6791596873, + "EUR": 1.163223525, + "GBP": 1, + "HKD": 10.3927042621, + "HRK": 8.645077238, + "HUF": 367.6484273218, + "IDR": 18689.5123766983, + "ILS": 4.8077191513, + "INR": 93.8663223525, + "ISK": 158.0820770519, + "JPY": 148.1365159129, + "KRW": 1491.3921459148, + "MXN": 25.5839382096, + "MYR": 5.394332775, + "NOK": 11.3144425833, + "NZD": 1.9374651033, + "PHP": 68.6139028476, + "PLN": 5.0130281035, + "RON": 5.5172855016, + "RUB": 87.2333891681, + "SEK": 12.2141959799, + "SGD": 1.7908989391, + "THB": 42.0074911595, + "TRY": 7.1224176438, + "USD": 1.3240973385, + "ZAR": 18.7774520752 + }, + "USD": { + "AUD": 1.4056048493, + "BGN": 1.7181762277, + "BRL": 3.7808134938, + "CAD": 1.3152068875, + "CHF": 0.9982429939, + "CNY": 6.705789335, + "CZK": 22.5213036985, + "DKK": 6.554774664, + "EUR": 0.8785030308, + "GBP": 0.7552314855, + "HKD": 7.8488974787, + "HRK": 6.5290345252, + "HUF": 277.6596679259, + "IDR": 14114.9081964333, + "ILS": 3.6309408767, + "INR": 70.8908020733, + "ISK": 119.3885618905, + "JPY": 111.8773609769, + "KRW": 1126.3463058948, + "MXN": 19.3217956602, + "MYR": 4.0739699552, + "NOK": 8.5450232803, + "NZD": 1.4632346482, + "PHP": 51.8193797769, + "PLN": 3.7859966617, + "RON": 4.1668277256, + "RUB": 65.8814020908, + "SEK": 9.2245453747, + "SGD": 1.3525432663, + "THB": 31.7253799526, + "TRY": 5.3790740578, + "USD": 1, + "ZAR": 14.1813230256 + } + } + } +} +``` + +#### Rate converter set with constant rates +```json +{ + "active": true, + "source": "", + "fetchingIntervalNs": 0, + "lastUpdated": "0001-01-01T00:00:00Z" +} +``` + +#### Rate converter not set +```json +{ + "active": false +} +``` \ No newline at end of file diff --git a/endpoints/currency_rates.go b/endpoints/currency_rates.go new file mode 100644 index 00000000000..fe1cb980139 --- /dev/null +++ b/endpoints/currency_rates.go @@ -0,0 +1,74 @@ +package endpoints + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/golang/glog" + "github.com/prebid/prebid-server/currencies" +) + +// currencyRatesInfo holds currency rates information. +type currencyRatesInfo struct { + Active bool `json:"active"` + Source *string `json:"source,omitempty"` + FetchingInterval *time.Duration `json:"fetchingIntervalNs,omitempty"` + LastUpdated *time.Time `json:"lastUpdated,omitempty"` + Rates *map[string]map[string]float64 `json:"rates,omitempty"` + AdditionalInfo interface{} `json:"additionalInfo,omitempty"` +} + +type rateConverter interface { + GetInfo() currencies.ConverterInfo +} + +// newCurrencyRatesInfo creates a new CurrencyRatesInfo instance. +func newCurrencyRatesInfo(rateConverter rateConverter) currencyRatesInfo { + + currencyRatesInfo := currencyRatesInfo{ + Active: false, + } + + if rateConverter == nil { + return currencyRatesInfo + } + + currencyRatesInfo.Active = true + + infos := rateConverter.GetInfo() + if infos == nil { + return currencyRatesInfo + } + + source := infos.Source() + currencyRatesInfo.Source = &source + + fetchingInterval := infos.FetchingInterval() + currencyRatesInfo.FetchingInterval = &fetchingInterval + + lastUpdated := infos.LastUpdated() + currencyRatesInfo.LastUpdated = &lastUpdated + + currencyRatesInfo.Rates = infos.Rates() + currencyRatesInfo.AdditionalInfo = infos.AdditionalInfo() + + return currencyRatesInfo +} + +// NewCurrencyRatesEndpoint returns current currency rates applied by the PBS server. +func NewCurrencyRatesEndpoint(rateConverter rateConverter) func(w http.ResponseWriter, r *http.Request) { + currencyRateInfo := newCurrencyRatesInfo(rateConverter) + + return func(w http.ResponseWriter, r *http.Request) { + jsonOutput, err := json.Marshal(currencyRateInfo) + if err != nil { + glog.Errorf("/currency/rates Critical error when trying to marshal currencyRateInfo: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(jsonOutput) + } +} diff --git a/endpoints/currency_rates_test.go b/endpoints/currency_rates_test.go new file mode 100644 index 00000000000..e0b127fcd95 --- /dev/null +++ b/endpoints/currency_rates_test.go @@ -0,0 +1,230 @@ +package endpoints + +import ( + "math/cmplx" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/prebid/prebid-server/currencies" + "github.com/stretchr/testify/assert" +) + +func TestCurrencyRatesEndpoint(t *testing.T) { + // Setup: + var testCases = []struct { + input rateConverter + expectedBody string + expectedCode int + description string + }{ + { + nil, + `{"active": false}`, + http.StatusOK, + "case 1 - rate converter is nil", + }, + { + newRateConverterMock( + 5*time.Minute, + "https://sync.test.com", + time.Date(2019, 3, 2, 12, 54, 56, 651387237, time.UTC), + newConversionMock(&map[string]map[string]float64{ + "USD": { + "USD": 1.0, + }, + }), + ), + `{ + "active": true, + "source": "https://sync.test.com", + "fetchingIntervalNs": 300000000000, + "lastUpdated": "2019-03-02T12:54:56.651387237Z", + "rates": { + "USD": { + "USD": 1 + } + } + }`, + http.StatusOK, + "case 2 - rate converter is set and has some rates", + }, + { + newRateConverterMock( + time.Duration(0), + "", + time.Time{}, + nil, + ), + `{ + "active": true, + "source": "", + "fetchingIntervalNs": 0, + "lastUpdated": "0001-01-01T00:00:00Z" + }`, + http.StatusOK, + "case 3 - rate converter is set and doesn't have any rates set", + }, + { + newRateConverterMockWithInfo( + newUnmarshableConverterInfoMock(), + ), + "", + http.StatusInternalServerError, + "case 4 - invalid rates input for marshaling", + }, + { + newRateConverterMockWithNilInfo(), + `{ + "active": true + }`, + http.StatusOK, + "case 5 - rate converter is set but returns nil Infos", + }, + } + + for _, tc := range testCases { + + handler := NewCurrencyRatesEndpoint(tc.input) + w := httptest.NewRecorder() + + // Execute: + handler(w, nil) + + // Verify: + assert.Equal(t, tc.expectedCode, w.Code, tc.description) + if tc.expectedBody != "" { + assert.JSONEq(t, tc.expectedBody, w.Body.String(), tc.description) + } else { + assert.Equal(t, tc.expectedBody, w.Body.String(), tc.description) + } + } +} + +type conversionMock struct { + rates *map[string]map[string]float64 +} + +func (m conversionMock) GetRates() *map[string]map[string]float64 { + return m.rates +} + +func newConversionMock(rates *map[string]map[string]float64) *conversionMock { + return &conversionMock{ + rates: rates, + } +} + +type converterInfoMock struct { + source string + fetchingInterval time.Duration + lastUpdated time.Time + rates *map[string]map[string]float64 + additionalInfo interface{} +} + +func (m converterInfoMock) Source() string { + return m.source +} + +func (m converterInfoMock) FetchingInterval() time.Duration { + return m.fetchingInterval +} + +func (m converterInfoMock) LastUpdated() time.Time { + return m.lastUpdated +} + +func (m converterInfoMock) Rates() *map[string]map[string]float64 { + return m.rates +} + +func (m converterInfoMock) AdditionalInfo() interface{} { + return m.additionalInfo +} + +type unmarshableConverterInfoMock struct{} + +func (m unmarshableConverterInfoMock) Source() string { + return "" +} + +func (m unmarshableConverterInfoMock) FetchingInterval() time.Duration { + return time.Duration(0) +} + +func (m unmarshableConverterInfoMock) LastUpdated() time.Time { + return time.Time{} +} + +func (m unmarshableConverterInfoMock) Rates() *map[string]map[string]float64 { + return nil +} + +func (m unmarshableConverterInfoMock) AdditionalInfo() interface{} { + cmplx.Sqrt(-5 + 12i) + return cmplx.Sqrt(-5 + 12i) +} + +func newUnmarshableConverterInfoMock() unmarshableConverterInfoMock { + return unmarshableConverterInfoMock{} +} + +type rateConverterMock struct { + fetchingInterval time.Duration + syncSourceURL string + rates *conversionMock + lastUpdated time.Time + rateConverterInfos currencies.ConverterInfo + shouldReturnNilInfo bool +} + +func (m rateConverterMock) GetInfo() currencies.ConverterInfo { + + if m.shouldReturnNilInfo { + return nil + } + + if m.rateConverterInfos != nil { + return m.rateConverterInfos + } + + var rates *map[string]map[string]float64 + if m.rates == nil { + rates = nil + } else { + rates = m.rates.GetRates() + } + return converterInfoMock{ + source: m.syncSourceURL, + fetchingInterval: m.fetchingInterval, + lastUpdated: m.lastUpdated, + rates: rates, + } +} + +func newRateConverterMock( + fetchingInterval time.Duration, + syncSourceURL string, + lastUpdated time.Time, + rates *conversionMock) rateConverterMock { + return rateConverterMock{ + fetchingInterval: fetchingInterval, + syncSourceURL: syncSourceURL, + rates: rates, + lastUpdated: lastUpdated, + } +} + +func newRateConverterMockWithInfo(rateConverterInfos currencies.ConverterInfo) rateConverterMock { + return rateConverterMock{ + rateConverterInfos: rateConverterInfos, + } +} + +func newRateConverterMockWithNilInfo() rateConverterMock { + return rateConverterMock{ + shouldReturnNilInfo: true, + } +} diff --git a/pbs_light.go b/pbs_light.go index eceb9543cf8..dbe306801bd 100644 --- a/pbs_light.go +++ b/pbs_light.go @@ -3,9 +3,11 @@ package main import ( "flag" "math/rand" + "net/http" "time" "github.com/prebid/prebid-server/config" + "github.com/prebid/prebid-server/currencies" pbc "github.com/prebid/prebid-server/prebid_cache_client" "github.com/prebid/prebid-server/router" "github.com/prebid/prebid-server/server" @@ -40,7 +42,10 @@ func main() { } func serve(revision string, cfg *config.Configuration) error { - r, err := router.New(cfg) + + currencyConverter := currencies.NewRateConverter(&http.Client{}, cfg.CurrencyConverter.FetchURL, time.Duration(cfg.CurrencyConverter.FetchIntervalSeconds)*time.Second) + + r, err := router.New(cfg, currencyConverter) if err != nil { return err } @@ -48,7 +53,7 @@ func serve(revision string, cfg *config.Configuration) error { pbc.InitPrebidCache(cfg.CacheURL.GetBaseURL()) // Add cors support corsRouter := router.SupportCORS(r) - server.Listen(cfg, router.NoCache{Handler: corsRouter}, router.Admin(revision), r.MetricsEngine) + server.Listen(cfg, router.NoCache{Handler: corsRouter}, router.Admin(revision, currencyConverter), r.MetricsEngine) r.Shutdown() return nil } diff --git a/router/admin.go b/router/admin.go index 1f6e0519431..83c4701bb19 100644 --- a/router/admin.go +++ b/router/admin.go @@ -4,10 +4,11 @@ import ( "net/http" "net/http/pprof" + "github.com/prebid/prebid-server/currencies" "github.com/prebid/prebid-server/endpoints" ) -func Admin(revision string) *http.ServeMux { +func Admin(revision string, rateConverter *currencies.RateConverter) *http.ServeMux { // Add endpoints to the admin server // Making sure to add pprof routes mux := http.NewServeMux() @@ -18,6 +19,7 @@ func Admin(revision string) *http.ServeMux { mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) mux.HandleFunc("/debug/pprof/trace", pprof.Trace) // Register prebid-server defined admin handlers + mux.HandleFunc("/currency/rates", endpoints.NewCurrencyRatesEndpoint(rateConverter)) mux.HandleFunc("/version", endpoints.NewVersionEndpoint(revision)) return mux } diff --git a/router/router.go b/router/router.go index 935fb3305d5..a10ce417a7f 100644 --- a/router/router.go +++ b/router/router.go @@ -168,7 +168,7 @@ type Router struct { Shutdown func() } -func New(cfg *config.Configuration) (r *Router, err error) { +func New(cfg *config.Configuration, rateConvertor *currencies.RateConverter) (r *Router, err error) { const schemaDirectory = "./static/bidder-params" const infoDirectory = "./static/bidder-info" @@ -216,10 +216,9 @@ func New(cfg *config.Configuration) (r *Router, err error) { syncers := usersyncers.NewSyncerMap(cfg) gdprPerms := gdpr.NewPermissions(context.Background(), cfg.GDPR, adapters.GDPRAwareSyncerIDs(syncers), theClient) - currencyConverter := currencies.NewRateConverter(theClient, cfg.CurrencyConverter.FetchURL, time.Duration(cfg.CurrencyConverter.FetchIntervalSeconds)*time.Second) exchanges = newExchangeMap(cfg) - theExchange := exchange.NewExchange(theClient, pbc.NewClient(&cfg.CacheURL), cfg, r.MetricsEngine, bidderInfos, gdprPerms, currencyConverter) + theExchange := exchange.NewExchange(theClient, pbc.NewClient(&cfg.CacheURL), cfg, r.MetricsEngine, bidderInfos, gdprPerms, rateConvertor) openrtbEndpoint, err := openrtb2.NewEndpoint(theExchange, paramsValidator, fetcher, cfg, r.MetricsEngine, pbsAnalytics, disabledBidders, defReqJSON, bidderMap)