Repository: prebid/prebid-server
Pull request #1444 (2020-08-18 20:13:20)

Added adpod_id to request extension
- Added adpod_id to request -> ext -> appnexus 
- Modified requests splitting based on pod id

VeronikaSolovei9: `adapters/appnexus/appnexusplatformtest/video/simple-video.json` was removed because it has a random value in it unit test fails. I was not able to configure expected result properly, it’s a deepEqual under the hood and I haven’t found a way to pass reg exp (or override unmarshaller)
SyntaxNode: We'll also need to update the bidder params file in this repo and in the https://github.com/prebid/prebid.github.io repo. We've recently changed the doc website to use hardcoded values in that repo instead of dynamically hitting the Xandr servers.
hhhjort: > We'll also need to update the bidder params file in this repo and in the https://github.com/prebid/prebid.github.io repo. We've recently changed the doc website to use hardcoded values in that repo instead of dynamically hitting the Xandr servers.

I don't think we do actually. This changes the parameters PBS sends to Appnexus, but does not change the parameters the Appnexus adapter expects publishers to send to it.
SyntaxNode: > I don't think we do actually. This changes the parameters PBS sends to Appnexus, but does not change the parameters the Appnexus adapter expects publishers to send to it.

Ah, yes. Missed that the request ext we're changing here is outgoing only. 👍 

Patch:
diff --git a/adapters/appnexus/appnexus.go b/adapters/appnexus/appnexus.go
index 9bec9bf1..334817eb 100644
--- a/adapters/appnexus/appnexus.go
+++ b/adapters/appnexus/appnexus.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
+	"math/rand"
 	"net/http"
 	"strconv"
 	"strings"
@@ -95,10 +96,11 @@ type appnexusBidExt struct {
 }
 
 type appnexusReqExtAppnexus struct {
-	IncludeBrandCategory    *bool `json:"include_brand_category,omitempty"`
-	BrandCategoryUniqueness *bool `json:"brand_category_uniqueness,omitempty"`
-	IsAMP                   int   `json:"is_amp,omitempty"`
-	HeaderBiddingSource     int   `json:"hb_source,omitempty"`
+	IncludeBrandCategory    *bool  `json:"include_brand_category,omitempty"`
+	BrandCategoryUniqueness *bool  `json:"brand_category_uniqueness,omitempty"`
+	IsAMP                   int    `json:"is_amp,omitempty"`
+	HeaderBiddingSource     int    `json:"hb_source,omitempty"`
+	AdPodId                 string `json:"adpod_id,omitempty"`
 }
 
 // Full request extension including appnexus extension object
@@ -354,14 +356,56 @@ func (a *AppNexusAdapter) MakeRequests(request *openrtb.BidRequest, reqInfo *ada
 	}
 	reqExt.Appnexus.IsAMP = isAMP
 	reqExt.Appnexus.HeaderBiddingSource = a.hbSource + isVIDEO
+
+	imps := request.Imp
+
+	// For long form requests adpod_id must be sent downstream.
+	// Adpod id is a unique identifier for pod
+	// All impressions in the same pod must have the same pod id in request extension
+	// For this all impressions in  request should belong to the same pod
+	// If impressions number per pod is more than maxImpsPerReq - divide those imps to several requests but keep pod id the same
+	if isVIDEO == 1 {
+		podImps := groupByPods(imps)
+
+		requests := make([]*adapters.RequestData, 0, len(podImps))
+		for _, podImps := range podImps {
+			reqExt.Appnexus.AdPodId = generatePodId()
+
+			reqs, errors := splitRequests(podImps, request, reqExt, thisURI, errs)
+			requests = append(requests, reqs...)
+			errs = append(errs, errors...)
+		}
+		return requests, errs
+	}
+
+	return splitRequests(imps, request, reqExt, thisURI, errs)
+}
+
+func generatePodId() string {
+	val := rand.Int63()
+	return fmt.Sprint(val)
+}
+
+func groupByPods(imps []openrtb.Imp) map[string]([]openrtb.Imp) {
+	// find number of pods in response
+	podImps := make(map[string][]openrtb.Imp)
+	for _, imp := range imps {
+		pod := strings.Split(imp.ID, "_")[0]
+		podImps[pod] = append(podImps[pod], imp)
+	}
+	return podImps
+}
+
+func marshalAndSetRequestExt(request *openrtb.BidRequest, requestExtension appnexusReqExt, errs []error) {
 	var err error
-	request.Ext, err = json.Marshal(reqExt)
+	request.Ext, err = json.Marshal(requestExtension)
 	if err != nil {
 		errs = append(errs, err)
-		return nil, errs
 	}
+}
+
+func splitRequests(imps []openrtb.Imp, request *openrtb.BidRequest, requestExtension appnexusReqExt, uri string, errs []error) ([]*adapters.RequestData, []error) {
 
-	imps := request.Imp
 	// Initial capacity for future array of requests, memory optimization.
 	// Let's say there are 35 impressions and limit impressions per request equals to 10.
 	// In this case we need to create 4 requests with 10, 10, 10 and 5 impressions.
@@ -375,6 +419,8 @@ func (a *AppNexusAdapter) MakeRequests(request *openrtb.BidRequest, reqInfo *ada
 	headers.Add("Content-Type", "application/json;charset=utf-8")
 	headers.Add("Accept", "application/json")
 
+	marshalAndSetRequestExt(request, requestExtension, errs)
+
 	for impsLeft {
 
 		endInd := startInd + maxImpsPerReq
@@ -393,7 +439,7 @@ func (a *AppNexusAdapter) MakeRequests(request *openrtb.BidRequest, reqInfo *ada
 
 		resArr = append(resArr, &adapters.RequestData{
 			Method:  "POST",
-			Uri:     thisURI,
+			Uri:     uri,
 			Body:    reqJSON,
 			Headers: headers,
 		})
diff --git a/config/config.go b/config/config.go
index 7fc77855..e3b7d8eb 100755
--- a/config/config.go
+++ b/config/config.go
@@ -2,6 +2,7 @@ package config
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"net/url"
 	"reflect"
@@ -111,6 +112,7 @@ func (cfg *Configuration) validate() configErrors {
 	errs = cfg.CurrencyConverter.validate(errs)
 	errs = validateAdapters(cfg.Adapters, errs)
 	errs = cfg.Debug.validate(errs)
+	errs = cfg.ExtCacheURL.validate(errs)
 	return errs
 }
 
@@ -128,6 +130,40 @@ func (cfg *AuctionTimeouts) validate(errs configErrors) configErrors {
 	return errs
 }
 
+func (data *ExternalCache) validate(errs configErrors) configErrors {
+	if data.Host == "" && data.Path == "" {
+		// Both host and path can be blank. No further validation needed
+		return errs
+	}
+
+	// Either host or path or both not empty, validate.
+	if data.Host == "" && data.Path != "" || data.Host != "" && data.Path == "" {
+		return append(errs, errors.New("External cache Host and Path must both be specified"))
+	}
+	if strings.HasSuffix(data.Host, "/") {
+		return append(errs, errors.New(fmt.Sprintf("External cache Host '%s' must not end with a path separator", data.Host)))
+	}
+	if strings.ContainsAny(data.Host, "://") {
+		return append(errs, errors.New(fmt.Sprintf("External cache Host must not specify a protocol. '%s'", data.Host)))
+	}
+	if !strings.HasPrefix(data.Path, "/") {
+		return append(errs, errors.New(fmt.Sprintf("External cache Path '%s' must begin with a path separator", data.Path)))
+	}
+
+	urlObj, err := url.Parse("https://" + data.Host + data.Path)
+	if err != nil {
+		return append(errs, errors.New(fmt.Sprintf("External cache Path validation error: %s ", err.Error())))
+	}
+	if urlObj.Host != data.Host {
+		return append(errs, errors.New(fmt.Sprintf("External cache Host '%s' is invalid", data.Host)))
+	}
+	if urlObj.Path != data.Path {
+		return append(errs, errors.New("External cache Path is invalid"))
+	}
+
+	return errs
+}
+
 // LimitAuctionTimeout returns the min of requested or cfg.MaxAuctionTimeout.
 // Both values treat "0" as "infinite".
 func (cfg *AuctionTimeouts) LimitAuctionTimeout(requested time.Duration) time.Duration {
@@ -159,6 +195,11 @@ type GDPR struct {
 	TCF1                    TCF1 `mapstructure:"tcf1"`
 	TCF2                    TCF2 `mapstructure:"tcf2"`
 	AMPException            bool `mapstructure:"amp_exception"`
+	// EEACountries (EEA = European Economic Area) are a list of countries where we should assume GDPR applies.
+	// If the gdpr flag is unset in a request, but geo.country is set, we will assume GDPR applies if and only
+	// if the country matches one on this list. If both the GDPR flag and country are not set, we default
+	// to UsersyncIfAmbiguous
+	EEACountries []string `mapstructure:"eea_countries"`
 }
 
 func (cfg *GDPR) validate(errs configErrors) configErrors {
@@ -903,6 +944,10 @@ func SetupViper(v *viper.Viper, filename string) {
 	v.SetDefault("gdpr.tcf2.purpose_one_treatement.enabled", true)
 	v.SetDefault("gdpr.tcf2.purpose_one_treatement.access_allowed", true)
 	v.SetDefault("gdpr.amp_exception", false)
+	v.SetDefault("gdpr.eea_countries", []string{"ALA", "AUT", "BEL", "BGR", "HRV", "CYP", "CZE", "DNK", "EST",
+		"FIN", "FRA", "GUF", "DEU", "GIB", "GRC", "GLP", "GGY", "HUN", "ISL", "IRL", "IMN", "ITA", "JEY", "LVA",
+		"LIE", "LTU", "LUX", "MLT", "MTQ", "MYT", "NLD", "NOR", "POL", "PRT", "REU", "ROU", "BLM", "MAF", "SPM",
+		"SVK", "SVN", "ESP", "SWE", "GBR"})
 	v.SetDefault("ccpa.enforce", false)
 	v.SetDefault("lmt.enforce", true)
 	v.SetDefault("currency_converter.fetch_url", "https://cdn.jsdelivr.net/gh/prebid/currency-file@1/latest.json")
diff --git a/exchange/exchange.go b/exchange/exchange.go
index cf5ec9cc..e465a783 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -55,6 +55,7 @@ type exchange struct {
 	UsersyncIfAmbiguous bool
 	defaultTTLs         config.DefaultTTLs
 	privacyConfig       config.Privacy
+	eeaCountries        map[string]struct{}
 }
 
 // Container to pass out response ext data from the GetAllBids goroutines back into the main thread
@@ -75,6 +76,11 @@ type bidResponseWrapper struct {
 func NewExchange(client *http.Client, cache prebid_cache_client.Client, cfg *config.Configuration, metricsEngine pbsmetrics.MetricsEngine, infos adapters.BidderInfos, gDPR gdpr.Permissions, currencyConverter *currencies.RateConverter) Exchange {
 	e := new(exchange)
 
+	e.eeaCountries = make(map[string]struct{}, len(cfg.GDPR.EEACountries))
+	var s struct{}
+	for _, c := range cfg.GDPR.EEACountries {
+		e.eeaCountries[c] = s
+	}
 	e.adapterMap = newAdapterMap(client, cfg, infos, metricsEngine)
 	e.cache = cache
 	e.cacheTime = time.Duration(cfg.CacheURL.ExpectedTimeMillis) * time.Millisecond
@@ -121,9 +127,27 @@ func (e *exchange) HoldAuction(ctx context.Context, bidRequest *openrtb.BidReque
 		e.me.RecordImps(impLabels)
 	}
 
+	// Make our best guess if GDPR applies
+	usersyncIfAmbiguous := e.UsersyncIfAmbiguous
+	var geo *openrtb.Geo = nil
+	if bidRequest.User != nil && bidRequest.User.Geo != nil {
+		geo = bidRequest.User.Geo
+	} else if bidRequest.Device != nil && bidRequest.Device.Geo != nil {
+		geo = bidRequest.Device.Geo
+	}
+	if geo != nil {
+		// If we have a country set, and it is on the list, we assume GDPR applies if not set on the request.
+		// Otherwise we assume it does not apply as long as it appears "valid" (is 3 characters long).
+		if _, found := e.eeaCountries[strings.ToUpper(geo.Country)]; found {
+			usersyncIfAmbiguous = false
+		} else if len(geo.Country) == 3 {
+			// The country field is formatted properly as a three character country code
+			usersyncIfAmbiguous = true
+		}
+	}
 	// Slice of BidRequests, each a copy of the original cleaned to only contain bidder data for the named bidder
 	blabels := make(map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels)
-	cleanRequests, aliases, privacyLabels, errs := cleanOpenRTBRequests(ctx, bidRequest, requestExt, usersyncs, blabels, labels, e.gDPR, e.UsersyncIfAmbiguous, e.privacyConfig)
+	cleanRequests, aliases, privacyLabels, errs := cleanOpenRTBRequests(ctx, bidRequest, requestExt, usersyncs, blabels, labels, e.gDPR, usersyncIfAmbiguous, e.privacyConfig)
 
 	e.me.RecordRequestPrivacy(privacyLabels)
 
diff --git a/gdpr/impl.go b/gdpr/impl.go
index 2deddc7b..2fbd9c5a 100644
--- a/gdpr/impl.go
+++ b/gdpr/impl.go
@@ -228,3 +228,22 @@ func (a AlwaysAllow) PersonalInfoAllowed(ctx context.Context, bidder openrtb_ext
 func (a AlwaysAllow) AMPException() bool {
 	return false
 }
+
+// Exporting to allow for easy test setups
+type AlwaysFail struct{}
+
+func (a AlwaysFail) HostCookiesAllowed(ctx context.Context, consent string) (bool, error) {
+	return false, nil
+}
+
+func (a AlwaysFail) BidderSyncAllowed(ctx context.Context, bidder openrtb_ext.BidderName, consent string) (bool, error) {
+	return false, nil
+}
+
+func (a AlwaysFail) PersonalInfoAllowed(ctx context.Context, bidder openrtb_ext.BidderName, PublisherID string, consent string) (bool, bool, bool, error) {
+	return false, false, false, nil
+}
+
+func (a AlwaysFail) AMPException() bool {
+	return false
+}


Test patch:
diff --git a/adapters/appnexus/appnexus_test.go b/adapters/appnexus/appnexus_test.go
index bf493749..26380406 100644
--- a/adapters/appnexus/appnexus_test.go
+++ b/adapters/appnexus/appnexus_test.go
@@ -4,9 +4,11 @@ import (
 	"bytes"
 	"context"
 	"encoding/json"
+	"github.com/stretchr/testify/assert"
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
+	"regexp"
 	"testing"
 	"time"
 
@@ -38,6 +40,233 @@ func TestMemberQueryParam(t *testing.T) {
 	}
 }
 
+func TestVideoSinglePod(t *testing.T) {
+	var a AppNexusAdapter
+	a.URI = "http://test.com/openrtb2"
+	a.hbSource = 5
+
+	var reqInfo adapters.ExtraRequestInfo
+	reqInfo.PbsEntryPoint = "video"
+
+	var req openrtb.BidRequest
+	req.ID = "test_id"
+
+	reqExt := `{"prebid":{}}`
+	impExt := `{"bidder":{"placementId":123}}`
+	req.Ext = []byte(reqExt)
+
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_0", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_1", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_2", Ext: []byte(impExt)})
+
+	result, err := a.MakeRequests(&req, &reqInfo)
+
+	assert.Empty(t, err, "Errors array should be empty")
+	assert.Len(t, result, 1, "Only one request should be returned")
+
+	var error error
+	var reqData *openrtb.BidRequest
+	error = json.Unmarshal(result[0].Body, &reqData)
+	assert.NoError(t, error, "Response body unmarshalling error should be nil")
+
+	var reqDataExt *appnexusReqExt
+	error = json.Unmarshal(reqData.Ext, &reqDataExt)
+	assert.NoError(t, error, "Response ext unmarshalling error should be nil")
+
+	regMatch, matchErr := regexp.Match(`[0-9]19`, []byte(reqDataExt.Appnexus.AdPodId))
+	assert.NoError(t, matchErr, "Regex match error should be nil")
+	assert.True(t, regMatch, "AdPod id doesn't present in Appnexus extension or has incorrect format")
+}
+
+func TestVideoSinglePodManyImps(t *testing.T) {
+	var a AppNexusAdapter
+	a.URI = "http://test.com/openrtb2"
+	a.hbSource = 5
+
+	var reqInfo adapters.ExtraRequestInfo
+	reqInfo.PbsEntryPoint = "video"
+
+	var req openrtb.BidRequest
+	req.ID = "test_id"
+
+	reqExt := `{"prebid":{}}`
+	impExt := `{"bidder":{"placementId":123}}`
+	req.Ext = []byte(reqExt)
+
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_0", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_1", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_2", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_3", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_4", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_5", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_6", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_7", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_8", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_9", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_10", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_11", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_12", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_13", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_14", Ext: []byte(impExt)})
+
+	res, err := a.MakeRequests(&req, &reqInfo)
+
+	assert.Empty(t, err, "Errors array should be empty")
+	assert.Len(t, res, 2, "Two requests should be returned")
+
+	var error error
+	var reqData1 *openrtb.BidRequest
+	error = json.Unmarshal(res[0].Body, &reqData1)
+	assert.NoError(t, error, "Response body unmarshalling error should be nil")
+
+	var reqDataExt1 *appnexusReqExt
+	error = json.Unmarshal(reqData1.Ext, &reqDataExt1)
+	assert.NoError(t, error, "Response ext unmarshalling error should be nil")
+
+	adPodId1 := reqDataExt1.Appnexus.AdPodId
+
+	var reqData2 *openrtb.BidRequest
+	error = json.Unmarshal(res[1].Body, &reqData2)
+	assert.NoError(t, error, "Response body unmarshalling error should be nil")
+
+	var reqDataExt2 *appnexusReqExt
+	error = json.Unmarshal(reqData2.Ext, &reqDataExt2)
+	assert.NoError(t, error, "Response ext unmarshalling error should be nil")
+
+	adPodId2 := reqDataExt2.Appnexus.AdPodId
+
+	assert.Equal(t, adPodId1, adPodId2, "AdPod id is not the same for the same pod")
+}
+
+func TestVideoTwoPods(t *testing.T) {
+	var a AppNexusAdapter
+	a.URI = "http://test.com/openrtb2"
+	a.hbSource = 5
+
+	var reqInfo adapters.ExtraRequestInfo
+	reqInfo.PbsEntryPoint = "video"
+
+	var req openrtb.BidRequest
+	req.ID = "test_id"
+
+	reqExt := `{"prebid":{}}`
+	impExt := `{"bidder":{"placementId":123}}`
+	req.Ext = []byte(reqExt)
+
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_0", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_1", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_2", Ext: []byte(impExt)})
+
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_0", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_1", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_2", Ext: []byte(impExt)})
+
+	res, err := a.MakeRequests(&req, &reqInfo)
+
+	assert.Empty(t, err, "Errors array should be empty")
+	assert.Len(t, res, 2, "Two request should be returned")
+
+	var error error
+	var reqData1 *openrtb.BidRequest
+	error = json.Unmarshal(res[0].Body, &reqData1)
+	assert.NoError(t, error, "Response body unmarshalling error should be nil")
+
+	var reqDataExt1 *appnexusReqExt
+	error = json.Unmarshal(reqData1.Ext, &reqDataExt1)
+	assert.NoError(t, error, "Response ext unmarshalling error should be nil")
+
+	adPodId1 := reqDataExt1.Appnexus.AdPodId
+
+	var reqData2 *openrtb.BidRequest
+	error = json.Unmarshal(res[1].Body, &reqData2)
+	assert.NoError(t, error, "Response body unmarshalling error should be nil")
+
+	var reqDataExt2 *appnexusReqExt
+	error = json.Unmarshal(reqData2.Ext, &reqDataExt2)
+	assert.NoError(t, error, "Response ext unmarshalling error should be nil")
+
+	adPodId2 := reqDataExt2.Appnexus.AdPodId
+
+	assert.NotEqual(t, adPodId1, adPodId2, "AdPod id should be different for different pods")
+}
+
+func TestVideoTwoPodsManyImps(t *testing.T) {
+	var a AppNexusAdapter
+	a.URI = "http://test.com/openrtb2"
+	a.hbSource = 5
+
+	var reqInfo adapters.ExtraRequestInfo
+	reqInfo.PbsEntryPoint = "video"
+
+	var req openrtb.BidRequest
+	req.ID = "test_id"
+
+	reqExt := `{"prebid":{}}`
+	impExt := `{"bidder":{"placementId":123}}`
+	req.Ext = []byte(reqExt)
+
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_0", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_1", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "1_2", Ext: []byte(impExt)})
+
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_0", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_1", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_2", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_3", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_4", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_5", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_6", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_7", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_8", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_9", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_10", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_11", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_12", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_13", Ext: []byte(impExt)})
+	req.Imp = append(req.Imp, openrtb.Imp{ID: "2_14", Ext: []byte(impExt)})
+
+	res, err := a.MakeRequests(&req, &reqInfo)
+
+	assert.Empty(t, err, "Errors array should be empty")
+	assert.Len(t, res, 3, "Three requests should be returned")
+
+	var error error
+	var reqData1 *openrtb.BidRequest
+	error = json.Unmarshal(res[0].Body, &reqData1)
+	assert.NoError(t, error, "Response body unmarshalling error should be nil")
+
+	var reqDataExt1 *appnexusReqExt
+	error = json.Unmarshal(reqData1.Ext, &reqDataExt1)
+	assert.NoError(t, error, "Response ext unmarshalling error should be nil")
+
+	var reqData2 *openrtb.BidRequest
+	error = json.Unmarshal(res[1].Body, &reqData2)
+	assert.NoError(t, error, "Response body unmarshalling error should be nil")
+
+	var reqDataExt2 *appnexusReqExt
+	error = json.Unmarshal(reqData2.Ext, &reqDataExt2)
+	assert.NoError(t, error, "Response ext unmarshalling error should be nil")
+
+	var reqData3 *openrtb.BidRequest
+	error = json.Unmarshal(res[2].Body, &reqData3)
+	assert.NoError(t, error, "Response body unmarshalling error should be nil")
+
+	var reqDataExt3 *appnexusReqExt
+	error = json.Unmarshal(reqData3.Ext, &reqDataExt3)
+	assert.NoError(t, error, "Response ext unmarshalling error should be nil")
+
+	adPodId1 := reqDataExt1.Appnexus.AdPodId
+	adPodId2 := reqDataExt2.Appnexus.AdPodId
+	adPodId3 := reqDataExt3.Appnexus.AdPodId
+
+	podIds := make(map[string]int)
+	podIds[adPodId1] = podIds[adPodId1] + 1
+	podIds[adPodId2] = podIds[adPodId2] + 1
+	podIds[adPodId3] = podIds[adPodId3] + 1
+
+	assert.Len(t, podIds, 2, "Incorrect number of unique pod ids")
+}
+
 // ----------------------------------------------------------------------------
 // Code below this line tests the legacy, non-openrtb code flow. It can be deleted after we
 // clean up the existing code and make everything openrtb.
diff --git a/adapters/appnexus/appnexusplatformtest/video/simple-video.json b/adapters/appnexus/appnexusplatformtest/video/simple-video.json
deleted file mode 100644
index 7ee192be..00000000
--- a/adapters/appnexus/appnexusplatformtest/video/simple-video.json
+++ /dev/null
@@ -1,132 +0,0 @@
-{
-	"mockBidRequest": {
-	  "id": "test-request-id",
-	  "imp": [
-		{
-		  "id": "test-imp-id",
-		  "video": {
-			"mimes": ["video/mp4"],
-			"minduration": 15,
-			"maxduration": 30,
-			"protocols": [2, 3, 5, 6, 7, 8],
-			"w": 940,
-			"h": 560
-		  },
-		  "ext": {
-			"bidder": {
-			  "placement_id": 1
-			}
-		  }
-		}
-	  ]
-	},
-
-	"httpCalls": [
-	  {
-		"expectedRequest": {
-		  "uri": "http://ib.adnxs.com/openrtb2",
-		  "body": {
-			"id": "test-request-id",
-			"ext": {
-				"appnexus": {
-				  "hb_source": 9
-				},
-				"prebid": {}
-			  },
-			"imp": [
-			  {
-				"id": "test-imp-id",
-				"video": {
-					"mimes": ["video/mp4"],
-					"minduration": 15,
-					"maxduration": 30,
-					"protocols": [2, 3, 5, 6, 7, 8],
-					"w": 940,
-					"h": 560
-				  },
-				"ext": {
-				  "appnexus": {
-					"placement_id": 1
-				  }
-				}
-			  }
-			]
-		  }
-		},
-		"mockResponse": {
-		  "status": 200,
-		  "body": {
-			"id": "test-request-id",
-			"seatbid": [
-			  {
-				"seat": "958",
-				"bid": [{
-				  "id": "7706636740145184841",
-				  "impid": "test-imp-id",
-				  "price": 0.500000,
-				  "adid": "29681110",
-				  "adm": "some-test-ad",
-				  "adomain": ["appnexus.com"],
-				  "iurl": "http://nym1-ib.adnxs.com/cr?id=29681110",
-				  "cid": "958",
-				  "crid": "29681110",
-				  "h": 250,
-					"w": 300,
-					"cat": ["IAB9-1"],
-				  "ext": {
-					"appnexus": {
-					  "brand_id": 9,
-						"brand_category_id": 9,
-					  "auction_id": 8189378542222915032,
-					  "bid_ad_type": 1,
-					  "bidder_id": 2,
-					  "ranking_price": 0.000000,
-					  "deal_priority": 5
-					}
-				  }
-				}]
-			  }
-			],
-			"bidid": "5778926625248726496",
-			"cur": "USD"
-		  }
-		}
-	  }
-	],
-
-	"expectedBidResponses": [
-	  {
-		"currency": "USD",
-		"bids": [
-		  {
-			"bid": {
-			  "id": "7706636740145184841",
-			  "impid": "test-imp-id",
-			  "price": 0.5,
-			  "adm": "some-test-ad",
-			  "adid": "29681110",
-			  "adomain": ["appnexus.com"],
-			  "iurl": "http://nym1-ib.adnxs.com/cr?id=29681110",
-			  "cid": "958",
-			  "crid": "29681110",
-			  "w": 300,
-			  "h": 250,
-			  "cat": ["IAB5-3"],
-			  "ext": {
-				"appnexus": {
-				  "brand_id": 9,
-					"brand_category_id": 9,
-				  "auction_id": 8189378542222915032,
-				  "bid_ad_type": 1,
-				  "bidder_id": 2,
-				  "ranking_price": 0.000000,
-				  "deal_priority": 5
-				}
-			  }
-			},
-			"type": "video"
-		  }
-		]
-	  }
-	]
-  }
\ No newline at end of file
diff --git a/config/config_test.go b/config/config_test.go
index 4774d9d6..3da3f721 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -14,6 +14,91 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func TestExternalCacheURLValidate(t *testing.T) {
+	testCases := []struct {
+		desc      string
+		data      ExternalCache
+		expErrors int
+	}{
+		{
+			desc:      "With http://",
+			data:      ExternalCache{Host: "http://www.google.com", Path: "/path/v1"},
+			expErrors: 1,
+		},
+		{
+			desc:      "Without http://",
+			data:      ExternalCache{Host: "www.google.com", Path: "/path/v1"},
+			expErrors: 0,
+		},
+		{
+			desc:      "No scheme but '//' prefix",
+			data:      ExternalCache{Host: "//www.google.com", Path: "/path/v1"},
+			expErrors: 1,
+		},
+		{
+			desc:      "// appears twice",
+			data:      ExternalCache{Host: "//www.google.com//", Path: "path/v1"},
+			expErrors: 1,
+		},
+		{
+			desc:      "Host has an only // value",
+			data:      ExternalCache{Host: "//", Path: "path/v1"},
+			expErrors: 1,
+		},
+		{
+			desc:      "only scheme host, valid path",
+			data:      ExternalCache{Host: "http://", Path: "/path/v1"},
+			expErrors: 1,
+		},
+		{
+			desc:      "No host, path only",
+			data:      ExternalCache{Host: "", Path: "path/v1"},
+			expErrors: 1,
+		},
+		{
+			desc:      "No host, nor path",
+			data:      ExternalCache{Host: "", Path: ""},
+			expErrors: 0,
+		},
+		{
+			desc:      "Invalid http at the end",
+			data:      ExternalCache{Host: "www.google.com", Path: "http://"},
+			expErrors: 1,
+		},
+		{
+			desc:      "Host has an unknown scheme",
+			data:      ExternalCache{Host: "unknownscheme://host", Path: "/path/v1"},
+			expErrors: 1,
+		},
+		{
+			desc:      "Wrong colon side in scheme",
+			data:      ExternalCache{Host: "http//:www.appnexus.com", Path: "/path/v1"},
+			expErrors: 1,
+		},
+		{
+			desc:      "Missing '/' in scheme",
+			data:      ExternalCache{Host: "http:/www.appnexus.com", Path: "/path/v1"},
+			expErrors: 1,
+		},
+		{
+			desc:      "host with scheme, no path",
+			data:      ExternalCache{Host: "http://www.appnexus.com", Path: ""},
+			expErrors: 1,
+		},
+		{
+			desc:      "scheme, no host nor path",
+			data:      ExternalCache{Host: "http://", Path: ""},
+			expErrors: 1,
+		},
+	}
+	for _, test := range testCases {
+		var errs configErrors
+		errs = test.data.validate(errs)
+
+		assert.Equal(t, test.expErrors, len(errs), "Test case threw unexpected number of errors. Desc: %s errMsg = %v \n", test.desc, errs)
+	}
+}
+
 func TestDefaults(t *testing.T) {
 	v := viper.New()
 	SetupViper(v, "")
@@ -66,7 +151,7 @@ cache:
   query: uuid=%PBS_CACHE_UUID%
 external_cache:
   host: www.externalprebidcache.net
-  path: endpoints/cache
+  path: /endpoints/cache
 http_client:
   max_connections_per_host: 10
   max_idle_connections: 500
@@ -223,7 +308,7 @@ func TestFullConfig(t *testing.T) {
 	cmpStrings(t, "cache.host", cfg.CacheURL.Host, "prebidcache.net")
 	cmpStrings(t, "cache.query", cfg.CacheURL.Query, "uuid=%PBS_CACHE_UUID%")
 	cmpStrings(t, "external_cache.host", cfg.ExtCacheURL.Host, "www.externalprebidcache.net")
-	cmpStrings(t, "external_cache.path", cfg.ExtCacheURL.Path, "endpoints/cache")
+	cmpStrings(t, "external_cache.path", cfg.ExtCacheURL.Path, "/endpoints/cache")
 	cmpInts(t, "http_client.max_connections_per_host", cfg.Client.MaxConnsPerHost, 10)
 	cmpInts(t, "http_client.max_idle_connections", cfg.Client.MaxIdleConns, 500)
 	cmpInts(t, "http_client.max_idle_connections_per_host", cfg.Client.MaxIdleConnsPerHost, 20)
diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go
index 545f04fd..a6f69f70 100644
--- a/exchange/exchange_test.go
+++ b/exchange/exchange_test.go
@@ -48,6 +48,9 @@ func TestNewExchange(t *testing.T) {
 			ExpectedTimeMillis: 20,
 		},
 		Adapters: blankAdapterConfig(openrtb_ext.BidderList()),
+		GDPR: config.GDPR{
+			EEACountries: []string{"FIN", "FRA", "GUF"},
+		},
 	}
 
 	currencyConverter := currencies.NewRateConverter(&http.Client{}, "", time.Duration(0))
@@ -909,6 +912,9 @@ func runSpec(t *testing.T, filename string, spec *exchangeSpec) {
 		LMT: config.LMT{
 			Enforce: spec.EnforceLMT,
 		},
+		GDPR: config.GDPR{
+			UsersyncIfAmbiguous: !spec.AssumeGDPRApplies,
+		},
 	}
 
 	ex := newExchangeForTests(t, filename, spec.OutgoingRequests, aliases, privacyConfig)
@@ -1026,15 +1032,22 @@ func newExchangeForTests(t *testing.T, filename string, expectations map[string]
 		}
 	}
 
+	var s struct{}
+	eeac := make(map[string]struct{})
+	for _, c := range []string{"FIN", "FRA", "GUF"} {
+		eeac[c] = s
+	}
+
 	return &exchange{
 		adapterMap:          adapters,
 		me:                  metricsConf.NewMetricsEngine(&config.Configuration{}, openrtb_ext.BidderList()),
 		cache:               &wellBehavedCache{},
 		cacheTime:           0,
-		gDPR:                gdpr.AlwaysAllow{},
+		gDPR:                gdpr.AlwaysFail{},
 		currencyConverter:   currencies.NewRateConverter(&http.Client{}, "", time.Duration(0)),
-		UsersyncIfAmbiguous: false,
+		UsersyncIfAmbiguous: privacyConfig.GDPR.UsersyncIfAmbiguous,
 		privacyConfig:       privacyConfig,
+		eeaCountries:        eeac,
 	}
 }
 
@@ -1882,12 +1895,13 @@ func TestUpdateHbPbCatDur(t *testing.T) {
 }
 
 type exchangeSpec struct {
-	IncomingRequest  exchangeRequest        `json:"incomingRequest"`
-	OutgoingRequests map[string]*bidderSpec `json:"outgoingRequests"`
-	Response         exchangeResponse       `json:"response,omitempty"`
-	EnforceCCPA      bool                   `json:"enforceCcpa"`
-	EnforceLMT       bool                   `json:"enforceLmt"`
-	DebugLog         *DebugLog              `json:"debuglog,omitempty"`
+	IncomingRequest   exchangeRequest        `json:"incomingRequest"`
+	OutgoingRequests  map[string]*bidderSpec `json:"outgoingRequests"`
+	Response          exchangeResponse       `json:"response,omitempty"`
+	EnforceCCPA       bool                   `json:"enforceCcpa"`
+	EnforceLMT        bool                   `json:"enforceLmt"`
+	AssumeGDPRApplies bool                   `json:"assume_gdpr_applies"`
+	DebugLog          *DebugLog              `json:"debuglog,omitempty"`
 }
 
 type exchangeRequest struct {
diff --git a/exchange/exchangetest/gdpr-geo-eu-off-device.json b/exchange/exchangetest/gdpr-geo-eu-off-device.json
new file mode 100644
index 00000000..fc655de8
--- /dev/null
+++ b/exchange/exchangetest/gdpr-geo-eu-off-device.json
@@ -0,0 +1,64 @@
+{
+    "assume_gdpr_applies": false,
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [{
+                "id": "my-imp-id",
+                "video": {
+                    "mimes": ["video/mp4"]
+                },
+                "ext": {
+                    "appnexus": {
+                        "placementId": 1
+                    }
+                }
+            }],
+            "user": {
+                "buyeruid": "some-buyer-id"
+            },
+            "device": {
+                "geo": {
+                    "country": "FRA"
+                }
+            }
+}
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "video": {
+                            "mimes": ["video/mp4"]
+                        },
+                        "ext": {
+                            "bidder": {
+                                "placementId": 1
+                            }
+                        }
+                    }],
+                    "user": {
+                    },
+                    "device": {
+                        "geo": {
+                            "country": "FRA"
+                        }
+                    }
+                },
+                "bidAdjustment": 1.0
+            },
+            "mockResponse": {
+                "errors": ["appnexus-error"]
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/exchange/exchangetest/gdpr-geo-eu-off.json b/exchange/exchangetest/gdpr-geo-eu-off.json
new file mode 100644
index 00000000..27a030f1
--- /dev/null
+++ b/exchange/exchangetest/gdpr-geo-eu-off.json
@@ -0,0 +1,60 @@
+{
+    "assume_gdpr_applies": false,
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [{
+                "id": "my-imp-id",
+                "video": {
+                    "mimes": ["video/mp4"]
+                },
+                "ext": {
+                    "appnexus": {
+                        "placementId": 1
+                    }
+                }
+            }],
+            "user": {
+                "buyeruid": "some-buyer-id",
+                "geo": {
+                    "country": "FRA"
+                }
+            }
+        }
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "video": {
+                            "mimes": ["video/mp4"]
+                        },
+                        "ext": {
+                            "bidder": {
+                                "placementId": 1
+                            }
+                        }
+                    }],
+                    "user": {
+                        "geo": {
+                            "country": "FRA"
+                        }
+                    }
+                },
+                "bidAdjustment": 1.0
+            },
+            "mockResponse": {
+                "errors": ["appnexus-error"]
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/exchange/exchangetest/gdpr-geo-eu-on.json b/exchange/exchangetest/gdpr-geo-eu-on.json
new file mode 100644
index 00000000..4ec42fc6
--- /dev/null
+++ b/exchange/exchangetest/gdpr-geo-eu-on.json
@@ -0,0 +1,60 @@
+{
+    "assume_gdpr_applies": true,
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [{
+                "id": "my-imp-id",
+                "video": {
+                    "mimes": ["video/mp4"]
+                },
+                "ext": {
+                    "appnexus": {
+                        "placementId": 1
+                    }
+                }
+            }],
+            "user": {
+                "buyeruid": "some-buyer-id",
+                "geo": {
+                    "country": "FRA"
+                }
+            }
+        }
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "video": {
+                            "mimes": ["video/mp4"]
+                        },
+                        "ext": {
+                            "bidder": {
+                                "placementId": 1
+                            }
+                        }
+                    }],
+                    "user": {
+                        "geo": {
+                            "country": "FRA"
+                        }
+                    }
+                },
+                "bidAdjustment": 1.0
+            },
+            "mockResponse": {
+                "errors": ["appnexus-error"]
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/exchange/exchangetest/gdpr-geo-usa-off.json b/exchange/exchangetest/gdpr-geo-usa-off.json
new file mode 100644
index 00000000..d56c9318
--- /dev/null
+++ b/exchange/exchangetest/gdpr-geo-usa-off.json
@@ -0,0 +1,61 @@
+{
+    "assume_gdpr_applies": false,
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [{
+                "id": "my-imp-id",
+                "video": {
+                    "mimes": ["video/mp4"]
+                },
+                "ext": {
+                    "appnexus": {
+                        "placementId": 1
+                    }
+                }
+            }],
+            "user": {
+                "buyeruid": "some-buyer-id",
+                "geo": {
+                    "country": "USA"
+                }
+            }
+        }
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "video": {
+                            "mimes": ["video/mp4"]
+                        },
+                        "ext": {
+                            "bidder": {
+                                "placementId": 1
+                            }
+                        }
+                    }],
+                    "user": {
+                        "buyeruid": "some-buyer-id",
+                        "geo": {
+                            "country": "USA"
+                        }
+                    }
+                },
+                "bidAdjustment": 1.0
+            },
+            "mockResponse": {
+                "errors": ["appnexus-error"]
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/exchange/exchangetest/gdpr-geo-usa-on.json b/exchange/exchangetest/gdpr-geo-usa-on.json
new file mode 100644
index 00000000..f922be9e
--- /dev/null
+++ b/exchange/exchangetest/gdpr-geo-usa-on.json
@@ -0,0 +1,61 @@
+{
+    "assume_gdpr_applies": true,
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [{
+                "id": "my-imp-id",
+                "video": {
+                    "mimes": ["video/mp4"]
+                },
+                "ext": {
+                    "appnexus": {
+                        "placementId": 1
+                    }
+                }
+            }],
+            "user": {
+                "buyeruid": "some-buyer-id",
+                "geo": {
+                    "country": "USA"
+                }
+            }
+        }
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "video": {
+                            "mimes": ["video/mp4"]
+                        },
+                        "ext": {
+                            "bidder": {
+                                "placementId": 1
+                            }
+                        }
+                    }],
+                    "user": {
+                        "buyeruid": "some-buyer-id",
+                        "geo": {
+                            "country": "USA"
+                        }
+                    }
+                },
+                "bidAdjustment": 1.0
+            },
+            "mockResponse": {
+                "errors": ["appnexus-error"]
+            }
+        }
+    }
+}
\ No newline at end of file