Skip to content

Commit

Permalink
Currency Conversion Utility Function (prebid#1901)
Browse files Browse the repository at this point in the history
  • Loading branch information
SyntaxNode authored and jizeyopera committed Oct 13, 2021
1 parent 1b17658 commit 83f1a34
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 25 deletions.
20 changes: 20 additions & 0 deletions adapters/bidder.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/mxmCherry/openrtb/v15/openrtb2"
"github.com/prebid/prebid-server/config"
"github.com/prebid/prebid-server/currency"
"github.com/prebid/prebid-server/metrics"
"github.com/prebid/prebid-server/openrtb_ext"
)
Expand Down Expand Up @@ -138,6 +139,25 @@ func (r *RequestData) SetBasicAuth(username string, password string) {
type ExtraRequestInfo struct {
PbsEntryPoint metrics.RequestType
GlobalPrivacyControlHeader string
currencyConversions currency.Conversions
}

func NewExtraRequestInfo(c currency.Conversions) ExtraRequestInfo {
return ExtraRequestInfo{
currencyConversions: c,
}
}

// ConvertCurrency converts a given amount from one currency to another, or returns:
// - Error if the `from` or `to` arguments are malformed or unknown ISO-4217 codes.
// - ConversionNotFoundError if the conversion mapping is unknown to Prebid Server
// and not provided in the bid request.
func (r ExtraRequestInfo) ConvertCurrency(value float64, from, to string) (float64, error) {
if rate, err := r.currencyConversions.GetRate(from, to); err == nil {
return value * rate, nil
} else {
return 0, err
}
}

type Builder func(openrtb_ext.BidderName, config.Adapter) (Bidder, error)
63 changes: 63 additions & 0 deletions adapters/bidder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package adapters

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

func TestExtraRequestInfoConvertCurrency(t *testing.T) {
var (
givenValue float64 = 2
givenFrom string = "AAA"
givenTo string = "BBB"
)

testCases := []struct {
description string
setMock func(m *mock.Mock)
expectedValue float64
expectedError error
}{
{
description: "Success",
setMock: func(m *mock.Mock) { m.On("GetRate", "AAA", "BBB").Return(2.5, nil) },
expectedValue: 5,
expectedError: nil,
},
{
description: "Error",
setMock: func(m *mock.Mock) { m.On("GetRate", "AAA", "BBB").Return(2.5, errors.New("some error")) },
expectedValue: 0,
expectedError: errors.New("some error"),
},
}

for _, test := range testCases {
mockConversions := &mockConversions{}
test.setMock(&mockConversions.Mock)

extraRequestInfo := NewExtraRequestInfo(mockConversions)
result, err := extraRequestInfo.ConvertCurrency(givenValue, givenFrom, givenTo)

mockConversions.AssertExpectations(t)
assert.Equal(t, test.expectedValue, result, test.description+":result")
assert.Equal(t, test.expectedError, err, test.description+":err")
}
}

type mockConversions struct {
mock.Mock
}

func (m mockConversions) GetRate(from string, to string) (float64, error) {
args := m.Called(from, to)
return args.Get(0).(float64), args.Error(1)
}

func (m mockConversions) GetRates() *map[string]map[string]float64 {
args := m.Called()
return args.Get(0).(*map[string]map[string]float64)
}
2 changes: 1 addition & 1 deletion currency/aggregate_conversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func (re *AggregateConversions) GetRate(from string, to string) (float64, error)
rate, err := re.customRates.GetRate(from, to)
if err == nil {
return rate, nil
} else if _, isMissingRateErr := err.(ConversionRateNotFound); !isMissingRateErr {
} else if _, isMissingRateErr := err.(ConversionNotFoundError); !isMissingRateErr {
// other error, return the error
return 0, err
}
Expand Down
2 changes: 1 addition & 1 deletion currency/aggregate_conversions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestGroupedGetRate(t *testing.T) {
},
},
{
expectedError: ConversionRateNotFound{"GBP", "EUR"},
expectedError: ConversionNotFoundError{FromCur: "GBP", ToCur: "EUR"},
testCases: []aTest{
{"Valid three-digit currency codes, but conversion rate not found", "GBP", "EUR", 0},
},
Expand Down
2 changes: 1 addition & 1 deletion currency/constant_rates.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func (r *ConstantRates) GetRate(from string, to string) (float64, error) {
}

if fromUnit.String() != toUnit.String() {
return 0, ConversionRateNotFound{fromUnit.String(), toUnit.String()}
return 0, ConversionNotFoundError{FromCur: fromUnit.String(), ToCur: toUnit.String()}
}

return 1, nil
Expand Down
6 changes: 3 additions & 3 deletions currency/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ package currency

import "fmt"

// ConversionRateNotFound is thrown by the currency.Conversions GetRate(from string, to string) method
// ConversionNotFoundError is thrown by the currency.Conversions GetRate(from string, to string) method
// when the conversion rate between the two currencies, nor its reciprocal, can be found.
type ConversionRateNotFound struct {
type ConversionNotFoundError struct {
FromCur, ToCur string
}

func (err ConversionRateNotFound) Error() string {
func (err ConversionNotFoundError) Error() string {
return fmt.Sprintf("Currency conversion rate not found: '%s' => '%s'", err.FromCur, err.ToCur)
}
6 changes: 3 additions & 3 deletions currency/rates.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ func (r *Rates) UnmarshalJSON(b []byte) error {
// GetRate returns the conversion rate between two currencies or:
// - An error if one of the currency strings is not well-formed
// - An error if any of the currency strings is not a recognized currency code.
// - A MissingConversionRate error in case the conversion rate between the two
// - A ConversionNotFoundError in case the conversion rate between the two
// given currencies is not in the currencies rates map
func (r *Rates) GetRate(from string, to string) (float64, error) {
func (r *Rates) GetRate(from, to string) (float64, error) {
var err error
fromUnit, err := currency.ParseISO(from)
if err != nil {
Expand All @@ -70,7 +70,7 @@ func (r *Rates) GetRate(from string, to string) (float64, error) {
// In case we have an entry TO -> FROM
return 1 / conversion, nil
}
return 0, ConversionRateNotFound{fromUnit.String(), toUnit.String()}
return 0, ConversionNotFoundError{FromCur: fromUnit.String(), ToCur: toUnit.String()}
}
return 0, errors.New("rates are nil")
}
Expand Down
20 changes: 10 additions & 10 deletions exchange/bidder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ func TestMultiCurrencies(t *testing.T) {
{currency: "USD", price: 1.3 * 1.3050530256},
},
expectedBadCurrencyErrors: []error{
currency.ConversionRateNotFound{"JPY", "USD"},
currency.ConversionNotFoundError{FromCur: "JPY", ToCur: "USD"},
},
description: "Case 6 - Bidder respond with a mix of currencies and one unknown on all HTTP responses",
},
Expand All @@ -587,9 +587,9 @@ func TestMultiCurrencies(t *testing.T) {
},
expectedBids: []bid{},
expectedBadCurrencyErrors: []error{
currency.ConversionRateNotFound{"JPY", "USD"},
currency.ConversionRateNotFound{"BZD", "USD"},
currency.ConversionRateNotFound{"DKK", "USD"},
currency.ConversionNotFoundError{FromCur: "JPY", ToCur: "USD"},
currency.ConversionNotFoundError{FromCur: "BZD", ToCur: "USD"},
currency.ConversionNotFoundError{FromCur: "DKK", ToCur: "USD"},
},
description: "Case 7 - Bidder respond with currencies not having any rate on all HTTP responses",
},
Expand Down Expand Up @@ -720,9 +720,9 @@ func TestMultiCurrencies_RateConverterNotSet(t *testing.T) {
bidCurrency: []string{"EUR", "EUR", "EUR"},
expectedBidsCount: 0,
expectedBadCurrencyErrors: []error{
currency.ConversionRateNotFound{"EUR", "USD"},
currency.ConversionRateNotFound{"EUR", "USD"},
currency.ConversionRateNotFound{"EUR", "USD"},
currency.ConversionNotFoundError{FromCur: "EUR", ToCur: "USD"},
currency.ConversionNotFoundError{FromCur: "EUR", ToCur: "USD"},
currency.ConversionNotFoundError{FromCur: "EUR", ToCur: "USD"},
},
description: "Case 2 - Bidder respond with the same currency (not default one) on all HTTP responses",
},
Expand Down Expand Up @@ -754,23 +754,23 @@ func TestMultiCurrencies_RateConverterNotSet(t *testing.T) {
bidCurrency: []string{"EUR", "", "USD"},
expectedBidsCount: 2,
expectedBadCurrencyErrors: []error{
currency.ConversionRateNotFound{"EUR", "USD"},
currency.ConversionNotFoundError{FromCur: "EUR", ToCur: "USD"},
},
description: "Case 7 - Bidder responds with a mix of not set, non default currency and default currency in HTTP responses",
},
{
bidCurrency: []string{"GBP", "", "USD"},
expectedBidsCount: 2,
expectedBadCurrencyErrors: []error{
currency.ConversionRateNotFound{"GBP", "USD"},
currency.ConversionNotFoundError{FromCur: "GBP", ToCur: "USD"},
},
description: "Case 8 - Bidder responds with a mix of not set, non default currency and default currency in HTTP responses",
},
{
bidCurrency: []string{"GBP", "", ""},
expectedBidsCount: 2,
expectedBadCurrencyErrors: []error{
currency.ConversionRateNotFound{"GBP", "USD"},
currency.ConversionNotFoundError{FromCur: "GBP", ToCur: "USD"},
},
description: "Case 9 - Bidder responds with a mix of not set and empty currencies (default currency) in HTTP responses",
},
Expand Down
2 changes: 1 addition & 1 deletion exchange/exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ func (e *exchange) getAllBids(
if givenAdjustment, ok := bidAdjustments[string(bidderRequest.BidderName)]; ok {
adjustmentFactor = givenAdjustment
}
var reqInfo adapters.ExtraRequestInfo
reqInfo := adapters.NewExtraRequestInfo(conversions)
reqInfo.PbsEntryPoint = bidderRequest.BidderLabels.RType
reqInfo.GlobalPrivacyControlHeader = globalPrivacyControlHeader
bids, err := e.adapterMap[bidderRequest.BidderCoreName].requestBid(ctx, bidderRequest.BidRequest, bidderRequest.BidderName, adjustmentFactor, conversions, &reqInfo, accountDebugAllowed, headerDebugAllowed)
Expand Down
94 changes: 89 additions & 5 deletions exchange/exchange_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (

"github.com/buger/jsonparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/yudai/gojsondiff"
"github.com/yudai/gojsondiff/formatter"
)
Expand Down Expand Up @@ -539,7 +540,7 @@ func TestTwoBiddersDebugDisabledAndEnabled(t *testing.T) {

func TestOverrideWithCustomCurrency(t *testing.T) {

mockCurrencyClient := &mockCurrencyRatesClient{
mockCurrencyClient := &fakeCurrencyRatesHttpClient{
responseBody: `{"dataAsOf":"2018-09-12","conversions":{"USD":{"MXN":10.00}}}`,
}
mockCurrencyConverter := currency.NewRateConverter(
Expand Down Expand Up @@ -694,6 +695,72 @@ func TestOverrideWithCustomCurrency(t *testing.T) {
}
}

func TestAdapterCurrency(t *testing.T) {
fakeCurrencyClient := &fakeCurrencyRatesHttpClient{
responseBody: `{"dataAsOf":"2018-09-12","conversions":{"USD":{"MXN":10.00}}}`,
}
currencyConverter := currency.NewRateConverter(
fakeCurrencyClient,
"currency.fake.com",
24*time.Hour,
)
currencyConverter.Run()

// Initialize Mock Bidder
// - Response purposefully causes PBS-Core to stop processing the request, since this test is only
// interested in the call to MakeRequests and nothing after.
mockBidder := &mockBidder{}
mockBidder.On("MakeRequests", mock.Anything, mock.Anything).Return([]*adapters.RequestData(nil), []error(nil))

// Initialize Real Exchange
e := exchange{
cache: &wellBehavedCache{},
me: &metricsConf.DummyMetricsEngine{},
gDPR: gdpr.AlwaysAllow{},
currencyConverter: currencyConverter,
categoriesFetcher: nilCategoryFetcher{},
bidIDGenerator: &mockBidIDGenerator{false, false},
adapterMap: map[openrtb_ext.BidderName]adaptedBidder{
openrtb_ext.BidderName("foo"): adaptBidder(mockBidder, nil, &config.Configuration{}, &metricsConfig.DummyMetricsEngine{}, openrtb_ext.BidderName("foo"), nil),
},
}

// Define Bid Request
request := &openrtb2.BidRequest{
ID: "some-request-id",
Imp: []openrtb2.Imp{{
ID: "some-impression-id",
Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}, {W: 300, H: 600}}},
Ext: json.RawMessage(`{"foo": {"placementId": 1}}`),
}},
Site: &openrtb2.Site{
Page: "prebid.org",
Ext: json.RawMessage(`{"amp":0}`),
},
Cur: []string{"USD"},
Ext: json.RawMessage(`{"prebid": {"currency": {"rates": {"USD": {"MXN": 20.00}}}}}`),
}

// Run Auction
auctionRequest := AuctionRequest{
BidRequest: request,
Account: config.Account{},
UserSyncs: &emptyUsersync{},
}
response, err := e.HoldAuction(context.Background(), auctionRequest, &DebugLog{})
assert.NoError(t, err)
assert.Equal(t, "some-request-id", response.ID, "Response ID")
assert.Empty(t, response.SeatBid, "Response Bids")
assert.Contains(t, string(response.Ext), `"errors":{"foo":[{"code":5,"message":"The adapter failed to generate any bid requests, but also failed to generate an error explaining why"}]}`, "Response Ext")

// Test Currency Converter Properly Passed To Adapter
if assert.NotNil(t, mockBidder.lastExtraRequestInfo, "Currency Conversion Argument") {
converted, err := mockBidder.lastExtraRequestInfo.ConvertCurrency(2.0, "USD", "MXN")
assert.NoError(t, err, "Currency Conversion Error")
assert.Equal(t, 40.0, converted, "Currency Conversion Response")
}
}

func TestGetAuctionCurrencyRates(t *testing.T) {

pbsRates := map[string]map[string]float64{
Expand Down Expand Up @@ -859,7 +926,7 @@ func TestGetAuctionCurrencyRates(t *testing.T) {
}

// Init mock currency conversion service
mockCurrencyClient := &mockCurrencyRatesClient{
mockCurrencyClient := &fakeCurrencyRatesHttpClient{
responseBody: `{"dataAsOf":"2018-09-12","conversions":` + string(jsonPbsRates) + `}`,
}
mockCurrencyConverter := currency.NewRateConverter(
Expand Down Expand Up @@ -3654,15 +3721,32 @@ func (nilCategoryFetcher) FetchCategories(ctx context.Context, primaryAdServer,
return "", nil
}

// mockCurrencyRatesClient is a simple http client mock returning a constant response body
type mockCurrencyRatesClient struct {
// fakeCurrencyRatesHttpClient is a simple http client mock returning a constant response body
type fakeCurrencyRatesHttpClient struct {
responseBody string
}

func (m *mockCurrencyRatesClient) Do(req *http.Request) (*http.Response, error) {
func (m *fakeCurrencyRatesHttpClient) Do(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: "200 OK",
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(strings.NewReader(m.responseBody)),
}, nil
}

type mockBidder struct {
mock.Mock
lastExtraRequestInfo *adapters.ExtraRequestInfo
}

func (m *mockBidder) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) {
m.lastExtraRequestInfo = reqInfo

args := m.Called(request, reqInfo)
return args.Get(0).([]*adapters.RequestData), args.Get(1).([]error)
}

func (m *mockBidder) MakeBids(internalRequest *openrtb2.BidRequest, externalRequest *adapters.RequestData, response *adapters.ResponseData) (*adapters.BidderResponse, []error) {
args := m.Called(internalRequest, externalRequest, response)
return args.Get(0).(*adapters.BidderResponse), args.Get(1).([]error)
}

0 comments on commit 83f1a34

Please sign in to comment.