Skip to content

Commit

Permalink
Request Provided Currency Rates (prebid#1753)
Browse files Browse the repository at this point in the history
  • Loading branch information
guscarreon authored and sachin-pubmatic committed Aug 2, 2021
1 parent 9af0a24 commit 07e3fa4
Show file tree
Hide file tree
Showing 47 changed files with 1,368 additions and 95 deletions.
41 changes: 41 additions & 0 deletions currency/aggregate_conversions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package currency

// AggregateConversions contains both the request-defined currency rate
// map found in request.ext.prebid.currency and the currencies conversion
// rates fetched with the RateConverter object defined in rate_converter.go
// It implements the Conversions interface.
type AggregateConversions struct {
customRates, serverRates Conversions
}

// NewAggregateConversions expects both customRates and pbsRates to not be nil
func NewAggregateConversions(customRates, pbsRates Conversions) *AggregateConversions {
return &AggregateConversions{
customRates: customRates,
serverRates: pbsRates,
}
}

// GetRate returns the conversion rate between two currencies prioritizing
// the customRates currency rate over that of the PBS currency rate service
// returns an error if both Conversions objects return error.
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 {
// other error, return the error
return 0, err
}

// because the custom rates' GetRate() call returned an error other than "conversion
// rate not found", there's nothing wrong with the 3 letter currency code so let's
// try the PBS rates instead
return re.serverRates.GetRate(from, to)
}

// GetRates is not implemented for AggregateConversions . There is no need to call
// this function for this scenario.
func (r *AggregateConversions) GetRates() *map[string]map[string]float64 {
return nil
}
89 changes: 89 additions & 0 deletions currency/aggregate_conversions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package currency

import (
"errors"
"testing"
"time"

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

func TestGroupedGetRate(t *testing.T) {

// Setup:
customRates := NewRates(time.Now(), map[string]map[string]float64{
"USD": {
"GBP": 3.00,
"EUR": 2.00,
},
})

pbsRates := NewRates(time.Now(), map[string]map[string]float64{
"USD": {
"GBP": 4.00,
"MXN": 10.00,
},
})
aggregateConversions := NewAggregateConversions(customRates, pbsRates)

// Test cases:
type aTest struct {
desc string
from string
to string
expectedRate float64
}

testGroups := []struct {
expectedError error
testCases []aTest
}{
{
expectedError: nil,
testCases: []aTest{
{"Found in both, return custom rate", "USD", "GBP", 3.00},
{"Found in both, return inverse custom rate", "GBP", "USD", 1 / 3.00},
{"Found in custom rates only", "USD", "EUR", 2.00},
{"Found in PBS rates only", "USD", "MXN", 10.00},
{"Found in PBS rates only, return inverse", "MXN", "USD", 1 / 10.00},
{"Same currency, return unitary rate", "USD", "USD", 1},
},
},
{
expectedError: errors.New("currency: tag is not well-formed"),
testCases: []aTest{
{"From-currency three-digit code malformed", "XX", "EUR", 0},
{"To-currency three-digit code malformed", "GBP", "", 0},
{"Both currencies malformed", "", "", 0},
},
},
{
expectedError: errors.New("currency: tag is not a recognized currency"),
testCases: []aTest{
{"From-currency three-digit code not found", "FOO", "EUR", 0},
{"To-currency three-digit code not found", "GBP", "BAR", 0},
},
},
{
expectedError: ConversionRateNotFound{"GBP", "EUR"},
testCases: []aTest{
{"Valid three-digit currency codes, but conversion rate not found", "GBP", "EUR", 0},
},
},
}

for _, group := range testGroups {
for _, tc := range group.testCases {
// Execute:
rate, err := aggregateConversions.GetRate(tc.from, tc.to)

// Verify:
assert.Equal(t, tc.expectedRate, rate, "conversion rate doesn't match the expected rate: %s\n", tc.desc)
if group.expectedError != nil {
assert.Error(t, err, "error doesn't match expected: %s\n", tc.desc)
} else {
assert.NoError(t, err, "err should be nil: %s\n", tc.desc)
}
}
}
}
4 changes: 1 addition & 3 deletions currency/constant_rates.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package currency

import (
"fmt"

"golang.org/x/text/currency"
)

Expand All @@ -29,7 +27,7 @@ func (r *ConstantRates) GetRate(from string, to string) (float64, error) {
}

if fromUnit.String() != toUnit.String() {
return 0, fmt.Errorf("Constant rates doesn't proceed to any conversions, cannot convert '%s' => '%s'", fromUnit.String(), toUnit.String())
return 0, ConversionRateNotFound{fromUnit.String(), toUnit.String()}
}

return 1, nil
Expand Down
13 changes: 13 additions & 0 deletions currency/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package currency

import "fmt"

// ConversionRateNotFound 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 {
FromCur, ToCur string
}

func (err ConversionRateNotFound) Error() string {
return fmt.Sprintf("Currency conversion rate not found: '%s' => '%s'", err.FromCur, err.ToCur)
}
14 changes: 8 additions & 6 deletions currency/rates.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package currency
import (
"encoding/json"
"errors"
"fmt"
"time"

"golang.org/x/text/currency"
Expand Down Expand Up @@ -45,8 +44,11 @@ func (r *Rates) UnmarshalJSON(b []byte) error {
return nil
}

// GetRate returns the conversion rate between two currencies
// returns an error in case the conversion rate between the two given currencies is not in the currencies rates map
// 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
// given currencies is not in the currencies rates map
func (r *Rates) GetRate(from string, to string) (float64, error) {
var err error
fromUnit, err := currency.ParseISO(from)
Expand All @@ -63,12 +65,12 @@ func (r *Rates) GetRate(from string, to string) (float64, error) {
if r.Conversions != nil {
if conversion, present := r.Conversions[fromUnit.String()][toUnit.String()]; present {
// In case we have an entry FROM -> TO
return conversion, err
return conversion, nil
} else if conversion, present := r.Conversions[toUnit.String()][fromUnit.String()]; present {
// In case we have an entry TO -> FROM
return 1 / conversion, err
return 1 / conversion, nil
}
return 0, fmt.Errorf("Currency conversion rate not found: '%s' => '%s'", fromUnit.String(), toUnit.String())
return 0, ConversionRateNotFound{fromUnit.String(), toUnit.String()}
}
return 0, errors.New("rates are nil")
}
Expand Down
29 changes: 29 additions & 0 deletions endpoints/openrtb2/auction.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/prebid/prebid-server/util/httputil"
"github.com/prebid/prebid-server/util/iputil"
"golang.org/x/net/publicsuffix"
"golang.org/x/text/currency"
)

const storedRequestTimeoutMillis = 50
Expand Down Expand Up @@ -343,6 +344,10 @@ func (deps *endpointDeps) validateRequest(req *openrtb2.BidRequest) []error {
if err := deps.validateEidPermissions(bidExt, aliases); err != nil {
return []error{err}
}

if err := validateCustomRates(bidExt.Prebid.CurrencyConversions); err != nil {
return []error{err}
}
}

if (req.Site == nil && req.App == nil) || (req.Site != nil && req.App != nil) {
Expand Down Expand Up @@ -437,6 +442,30 @@ func validateSChains(req *openrtb_ext.ExtRequest) error {
return err
}

// validateCustomRates throws a bad input error if any of the 3-digit currency codes found in
// the bidRequest.ext.prebid.currency field is invalid, malfomed or does not represent any actual
// currency. No error is thrown if bidRequest.ext.prebid.currency is invalid or empty.
func validateCustomRates(bidReqCurrencyRates *openrtb_ext.ExtRequestCurrency) error {
if bidReqCurrencyRates == nil {
return nil
}

for fromCurrency, rates := range bidReqCurrencyRates.ConversionRates {
// Check if fromCurrency is a valid 3-letter currency code
if _, err := currency.ParseISO(fromCurrency); err != nil {
return &errortypes.BadInput{Message: fmt.Sprintf("currency code %s is not recognized or malformed", fromCurrency)}
}

// Check if currencies mapped to fromCurrency are valid 3-letter currency codes
for toCurrency := range rates {
if _, err := currency.ParseISO(toCurrency); err != nil {
return &errortypes.BadInput{Message: fmt.Sprintf("currency code %s is not recognized or malformed", toCurrency)}
}
}
}
return nil
}

func (deps *endpointDeps) validateEidPermissions(req *openrtb_ext.ExtRequest, aliases map[string]string) error {
if req == nil || req.Prebid.Data == nil {
return nil
Expand Down
Loading

0 comments on commit 07e3fa4

Please sign in to comment.